From 3cf9b5ed40775d428ba6cf83c293732a88032aa8 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 13 Aug 2025 14:21:33 +0700 Subject: [PATCH] add multi select, add redownload --- backend/download/serializers.py | 1 + backend/download/src/queue.py | 10 +- backend/download/views.py | 18 +- backend/task/tasks.py | 17 +- frontend/public/img/icon-filter.svg | 46 ++- frontend/public/img/icon-multi-select.svg | 36 ++ .../src/api/actions/updateDownloadQueue.ts | 20 +- frontend/src/components/Filterbar.tsx | 326 +++++++++++------- frontend/src/components/VideoListItem.tsx | 66 ++-- .../src/components/VideoListItemTable.tsx | 27 +- frontend/src/pages/ChannelVideo.tsx | 2 +- frontend/src/pages/Download.tsx | 28 +- frontend/src/pages/Home.tsx | 2 +- frontend/src/pages/Playlist.tsx | 2 +- frontend/src/stores/VideoSelectionStore.ts | 37 ++ frontend/src/style.css | 29 +- 16 files changed, 485 insertions(+), 182 deletions(-) create mode 100644 frontend/public/img/icon-multi-select.svg create mode 100644 frontend/src/stores/VideoSelectionStore.ts diff --git a/backend/download/serializers.py b/backend/download/serializers.py index f0ad3274..f34a4ce7 100644 --- a/backend/download/serializers.py +++ b/backend/download/serializers.py @@ -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): diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index 78f7e56b..0dd880b6 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -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) diff --git a/backend/download/views.py b/backend/download/views.py index a4290cab..7f34716e 100644 --- a/backend/download/views.py +++ b/backend/download/views.py @@ -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": diff --git a/backend/task/tasks.py b/backend/task/tasks.py index 727caa67..64fb5ab8 100644 --- a/backend/task/tasks.py +++ b/backend/task/tasks.py @@ -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) diff --git a/frontend/public/img/icon-filter.svg b/frontend/public/img/icon-filter.svg index aa48b16f..5c586ebb 100644 --- a/frontend/public/img/icon-filter.svg +++ b/frontend/public/img/icon-filter.svg @@ -1,9 +1,41 @@ - - - - + + + + diff --git a/frontend/public/img/icon-multi-select.svg b/frontend/public/img/icon-multi-select.svg new file mode 100644 index 00000000..99183cb0 --- /dev/null +++ b/frontend/public/img/icon-multi-select.svg @@ -0,0 +1,36 @@ + + + + + + + diff --git a/frontend/src/api/actions/updateDownloadQueue.ts b/frontend/src/api/actions/updateDownloadQueue.ts index beb14120..57c09a5d 100644 --- a/frontend/src/api/actions/updateDownloadQueue.ts +++ b/frontend/src/api/actions/updateDownloadQueue.ts @@ -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, { diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx index 7d24aefd..197df041 100644 --- a/frontend/src/components/Filterbar.tsx +++ b/frontend/src/components/Filterbar.tsx @@ -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) => { + 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 ( -
-
- {showFilterItems && ( - <> - - {showTypeFilter && ( + <> +
+
+ icon multi select setShowSelection(!showSelection)} + title={showSelection ? 'Hide multi select boxes' : 'Show multi select boxes'} + /> +
+
+ {showFilterItems && ( + <> + Filter: - )} - setFilterHeight(e.target.value)} + {showTypeFilter && ( + + )} + setFilterHeight(e.target.value)} + /> + + )} + icon filter setShowFilterItems(!showFilterItems)} + /> +
+
+ {showHidden && ( +
+ Sort: + + +
+ )} + {showSort && ( + sort-icon { + setShowHidden(!showHidden); + }} + id="animate-icon" /> - - )} - icon filter setShowFilterItems(!showFilterItems)} - /> + )} +
+
+ {userConfig.grid_items !== undefined && isGridView && ( +
+ {userConfig.grid_items < 7 && ( + { + handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 }); + }} + alt="grid plus row" + /> + )} + {userConfig.grid_items > 3 && ( + { + handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 }); + }} + alt="grid minus row" + /> + )} +
+ )} + { + handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid }); + }} + alt="grid view" + /> + { + handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List }); + }} + alt="list view" + /> + { + handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table }); + }} + alt="table view" + /> +
- - {showHidden && ( -
-
- Sort by: - - -
+ {showSelection && ( +
+

+ Selected Videos: {selectedVideoIds.length} -{' '} + +

+

Apply action:

+ + {selectedAction !== null && }
)} -
- {showSort && ( - sort-icon { - setShowHidden(!showHidden); - }} - id="animate-icon" - /> - )} - - {userConfig.grid_items !== undefined && isGridView && ( -
- {userConfig.grid_items < 7 && ( - { - handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 }); - }} - alt="grid plus row" - /> - )} - {userConfig.grid_items > 3 && ( - { - handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 }); - }} - alt="grid minus row" - /> - )} -
- )} - { - handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid }); - }} - alt="grid view" - /> - { - handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List }); - }} - alt="list view" - /> - { - handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table }); - }} - alt="table view" - /> -
-
+ ); }; diff --git a/frontend/src/components/VideoListItem.tsx b/frontend/src/components/VideoListItem.tsx index 6adec21f..e034b732 100644 --- a/frontend/src/components/VideoListItem.tsx +++ b/frontend/src/components/VideoListItem.tsx @@ -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

No video found.

; @@ -38,6 +43,19 @@ const VideoListItem = ({ return (
+ {showSelection && ( +
+ + selectedVideoIds.includes(video.youtube_id) + ? removeVideoId(video.youtube_id) + : appendVideoId(video.youtube_id) + } + /> +
+ )} { setSearchParams(params => { @@ -75,28 +93,32 @@ const VideoListItem = ({
- { - await updateWatchedState({ - id: video.youtube_id, - is_watched: status, - }); - }} - onDone={() => { - refreshVideoList(true); - }} - /> - {video.player.progress && ( - { - await deleteVideoProgressById(video.youtube_id); - refreshVideoList(true); - }} - /> + {!showSelection && ( + <> + { + await updateWatchedState({ + id: video.youtube_id, + is_watched: status, + }); + }} + onDone={() => { + refreshVideoList(true); + }} + /> + {video.player.progress && ( + { + await deleteVideoProgressById(video.youtube_id); + refreshVideoList(true); + }} + /> + )} + )} {formatDate(video.published)} | {video.player.duration_str} diff --git a/frontend/src/components/VideoListItemTable.tsx b/frontend/src/components/VideoListItemTable.tsx index 3542406c..f12a9aaa 100644 --- a/frontend/src/components/VideoListItemTable.tsx +++ b/frontend/src/components/VideoListItemTable.tsx @@ -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) => { - + {showSelection && + @@ -45,12 +51,25 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => { return ( - + {showSelection && ( + + )} + diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx index 0c3d9eac..05f329a9 100644 --- a/frontend/src/pages/ChannelVideo.tsx +++ b/frontend/src/pages/ChannelVideo.tsx @@ -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}` : ''; diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx index 5a2cd271..c06b9d21 100644 --- a/frontend/src/pages/Download.tsx +++ b/frontend/src/pages/Download.tsx @@ -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 = () => { Fast add +
+
+ setAddAsForce(!addAsForce)} + /> + {addAsForce ? ( + + ) : ( + + )} +
+ Re-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); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 7ae3abff..391c29f0 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -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; diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx index c28654f3..e0c7511e 100644 --- a/frontend/src/pages/Playlist.tsx +++ b/frontend/src/pages/Playlist.tsx @@ -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}` : ''; diff --git a/frontend/src/stores/VideoSelectionStore.ts b/frontend/src/stores/VideoSelectionStore.ts new file mode 100644 index 00000000..556f2e6d --- /dev/null +++ b/frontend/src/stores/VideoSelectionStore.ts @@ -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(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 }), +})); diff --git a/frontend/src/style.css b/frontend/src/style.css index 2760a356..3cc10ccc 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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;
Channel} TitleChannel Type Resolution Media size
- {channel.channel_name} - + + selectedVideoIds.includes(youtube_id) + ? removeVideoId(youtube_id) + : appendVideoId(youtube_id) + } + /> + {title} + {channel.channel_name} + {vid_type} {`${videoStream?.width || '-'}x${videoStream?.height || '-'}`} {humanFileSize(media_size, useSiUnits)}