add multi select, add redownload

This commit is contained in:
Simon
2025-08-13 14:21:33 +07:00
parent 9c5e4b599f
commit 3cf9b5ed40
16 changed files with 485 additions and 182 deletions

View File

@@ -79,6 +79,7 @@ class AddToDownloadQuerySerializer(serializers.Serializer):
autostart = serializers.BooleanField(required=False) autostart = serializers.BooleanField(required=False)
flat = serializers.BooleanField(required=False) flat = serializers.BooleanField(required=False)
force = serializers.BooleanField(required=False)
class BulkUpdateDowloadQuerySerializer(serializers.Serializer): class BulkUpdateDowloadQuerySerializer(serializers.Serializer):

View File

@@ -109,6 +109,7 @@ class PendingList(PendingIndex):
task=None, task=None,
auto_start=False, auto_start=False,
flat=False, flat=False,
force=False,
): ):
super().__init__() super().__init__()
self.config = AppConfig().config self.config = AppConfig().config
@@ -116,6 +117,7 @@ class PendingList(PendingIndex):
self.task = task self.task = task
self.auto_start = auto_start self.auto_start = auto_start
self.flat = flat self.flat = flat
self.force = force
self.to_skip = False self.to_skip = False
self.missing_videos: list[dict] = [] self.missing_videos: list[dict] = []
self.added = 0 self.added = 0
@@ -173,10 +175,16 @@ class PendingList(PendingIndex):
PendingInteract(youtube_id=url, status="priority").update_status() PendingInteract(youtube_id=url, status="priority").update_status()
return None return None
if url in self.missing_videos or url in self.to_skip: if not self.force and (
url in self.missing_videos or url in self.to_skip
):
print(f"{url}: skipped adding already indexed video to download.") print(f"{url}: skipped adding already indexed video to download.")
return None return None
if self.force and url in self.all_ignored or url in self.all_pending:
print(f"{url}: skipped adding force video already in queue.")
return None
to_add = self._parse_video(url, vid_type) to_add = self._parse_video(url, vid_type)
if to_add: if to_add:
self.missing_videos.append(to_add) self.missing_videos.append(to_add)

View File

