add multi select, add redownload
This commit is contained in:
parent
9c5e4b599f
commit
3cf9b5ed40
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}` : '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}` : '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue