add multi select, add redownload

This commit is contained in:
Simon 2025-08-13 14:21:33 +07:00
parent 9c5e4b599f
commit 3cf9b5ed40
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
16 changed files with 485 additions and 182 deletions

View File

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

View File

@ -109,6 +109,7 @@ class PendingList(PendingIndex):
task=None,
auto_start=False,
flat=False,
force=False,
):
super().__init__()
self.config = AppConfig().config
@ -116,6 +117,7 @@ class PendingList(PendingIndex):
self.task = task
self.auto_start = auto_start
self.flat = flat
self.force = force
self.to_skip = False
self.missing_videos: list[dict] = []
self.added = 0
@ -173,10 +175,16 @@ class PendingList(PendingIndex):
PendingInteract(youtube_id=url, status="priority").update_status()
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.")
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)
if to_add:
self.missing_videos.append(to_add)

View File

@ -118,12 +118,15 @@ class DownloadApiListView(ApiBaseView):
auto_start = validated_query.get("autostart")
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"]
pending = [i["youtube_id"] for i in to_add if i["status"] == "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": "add to queue task started",
@ -153,15 +156,12 @@ class DownloadApiListView(ApiBaseView):
query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data
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(
channel_id=channel,
vid_type=vid_type,
new_status=new_status,
error=error,
channel_id=validated_query.get("channel"),
vid_type=validated_query.get("vid_type"),
new_status=validated_data["status"],
error=validated_query.get("error"),
)
if new_status == "priority":

View File

@ -16,7 +16,7 @@ from celery import Task, shared_task
from celery.exceptions import Retry
from channel.src.index import YoutubeChannel
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.subscriptions import SubscriptionHandler, SubscriptionScanner
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)
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"""
TaskManager().init(self)
if isinstance(youtube_ids, str):
@ -160,7 +165,11 @@ def extrac_dl(
to_add = youtube_ids
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)

View File

@ -1,9 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<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"
viewBox="131 -131 512 512" style="enable-background:new 131 -131 512 512;" xml:space="preserve">
<g id="XMLID_2_">
<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
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
c3.7-9.3,11.2-14,21.4-14h464.5C629.7-131.4,636.2-126.7,640.9-116.5z"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
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>
</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';
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 containsMultiple = youtubeIdStrings.includes('\n');
const containsMultiple = params.youtubeIdStrings.includes('\n');
if (containsMultiple) {
const youtubeIds = youtubeIdStrings.split('\n');
const youtubeIds = params.youtubeIdStrings.split('\n');
youtubeIds.forEach(youtubeId => {
if (youtubeId.trim()) {
@ -13,12 +20,13 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean,
}
});
} else {
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' });
urls.push({ youtube_id: params.youtubeIdStrings, status: 'pending' });
}
const searchParams = new URLSearchParams();
if (autostart) searchParams.append('autostart', 'true');
if (flat) searchParams.append('flat', 'true');
if (params.autostart === true) searchParams.append('autostart', '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()}` : ''}`;
return APIClient(endpoint, {

View File

@ -6,6 +6,7 @@ import iconGridView from '/img/icon-gridview.svg';
import iconListView from '/img/icon-listview.svg';
import iconTableView from '/img/icon-tableview.svg';
import iconFilter from '/img/icon-filter.svg';
import iconMultiSelect from '/img/icon-multi-select.svg';
import { useUserConfigStore } from '../stores/UserConfigStore';
import { ViewStyleNamesType, ViewStylesEnum } from '../configuration/constants/ViewStyle';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
@ -17,6 +18,9 @@ import {
VideoTypes,
} from '../api/loader/loadVideoListByPage';
import { useFilterBarTempConf } from '../stores/FilterbarTempConf';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
import Button from './Button';
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
type FilterbarProps = {
viewStyle: ViewStyleNamesType;
@ -26,6 +30,14 @@ type FilterbarProps = {
const Filterbar = ({ viewStyle, showSort = true, showTypeFilter = false }: FilterbarProps) => {
const { userConfig, setUserConfig } = useUserConfigStore();
const {
selectedVideoIds,
clearSelected,
showSelection,
setShowSelection,
selectedAction,
setSelectedAction,
} = useVideoSelectionStore();
const [showHidden, setShowHidden] = useState(false);
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 (
<div className="view-controls three">
<div className="view-icons">
{showFilterItems && (
<>
<select
value={userConfig.hide_watched === null ? '' : userConfig.hide_watched.toString()}
onChange={event => {
handleUserConfigUpdate({
hide_watched: event.target.value === '' ? null : event.target.value === 'true',
});
}}
>
<option value="">All watched state</option>
<option value="true">Watched only</option>
<option value="false">Unwatched only</option>
</select>
{showTypeFilter && (
<>
<div className="view-controls">
<div className="view-icons">
<img
alt="icon multi select"
src={iconMultiSelect}
onClick={() => setShowSelection(!showSelection)}
title={showSelection ? 'Hide multi select boxes' : 'Show multi select boxes'}
/>
</div>
<div className="view-icons">
{showFilterItems && (
<>
<span>Filter:</span>
<select
value={userConfig.vid_type_filter === null ? '' : userConfig.vid_type_filter}
value={userConfig.hide_watched === null ? '' : userConfig.hide_watched.toString()}
onChange={event => {
handleUserConfigUpdate({
vid_type_filter:
event.target.value === '' ? null : (event.target.value as VideoTypes),
hide_watched: event.target.value === '' ? null : event.target.value === 'true',
});
}}
>
<option value="">All Types</option>
<option value="videos">Videos</option>
<option value="streams">Streams</option>
<option value="shorts">Shorts</option>
<option value="">All watched state</option>
<option value="true">Watched only</option>
<option value="false">Unwatched only</option>
</select>
)}
<input
placeholder="height in px"
value={filterHeight}
onChange={e => setFilterHeight(e.target.value)}
{showTypeFilter && (
<select
value={userConfig.vid_type_filter === null ? '' : userConfig.vid_type_filter}
onChange={event => {
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"
/>
</>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
)}
</div>
<div className="view-icons">
{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>
{showHidden && (
<div className="sort">
<div id="form">
<span>Sort by:</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]) => {
return <option 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]) => {
return <option value={value}>{key}</option>;
})}
</select>
</div>
{showSelection && (
<div className="info-box-item">
<p>
Selected Videos: {selectedVideoIds.length} -{' '}
<Button onClick={clearSelected}>Clear</Button>
</p>
<p>Apply action:</p>
<select onChange={handleActionSelectChange}>
<option value="">---</option>
{actionList.map((actionItem, idx) => (
<option key={idx} value={idx}>
{actionItem.label}
</option>
))}
</select>
{selectedAction !== null && <Button onClick={handleSelectedActionRun}>Apply</Button>}
</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 iconDotMenu from '/img/icon-dot-menu.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 formatDate from '../functions/formatDates';
import WatchedCheckBox from './WatchedCheckBox';
@ -12,6 +14,7 @@ import { useState } from 'react';
import deleteVideoProgressById from '../api/actions/deleteVideoProgressById';
import VideoThumbnail from './VideoThumbail';
import { ViewStylesType } from '../configuration/constants/ViewStyle';
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
type VideoListItemProps = {
video: VideoType;
@ -31,6 +34,8 @@ const VideoListItem = ({
const [, setSearchParams] = useSearchParams();
const [showReorderMenu, setShowReorderMenu] = useState(false);
const { selectedVideoIds, appendVideoId, removeVideoId, showSelection } =
useVideoSelectionStore();
if (!video) {
return <p>No video found.</p>;
@ -38,6 +43,19 @@ const VideoListItem = ({
return (
<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
onClick={() => {
setSearchParams(params => {
@ -75,28 +93,32 @@ const VideoListItem = ({
</a>
<div className={`video-desc ${viewStyle}`}>
<div className="video-desc-player" id={`video-info-${video.youtube_id}`}>
<WatchedCheckBox
watched={video.player.watched}
onClick={async status => {
await updateWatchedState({
id: video.youtube_id,
is_watched: status,
});
}}
onDone={() => {
refreshVideoList(true);
}}
/>
{video.player.progress && (
<img
src={iconClose}
className="video-popup-menu-close-button"
title="Delete watch progress"
onClick={async () => {
await deleteVideoProgressById(video.youtube_id);
refreshVideoList(true);
}}
/>
{!showSelection && (
<>
<WatchedCheckBox
watched={video.player.watched}
onClick={async status => {
await updateWatchedState({
id: video.youtube_id,
is_watched: status,
});
}}
onDone={() => {
refreshVideoList(true);
}}
/>
{video.player.progress && (
<img
src={iconClose}
className="video-popup-menu-close-button"
title="Delete watch progress"
onClick={async () => {
await deleteVideoProgressById(video.youtube_id);
refreshVideoList(true);
}}
/>
)}
</>
)}
<span>
{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 { FileSizeUnits } from '../api/actions/updateUserConfig';
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 = {
Video: 'video',
@ -18,6 +21,8 @@ type VideoListItemProps = {
const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
const { userConfig } = useUserConfigStore();
const { showSelection, selectedVideoIds, removeVideoId, appendVideoId } =
useVideoSelectionStore();
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
@ -26,8 +31,9 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
<table>
<thead>
<tr>
<th>Channel</th>
{showSelection && <th />}
<th>Title</th>
<th>Channel</th>
<th>Type</th>
<th>Resolution</th>
<th>Media size</th>
@ -45,12 +51,25 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
return (
<tr key={youtube_id}>
<td className="no-nowrap">
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
</td>
{showSelection && (
<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">
<Link to={Routes.Video(youtube_id)}>{title}</Link>
</td>
<td className="no-nowrap">
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
</td>
<td>{vid_type}</td>
<td>{`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}</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 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 gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';

View File

@ -64,6 +64,7 @@ const Download = () => {
const [showHiddenForm, setShowHiddenForm] = useState(false);
const [addAsAutoStart, setAddAsAutoStart] = useState(false);
const [addAsFlat, setAddAsFlat] = useState(false);
const [addAsForce, setAddAsForce] = useState(false);
const [showQueueActions, setShowQueueActions] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@ -249,6 +250,26 @@ const Download = () => {
</div>
<span>Fast add</span>
</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="toggleBox">
<input
@ -280,7 +301,12 @@ const Download = () => {
label="Add to queue"
onClick={async () => {
if (downloadQueueText.trim()) {
await updateDownloadQueue(downloadQueueText, addAsAutoStart, addAsFlat);
await updateDownloadQueue({
youtubeIdStrings: downloadQueueText,
autostart: addAsAutoStart,
flat: addAsFlat,
force: addAsForce,
});
setDownloadQueueText('');
setRefresh(true);
setShowHiddenForm(false);

View File

@ -130,7 +130,7 @@ const Home = () => {
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 gridViewGrid = isGridView ? `grid-${userConfig.grid_items}` : '';
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 gridItems = userConfig.grid_items;
const isGridView = viewStyle === ViewStylesEnum.Grid;
const isGridView = viewStyle === ViewStylesEnum.Grid || ViewStylesEnum.Table;
const gridView = isGridView ? `boxed-${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;
}
.video-item.table td img {
width: 20px;
height: 20px;
}
.button-box {
padding: 5px 2px;
display: inline-flex;
@ -423,8 +428,9 @@ button:disabled:hover {
}
.view-controls {
display: grid;
grid-template-columns: 1fr auto auto;
display: flex;
flex-wrap: wrap;
justify-content: end;
border-bottom: 2px solid;
border-color: var(--accent-font-dark);
margin: 15px 0;
@ -433,6 +439,7 @@ button:disabled:hover {
.view-icons,
.grid-count {
display: flex;
flex-wrap: wrap;
justify-content: end;
align-items: center;
}
@ -575,6 +582,7 @@ video:-webkit-full-screen {
.video-item {
overflow: hidden;
position: relative;
}
.video-item:hover .video-tags {
@ -588,6 +596,23 @@ video:-webkit-full-screen {
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,
.notification-progress-bar {
position: absolute;