@@ -118,12 +118,15 @@ class DownloadApiListView(ApiBaseView):
auto_start = validated_query.get("autostart") auto_start = validated_query.get("autostart")
flat = validated_query.get("flat", False) flat = validated_query.get("flat", False)
print(f"auto_start: {auto_start}, flat: {flat}") force = validated_query.get("force", False)
print(f"auto_start: {auto_start}, flat: {flat}, force: {force}")
to_add = validated_data["data"] to_add = validated_data["data"]
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"] pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
url_str = " ".join(pending) url_str = " ".join(pending)
task = extrac_dl.delay(url_str, auto_start=auto_start, flat=flat) task = extrac_dl.delay(
url_str, auto_start=auto_start, flat=flat, force=force
)
message = { message = {
"message": "add to queue task started", "message": "add to queue task started",
@@ -153,15 +156,12 @@ class DownloadApiListView(ApiBaseView):
query_serializer.is_valid(raise_exception=True) query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data validated_query = query_serializer.validated_data
status_filter = validated_query.get("filter") status_filter = validated_query.get("filter")
channel = validated_query.get("channel")
vid_type = validated_query.get("vid_type")
error = validated_query.get("error")
PendingInteract(status=status_filter).update_bulk( PendingInteract(status=status_filter).update_bulk(
channel_id=channel, channel_id=validated_query.get("channel"),
vid_type=vid_type, vid_type=validated_query.get("vid_type"),
new_status=new_status, new_status=validated_data["status"],
error=error, error=validated_query.get("error"),
) )
if new_status == "priority": if new_status == "priority":

View File

@@ -16,7 +16,7 @@ from celery import Task, shared_task
from celery.exceptions import Retry from celery.exceptions import Retry
from channel.src.index import YoutubeChannel from channel.src.index import YoutubeChannel
from common.src.ta_redis import RedisArchivist from common.src.ta_redis import RedisArchivist
from common.src.urlparser import Parser from common.src.urlparser import ParsedURLType, Parser
from download.src.queue import PendingList from download.src.queue import PendingList
from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner from download.src.subscriptions import SubscriptionHandler, SubscriptionScanner
from download.src.thumbnails import ThumbFilesystem, ThumbValidator from download.src.thumbnails import ThumbFilesystem, ThumbValidator
@@ -150,8 +150,13 @@ def download_pending(self, auto_only=False):
@shared_task(name="extract_download", bind=True, base=BaseTask) @shared_task(name="extract_download", bind=True, base=BaseTask)
def extrac_dl( def extrac_dl(
self, youtube_ids, auto_start=False, flat=False, status="pending" self,
): youtube_ids: str | list[ParsedURLType],
auto_start: bool = False,
flat: bool = False,
force: bool = False,
status: str = "pending",
) -> str | None:
"""parse list passed and add to pending""" """parse list passed and add to pending"""
TaskManager().init(self) TaskManager().init(self)
if isinstance(youtube_ids, str): if isinstance(youtube_ids, str):
@@ -160,7 +165,11 @@ def extrac_dl(
to_add = youtube_ids to_add = youtube_ids
pending_handler = PendingList( pending_handler = PendingList(
youtube_ids=to_add, task=self, auto_start=auto_start, flat=flat youtube_ids=to_add,
task=self,
auto_start=auto_start,
flat=flat,
force=force,
) )
videos_added = pending_handler.parse_url_list(status=status) videos_added = pending_handler.parse_url_list(status=status)

View File

@@ -1,9 +1,41 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg
viewBox="131 -131 512 512" style="enable-background:new 131 -131 512 512;" xml:space="preserve"> version="1.1"
<g id="XMLID_2_"> id="Layer_1"
<path id="XMLID_4_" d="M640.9-116.5c3.7,10.2,2.8,18.6-4.7,25.1L456.6,87.4v270c0,10.2-4.7,17.7-14,21.4c-2.8,0.9-6.5,1.9-9.3,1.9 x="0px"
c-6.5,0-12.1-1.9-16.8-6.5L323.4,281c-4.7-4.7-6.5-10.2-6.5-16.8V87.4L138.2-91.4c-7.4-7.4-9.3-15.8-4.7-25.1 y="0px"
c3.7-9.3,11.2-14,21.4-14h464.5C629.7-131.4,636.2-126.7,640.9-116.5z"/> viewBox="131 -131 512 512"
style="enable-background:new 131 -131 512 512;"
xml:space="preserve"
sodipodi:docname="icon-filter.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.6130873"
inkscape:cx="122.12606"
inkscape:cy="215.73537"
inkscape:window-width="2532"
inkscape:window-height="1379"
inkscape:window-x="1932"
inkscape:window-y="45"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />
<g
id="XMLID_2_"
transform="matrix(0.71904421,0,0,0.71904421,108.73394,35.132323)">
<path
id="XMLID_4_"
d="m 640.9,-116.5 c 3.7,10.2 2.8,18.6 -4.7,25.1 L 456.6,87.4 v 270 c 0,10.2 -4.7,17.7 -14,21.4 -2.8,0.9 -6.5,1.9 -9.3,1.9 -6.5,0 -12.1,-1.9 -16.8,-6.5 L 323.4,281 c -4.7,-4.7 -6.5,-10.2 -6.5,-16.8 V 87.4 L 138.2,-91.4 c -7.4,-7.4 -9.3,-15.8 -4.7,-25.1 3.7,-9.3 11.2,-14 21.4,-14 h 464.5 c 10.3,-0.9 16.8,3.8 21.5,14 z" />
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 603 B

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 512 512"
version="1.1"
id="svg1"
sodipodi:docname="icon-multi-select.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.0722656"
inkscape:cx="255.75872"
inkscape:cy="255.75872"
inkscape:window-width="2560"
inkscape:window-height="1407"
inkscape:window-x="1920"
inkscape:window-y="33"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!--! Font Awesome Free 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2022 Fonticons, Inc. -->
<path
d="m 55.494816,230.93685 c 0,-27.64778 22.43935,-50.12629 50.126294,-50.12629 h 50.1263 v 100.25259 c 0,41.51084 32.9737,75.18944 75.18944,75.18944 h 100.25259 v 50.1263 c 0,27.64778 -22.47851,50.12629 -50.12629,50.12629 H 105.62111 c -27.686944,0 -50.126294,-22.47851 -50.126294,-50.12629 z M 230.93685,331.18944 c -27.64778,0 -50.12629,-22.47851 -50.12629,-50.12629 V 105.62111 c 0,-27.686944 22.47851,-50.126294 50.12629,-50.126294 h 175.44204 c 27.64778,0 50.12629,22.43935 50.12629,50.126294 v 175.44204 c 0,27.64778 -22.47851,50.12629 -50.12629,50.12629 z"
id="path1"
style="stroke-width:0.783223" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,11 +1,18 @@
import APIClient from '../../functions/APIClient'; import APIClient from '../../functions/APIClient';
const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean, flat: boolean) => { type UpdateDownloadQueueType = {
youtubeIdStrings: string;
autostart?: boolean;
flat?: boolean;
force?: boolean;
};
const updateDownloadQueue = async (params: UpdateDownloadQueueType) => {
const urls = []; const urls = [];
const containsMultiple = youtubeIdStrings.includes('\n'); const containsMultiple = params.youtubeIdStrings.includes('\n');
if (containsMultiple) { if (containsMultiple) {
const youtubeIds = youtubeIdStrings.split('\n'); const youtubeIds = params.youtubeIdStrings.split('\n');
youtubeIds.forEach(youtubeId => { youtubeIds.forEach(youtubeId => {
if (youtubeId.trim()) { if (youtubeId.trim()) {
@@ -13,12 +20,13 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean,
} }
}); });
} else { } else {
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' }); urls.push({ youtube_id: params.youtubeIdStrings, status: 'pending' });
} }
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (autostart) searchParams.append('autostart', 'true'); if (params.autostart === true) searchParams.append('autostart', 'true');
if (flat) searchParams.append('flat', 'true'); if (params.flat === true) searchParams.append('flat', 'true');
if (params.force === true) searchParams.append('force', 'true');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(endpoint, { return APIClient(endpoint, {

View File

@@ -6,6 +6,7 @@ import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg'; import iconListView from '/img/icon-listview.svg';
import iconTableView from '/img/icon-tableview.svg'; import iconTableView from '/img/icon-tableview.svg';
import iconFilter from '/img/icon-filter.svg'; import iconFilter from '/img/icon-filter.svg';
import iconMultiSelect from '/img/icon-multi-select.svg';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
import { ViewStyleNamesType, ViewStylesEnum } from '../configuration/constants/ViewStyle'; import { ViewStyleNamesType, ViewStylesEnum } from '../configuration/constants/ViewStyle';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig'; import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
@@ -17,6 +18,9 @@ import {
VideoTypes, VideoTypes,
} from '../api/loader/loadVideoListByPage'; } from '../api/loader/loadVideoListByPage';
import { useFilterBarTempConf } from '../stores/FilterbarTempConf'; import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
import Button from './Button';
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
type FilterbarProps = { type FilterbarProps = {
viewStyle: ViewStyleNamesType; viewStyle: ViewStyleNamesType;
@@ -26,6 +30,14 @@ type FilterbarProps = {
const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: FilterbarProps) => { const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: FilterbarProps) => {
const { userConfig, setUserConfig } = useUserConfigStore(); const { userConfig, setUserConfig } = useUserConfigStore();
const {
selectedVideoIds,
clearSelected,
showSelection,
setShowSelection,
selectedAction,
setSelectedAction,
} = useVideoSelectionStore();
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const { filterHeight, setFilterHeight, showFilterItems, setShowFilterItems } = const { filterHeight, setFilterHeight, showFilterItems, setShowFilterItems } =
@@ -55,141 +67,209 @@ const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: Filte
} }
}; };
const redownloadSelected = async (ids: string[]) => {
updateDownloadQueue({
youtubeIdStrings: ids.join(' '),
autostart: true,
force: true,
});
};
const actionList = [
{
label: 'Redownload',
handler: redownloadSelected,
},
];
const handleActionSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value === '') {
setSelectedAction(null);
} else {
setSelectedAction(actionList[Number(value)].handler);
}
};
const handleSelectedActionRun = async () => {
if (selectedAction) {
selectedAction(selectedVideoIds);
setSelectedAction(null);
clearSelected();
setShowSelection(false);
}
};
return ( return (
<div className="view-controls three"> <>
<div className="view-icons"> <div className="view-controls">
{showFilterItems && ( <div className="view-icons">
<> <img
<select alt="icon multi select"
value={userConfig.hide_watched === null ? '' : userConfig.hide_watched.toString()} src={iconMultiSelect}
onChange={event => { onClick={() => setShowSelection(!showSelection)}
handleUserConfigUpdate({ title={showSelection ? 'Hide multi select boxes' : 'Show multi select boxes'}
hide_watched: event.target.value === '' ? null : event.target.value === 'true', />
}); </div>
}} <div className="view-icons">
> {showFilterItems && (
<option value="">All watched state</option> <>
<option value="true">Watched only</option> <span>Filter:</span>
<option value="false">Unwatched only</option>
</select>
{showTypeFilter && (
<select <select
value={userConfig.vid_type_filter === null ? '' : userConfig.vid_type_filter} value={userConfig.hide_watched === null ? '' : userConfig.hide_watched.toString()}
onChange={event => { onChange={event => {
handleUserConfigUpdate({ handleUserConfigUpdate({
vid_type_filter: hide_watched: event.target.value === '' ? null : event.target.value === 'true',
event.target.value === '' ? null : (event.target.value as VideoTypes),
}); });
}} }}
> >
<option value="">All Types</option> <option value="">All watched state</option>
<option value="videos">Videos</option> <option value="true">Watched only</option>
<option value="streams">Streams</option> <option value="false">Unwatched only</option>
<option value="shorts">Shorts</option>
</select> </select>
)} {showTypeFilter && (
<input <select
placeholder="height in px" value={userConfig.vid_type_filter === null ? '' : userConfig.vid_type_filter}
value={filterHeight} onChange={event => {
onChange={e => setFilterHeight(e.target.value)} handleUserConfigUpdate({
vid_type_filter:
event.target.value === '' ? null : (event.target.value as VideoTypes),
});
}}
>
<option value="">All Types</option>
<option value="videos">Videos</option>
<option value="streams">Streams</option>
<option value="shorts">Shorts</option>
</select>
)}
<input
placeholder="height in px"
value={filterHeight}
onChange={e => setFilterHeight(e.target.value)}
/>
</>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
</div>
<div className="view-icons">
{showHidden && (
<div className="sort">
<span>Sort:</span>
<select
name="sort_by"
id="sort"
value={userConfig.sort_by}
onChange={event => {
handleUserConfigUpdate({ sort_by: event.target.value as SortByType });
}}
>
{Object.entries(SortByEnum).map(([key, value], idx) => {
return (
<option key={idx} value={value}>
{key}
</option>
);
})}
</select>
<select
name="sort_order"
id="sort-order"
value={userConfig.sort_order}
onChange={event => {
handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType });
}}
>
{Object.entries(SortOrderEnum).map(([key, value], idx) => {
return (
<option key={idx} value={value}>
{key}
</option>
);
})}
</select>
</div>
)}
{showSort && (
<img
src={iconSort}
alt="sort-icon"
onClick={() => {
setShowHidden(!showHidden);
}}
id="animate-icon"
/> />
</> )}
)} </div>
<img <div className="view-icons">
src={iconFilter} {userConfig.grid_items !== undefined && isGridView && (
alt="icon filter" <div className="grid-count">
onClick={() => setShowFilterItems(!showFilterItems)} {userConfig.grid_items < 7 && (
/> <img
src={iconAdd}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 });
}}
alt="grid plus row"
/>
)}
{userConfig.grid_items > 3 && (
<img
src={iconSubstract}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 });
}}
alt="grid minus row"
/>
)}
</div>
)}
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
}}
alt="table view"
/>
</div>
</div> </div>
{showSelection && (
{showHidden && ( <div className="info-box-item">
<div className="sort"> <p>
<div id="form"> Selected Videos: {selectedVideoIds.length} -{' '}
<span>Sort by:</span> <Button onClick={clearSelected}>Clear</Button>
<select </p>
name="sort_by" <p>Apply action:</p>
id="sort" <select onChange={handleActionSelectChange}>
value={userConfig.sort_by} <option value="">---</option>
onChange={event => { {actionList.map((actionItem, idx) => (
handleUserConfigUpdate({ sort_by: event.target.value as SortByType }); <option key={idx} value={idx}>
}} {actionItem.label}
> </option>
{Object.entries(SortByEnum).map(([key, value]) => { ))}
return <option value={value}>{key}</option>; </select>
})} {selectedAction !== null && <Button onClick={handleSelectedActionRun}>Apply</Button>}
</select>
<select
name="sort_order"
id="sort-order"
value={userConfig.sort_order}
onChange={event => {
handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType });
}}
>
{Object.entries(SortOrderEnum).map(([key, value]) => {
return <option value={value}>{key}</option>;
})}
</select>
</div>
</div> </div>
)} )}
<div className="view-icons"> </>
{showSort && (
<img
src={iconSort}
alt="sort-icon"
onClick={() => {
setShowHidden(!showHidden);
}}
id="animate-icon"
/>
)}
{userConfig.grid_items !== undefined && isGridView && (
<div className="grid-count">
{userConfig.grid_items < 7 && (
<img
src={iconAdd}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 });
}}
alt="grid plus row"
/>
)}
{userConfig.grid_items > 3 && (
<img
src={iconSubstract}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 });
}}
alt="grid minus row"
/>
)}
</div>
)}
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
}}
alt="table view"
/>
</div>
</div>
); );
}; };

View File

@@ -4,6 +4,8 @@ import { VideoType } from '../pages/Home';
import iconPlay from '/img/icon-play.svg'; import iconPlay from '/img/icon-play.svg';
import iconDotMenu from '/img/icon-dot-menu.svg'; import iconDotMenu from '/img/icon-dot-menu.svg';
import iconClose from '/img/icon-close.svg'; import iconClose from '/img/icon-close.svg';
import iconChecked from '/img/icon-seen.svg';
import iconUnchecked from '/img/icon-unseen.svg';
import updateWatchedState from '../api/actions/updateWatchedState'; import updateWatchedState from '../api/actions/updateWatchedState';
import formatDate from '../functions/formatDates'; import formatDate from '../functions/formatDates';
import WatchedCheckBox from './WatchedCheckBox'; import WatchedCheckBox from './WatchedCheckBox';
@@ -12,6 +14,7 @@ import { useState } from 'react';
import deleteVideoProgressById from '../api/actions/deleteVideoProgressById'; import deleteVideoProgressById from '../api/actions/deleteVideoProgressById';
import VideoThumbnail from './VideoThumbail'; import VideoThumbnail from './VideoThumbail';
import { ViewStylesType } from '../configuration/constants/ViewStyle'; import { ViewStylesType } from '../configuration/constants/ViewStyle';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
type VideoListItemProps = { type VideoListItemProps = {
video: VideoType; video: VideoType;
@@ -31,6 +34,8 @@ const VideoListItem = ({
const [, setSearchParams] = useSearchParams(); const [, setSearchParams] = useSearchParams();
const [showReorderMenu, setShowReorderMenu] = useState(false); const [showReorderMenu, setShowReorderMenu] = useState(false);
const { selectedVideoIds, appendVideoId, removeVideoId, showSelection } =
useVideoSelectionStore();
if (!video) { if (!video) {
return <p>No video found.</p>; return <p>No video found.</p>;
@@ -38,6 +43,19 @@ const VideoListItem = ({
return ( return (
<div className={`video-item ${viewStyle}`}> <div className={`video-item ${viewStyle}`}>
{showSelection && (
<div className="video-item-select-wrapper">
<img
src={selectedVideoIds.includes(video.youtube_id) ? iconChecked : iconUnchecked}
className="video-item-select"
onClick={() =>
selectedVideoIds.includes(video.youtube_id)
? removeVideoId(video.youtube_id)
: appendVideoId(video.youtube_id)
}
/>
</div>
)}
<a <a
onClick={() => { onClick={() => {
setSearchParams(params => { setSearchParams(params => {
@@ -75,28 +93,32 @@ const VideoListItem = ({
</a> </a>
<div className={`video-desc ${viewStyle}`}> <div className={`video-desc ${viewStyle}`}>
<div className="video-desc-player" id={`video-info-${video.youtube_id}`}> <div className="video-desc-player" id={`video-info-${video.youtube_id}`}>
<WatchedCheckBox {!showSelection && (
watched={video.player.watched} <>
onClick={async status => { <WatchedCheckBox
await updateWatchedState({ watched={video.player.watched}
id: video.youtube_id, onClick={async status => {
is_watched: status, await updateWatchedState({
}); id: video.youtube_id,
}} is_watched: status,
onDone={() => { });
refreshVideoList(true); }}
}} onDone={() => {
/> refreshVideoList(true);
{video.player.progress && ( }}
<img />
src={iconClose} {video.player.progress && (
className="video-popup-menu-close-button" <img
title="Delete watch progress" src={iconClose}
onClick={async () => { className="video-popup-menu-close-button"
await deleteVideoProgressById(video.youtube_id); title="Delete watch progress"
refreshVideoList(true); onClick={async () => {
}} await deleteVideoProgressById(video.youtube_id);
/> refreshVideoList(true);
}}
/>
)}
</>
)} )}
<span> <span>
{formatDate(video.published)} | {video.player.duration_str} {formatDate(video.published)} | {video.player.duration_str}

View File

@@ -5,6 +5,9 @@ import { ViewStylesType } from '../configuration/constants/ViewStyle';
import humanFileSize from '../functions/humanFileSize'; import humanFileSize from '../functions/humanFileSize';
import { FileSizeUnits } from '../api/actions/updateUserConfig'; import { FileSizeUnits } from '../api/actions/updateUserConfig';
import { useUserConfigStore } from '../stores/UserConfigStore'; import { useUserConfigStore } from '../stores/UserConfigStore';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
import iconChecked from '/img/icon-seen.svg';
import iconUnchecked from '/img/icon-unseen.svg';
const StreamsTypeEmun = { const StreamsTypeEmun = {
Video: 'video', Video: 'video',
@@ -18,6 +21,8 @@ type VideoListItemProps = {
const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => { const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
const { userConfig } = useUserConfigStore(); const { userConfig } = useUserConfigStore();
const { showSelection, selectedVideoIds, removeVideoId, appendVideoId } =
useVideoSelectionStore();
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric; const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
@@ -26,8 +31,9 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Channel</th> {showSelection && <th />}
<th>Title</th> <th>Title</th>
<th>Channel</th>
<th>Type</th> <th>Type</th>
<th>Resolution</th> <th>Resolution</th>
<th>Media size</th> <th>Media size</th>
@@ -45,12 +51,25 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
return ( return (
<tr key={youtube_id}> <tr key={youtube_id}>
<td className="no-nowrap"> {showSelection && (
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link> <td>
</td> <img
src={selectedVideoIds.includes(youtube_id) ? iconChecked : iconUnchecked}
className="video-item-select"
onClick={() =>
selectedVideoIds.includes(youtube_id)
? removeVideoId(youtube_id)
: appendVideoId(youtube_id)
}
/>
</td>
)}
<td className="no-nowrap title"> <td className="no-nowrap title">
<Link to={Routes.Video(youtube_id)}>{title}</Link> <Link to={Routes.Video(youtube_id)}>{title}</Link>
</td> </td>
<td className="no-nowrap">
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
</td>
<td>{vid_type}</td> <td>{vid_type}</td>
<td>{`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}</td> <td>{`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}</td>
<td>{humanFileSize(media_size, useSiUnits)}</td> <td>{humanFileSize(media_size, useSiUnits)}</td>

View File

@@ -65,7 +65,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric; const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
const viewStyle = userConfig.view_style_home; const viewStyle = userConfig.view_style_home;
const isGridView = viewStyle === ViewStylesEnum.Grid; const isGridView = viewStyle === ViewStylesEnum.Grid || ViewStylesEnum.Table;
const gridView = isGridView ? `boxed-${userConfig.grid_items}` : ''; const gridView = isGridView ? `boxed-${userConfig.grid_items}` : '';
const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : ''; const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';

View File

@@ -64,6 +64,7 @@ const Download = () => {
const [showHiddenForm, setShowHiddenForm] = useState(false); const [showHiddenForm, setShowHiddenForm] = useState(false);
const [addAsAutoStart, setAddAsAutoStart] = useState(false); const [addAsAutoStart, setAddAsAutoStart] = useState(false);
const [addAsFlat, setAddAsFlat] = useState(false); const [addAsFlat, setAddAsFlat] = useState(false);
const [addAsForce, setAddAsForce] = useState(false);
const [showQueueActions, setShowQueueActions] = useState(false); const [showQueueActions, setShowQueueActions] = useState(false);
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -249,6 +250,26 @@ const Download = () => {
</div> </div>
<span>Fast add</span> <span>Fast add</span>
</div> </div>
<div className="toggle">
<div className="toggleBox">
<input
id="add_force"
type="checkbox"
checked={addAsForce}
onChange={() => setAddAsForce(!addAsForce)}
/>
{addAsForce ? (
<label htmlFor="" className="onbtn">
On
</label>
) : (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
</div>
<span>Re-Download</span>
</div>
<div className="toggle"> <div className="toggle">
<div className="toggleBox"> <div className="toggleBox">
<input <input
@@ -280,7 +301,12 @@ const Download = () => {
label="Add to queue" label="Add to queue"
onClick={async () => { onClick={async () => {
if (downloadQueueText.trim()) { if (downloadQueueText.trim()) {
await updateDownloadQueue(downloadQueueText, addAsAutoStart, addAsFlat); await updateDownloadQueue({
youtubeIdStrings: downloadQueueText,
autostart: addAsAutoStart,
flat: addAsFlat,
force: addAsForce,
});
setDownloadQueueText(''); setDownloadQueueText('');
setRefresh(true); setRefresh(true);
setShowHiddenForm(false); setShowHiddenForm(false);

View File

@@ -130,7 +130,7 @@ const Home = () => {
const hasVideos = videoResponseData?.data?.length !== 0; const hasVideos = videoResponseData?.data?.length !== 0;
const isGridView = userConfig.view_style_home === ViewStylesEnum.Grid; const isGridView = userConfig.view_style_home === ViewStylesEnum.Grid || ViewStylesEnum.Table;
const gridView = isGridView ? `boxed-${userConfig.grid_items}` : ''; const gridView = isGridView ? `boxed-${userConfig.grid_items}` : '';
const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : ''; const gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';
const isTableView = userConfig.view_style_home === ViewStylesEnum.Table; const isTableView = userConfig.view_style_home === ViewStylesEnum.Table;

View File

@@ -75,7 +75,7 @@ const Playlist = () => {
const viewStyle = userConfig.view_style_home; // its a list of videos, so view_style_home const viewStyle = userConfig.view_style_home; // its a list of videos, so view_style_home
const gridItems = userConfig.grid_items; const gridItems = userConfig.grid_items;
const isGridView = viewStyle === ViewStylesEnum.Grid; const isGridView = viewStyle === ViewStylesEnum.Grid || ViewStylesEnum.Table;
const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : '';

View File

@@ -0,0 +1,37 @@
import { create } from 'zustand';
interface SelectionState {
selectedVideoIds: string[];
appendVideoId: (id: string) => void;
removeVideoId: (id: string) => void;
clearSelected: () => void;
showSelection: boolean;
setShowSelection: (showSelection: boolean) => void;
selectedAction: ((ids: string[]) => void) | null;
setSelectedAction: (fn: ((ids: string[]) => void) | null) => void;
}
export const useVideoSelectionStore = create<SelectionState>(set => ({
selectedVideoIds: [],
appendVideoId: id =>
set(state => {
if (state.selectedVideoIds.includes(id)) {
return state; // avoid duplicates
}
return { selectedVideoIds: [...state.selectedVideoIds, id] };
}),
removeVideoId: id =>
set(state => ({
selectedVideoIds: state.selectedVideoIds.filter(item => item !== id),
})),
clearSelected: () => set({ selectedVideoIds: [] }),
showSelection: false,
setShowSelection: (showSelection: boolean) => set({ showSelection }),
selectedAction: null,
setSelectedAction: fn => set({ selectedAction: fn }),
}));

View File

@@ -178,6 +178,11 @@ button:disabled:hover {
min-width: 100px; min-width: 100px;
} }
.video-item.table td img {
width: 20px;
height: 20px;
}
.button-box { .button-box {
padding: 5px 2px; padding: 5px 2px;
display: inline-flex; display: inline-flex;
@@ -423,8 +428,9 @@ button:disabled:hover {
} }
.view-controls { .view-controls {
display: grid; display: flex;
grid-template-columns: 1fr auto auto; flex-wrap: wrap;
justify-content: end;
border-bottom: 2px solid; border-bottom: 2px solid;
border-color: var(--accent-font-dark); border-color: var(--accent-font-dark);
margin: 15px 0; margin: 15px 0;
@@ -433,6 +439,7 @@ button:disabled:hover {
.view-icons, .view-icons,
.grid-count { .grid-count {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: end; justify-content: end;
align-items: center; align-items: center;
} }
@@ -575,6 +582,7 @@ video:-webkit-full-screen {
.video-item { .video-item {
overflow: hidden; overflow: hidden;
position: relative;
} }
.video-item:hover .video-tags { .video-item:hover .video-tags {
@@ -588,6 +596,23 @@ video:-webkit-full-screen {
align-items: center; align-items: center;
} }
.video-item-select-wrapper {
background-color: var(--highlight-bg);
z-index: 10;
position: absolute;
padding: 5px;
top: 0px;
left: 0px;
width: 20px;
height: 20px;
}
.video-item-select {
width: 100%;
cursor: pointer;
filter: var(--img-filter);
}
.video-progress-bar, .video-progress-bar,
.notification-progress-bar { .notification-progress-bar {
position: absolute; position: absolute;