diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json index 03d43deb..6c5bff1c 100644 --- a/backend/appsettings/index_mapping.json +++ b/backend/appsettings/index_mapping.json @@ -1,12 +1,7 @@ { "index_config": [{ "index_name": "config", - "expected_map": { - "config": { - "type": "object", - "enabled": false - } - }, + "expected_map": {}, "expected_set": { "number_of_replicas": "0" } @@ -368,6 +363,15 @@ "category": { "type": "keyword" }, + "description": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, "locked": { "type": "short" }, diff --git a/backend/appsettings/src/backup.py b/backend/appsettings/src/backup.py index a26eb838..d977e521 100644 --- a/backend/appsettings/src/backup.py +++ b/backend/appsettings/src/backup.py @@ -153,7 +153,7 @@ class ElasticBackup: def restore(self, filename): """ restore from backup zip file - call reset from ElasitIndexWrap first to start blank + call reset from ElasticIndexWrap first to start blank """ zip_content = self._unpack_zip_backup(filename) self._restore_json_files(zip_content) diff --git a/backend/appsettings/src/index_setup.py b/backend/appsettings/src/index_setup.py index 657199ed..ae1a1438 100644 --- a/backend/appsettings/src/index_setup.py +++ b/backend/appsettings/src/index_setup.py @@ -35,7 +35,7 @@ class ElasticIndex: returns True when rebuild is needed """ - if self.expected_map: + if self.expected_map or self.expected_map == {}: rebuild = self.validate_mappings() if rebuild: return rebuild @@ -49,7 +49,7 @@ class ElasticIndex: def validate_mappings(self): """check if all mappings are as expected""" - now_map = self.details["mappings"]["properties"] + now_map = self.details["mappings"].get("properties", {}) for key, value in self.expected_map.items(): # nested @@ -75,6 +75,10 @@ class ElasticIndex: print(f"detected mapping change: {key}, {value}") return True + # simple doc store + if self.expected_map == {} and now_map != self.expected_map: + return True + return False def validate_settings(self): @@ -135,13 +139,16 @@ class ElasticIndex: data = {} if self.expected_set: data.update({"settings": self.expected_set}) - if self.expected_map: + if self.expected_map or self.expected_map == {}: data.update({"mappings": {"properties": self.expected_map}}) + if self.index_name == "config": + # no indexing for config + data["mappings"]["dynamic"] = False _, _ = ElasticWrap(path).put(data) -class ElasitIndexWrap: +class ElasticIndexWrap: """interact with all index mapping and setup""" def __init__(self): diff --git a/backend/channel/serializers.py b/backend/channel/serializers.py index 81464957..1f364a97 100644 --- a/backend/channel/serializers.py +++ b/backend/channel/serializers.py @@ -64,7 +64,9 @@ class ChannelListSerializer(serializers.Serializer): class ChannelListQuerySerializer(serializers.Serializer): """serialize list query""" - filter = serializers.ChoiceField(choices=["subscribed"], required=False) + filter = serializers.ChoiceField( + choices=["subscribed", "unsubscribed"], required=False + ) page = serializers.IntegerField(required=False) diff --git a/backend/channel/views.py b/backend/channel/views.py index 1f0daf57..204aaf73 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -51,8 +51,11 @@ class ChannelApiListView(ApiBaseView): must_list = [] query_filter = validated_data.get("filter") - if query_filter: - must_list.append({"term": {"channel_subscribed": {"value": True}}}) + if query_filter is not None: + channel_subscribed = query_filter == "subscribed" + must_list.append( + {"term": {"channel_subscribed": {"value": channel_subscribed}}} + ) self.data["query"] = {"bool": {"must": must_list}} self.get_document_list(request) diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py index 7108c8b6..3d0717c7 100644 --- a/backend/config/management/commands/ta_startup.py +++ b/backend/config/management/commands/ta_startup.py @@ -10,7 +10,7 @@ from random import randint from time import sleep from appsettings.src.config import AppConfig, ReleaseVersion -from appsettings.src.index_setup import ElasitIndexWrap +from appsettings.src.index_setup import ElasticIndexWrap from appsettings.src.snapshot import ElasticSnapshot from common.src.env_settings import EnvironmentSettings from common.src.es_connect import ElasticWrap @@ -151,7 +151,7 @@ class Command(BaseCommand): def _index_setup(self): """migration: validate index mappings""" self.stdout.write("[6] validate index mappings") - ElasitIndexWrap().setup() + ElasticIndexWrap().setup() def _snapshot_check(self): """migration setup snapshots""" 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..bdadaa95 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) @@ -258,12 +266,14 @@ class PendingList(PendingIndex): def _parse_playlist(self, url: str, limit: int | None): """fast parse playlist""" playlist = YoutubePlaylist(url) - playlist.update_playlist(limit=limit) + playlist.update_playlist() if not playlist.youtube_meta: print(f"{url}: playlist metadata extraction failed, skipping") return video_results = playlist.youtube_meta["entries"] + if limit: + video_results = video_results[:limit] total = len(video_results) for idx, video_data in enumerate(video_results, start=1): diff --git a/backend/download/src/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py index 68332da0..909931c0 100644 --- a/backend/download/src/yt_dlp_base.py +++ b/backend/download/src/yt_dlp_base.py @@ -57,7 +57,7 @@ class YtWrap: "extractor_args": { "youtube": { "po_token": [potoken], - "player-client": ["web", "default"], + "player-client": ["mweb", "default"], }, } } 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/playlist/serializers.py b/backend/playlist/serializers.py index 741c770d..cb2a12c7 100644 --- a/backend/playlist/serializers.py +++ b/backend/playlist/serializers.py @@ -46,7 +46,7 @@ class PlaylistListQuerySerializer(serializers.Serializer): """serialize playlist list query params""" channel = serializers.CharField(required=False) - subscribed = serializers.BooleanField(required=False) + subscribed = serializers.BooleanField(required=False, allow_null=True) type = serializers.ChoiceField( choices=["regular", "custom"], required=False ) diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py index e676fbbb..a30b4244 100644 --- a/backend/playlist/src/index.py +++ b/backend/playlist/src/index.py @@ -30,7 +30,7 @@ class YoutubePlaylist(YouTubeItem): self.all_members = False self.nav = False - def build_json(self, scrape=False, limit: int | None = None): + def build_json(self, scrape=False): """collection to create json_data""" self.get_from_es() if self.json_data: @@ -40,9 +40,8 @@ class YoutubePlaylist(YouTubeItem): subscribed = False playlist_sort_order = "top" - limit_str = limit if limit is not None else "" sort_order = 1 if playlist_sort_order == "top" else -1 - playlist_items = f":{limit_str}:{sort_order}" + playlist_items = f"::{sort_order}" if scrape or not self.json_data: self.get_from_youtube( @@ -198,9 +197,9 @@ class YoutubePlaylist(YouTubeItem): if status_code == 200: print(f"{self.youtube_id}: removed {video_id} from playlist") - def update_playlist(self, skip_on_empty=False, limit: int | None = None): + def update_playlist(self, skip_on_empty=False): """update metadata for playlist with data from YouTube""" - self.build_json(scrape=True, limit=limit) + self.build_json(scrape=True) if not self.json_data: # return false to deactivate return False diff --git a/backend/playlist/src/query_building.py b/backend/playlist/src/query_building.py index 3f5593e5..71374ee6 100644 --- a/backend/playlist/src/query_building.py +++ b/backend/playlist/src/query_building.py @@ -26,7 +26,7 @@ class QueryBuilder: must_list.append({"match": {"playlist_channel_id": channel}}) subscribed = self.request_params.get("subscribed") - if subscribed: + if subscribed is not None: must_list.append({"match": {"playlist_subscribed": subscribed}}) playlist_type = self.request_params.get("type") diff --git a/backend/task/tasks.py b/backend/task/tasks.py index 2cf49624..64fb5ab8 100644 --- a/backend/task/tasks.py +++ b/backend/task/tasks.py @@ -9,14 +9,14 @@ Functionality: from appsettings.src.backup import ElasticBackup from appsettings.src.config import ReleaseVersion from appsettings.src.filesystem import Scanner -from appsettings.src.index_setup import ElasitIndexWrap +from appsettings.src.index_setup import ElasticIndexWrap from appsettings.src.manual import ImportFolderScanner from appsettings.src.reindex import Reindex, ReindexManual, ReindexPopulate 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) @@ -242,7 +251,7 @@ def run_restore_backup(self, filename): manager.init(self) self.send_progress(["Reset your Index"]) - ElasitIndexWrap().reset() + ElasticIndexWrap().reset() ElasticBackup(task=self).restore(filename) print("index restore finished") diff --git a/backend/user/serializers.py b/backend/user/serializers.py index be617437..dea69916 100644 --- a/backend/user/serializers.py +++ b/backend/user/serializers.py @@ -5,7 +5,7 @@ from common.src.helper import get_stylesheets from rest_framework import serializers from user.models import Account -from video.src.constants import OrderEnum, SortEnum +from video.src.constants import OrderEnum, SortEnum, VideoTypeEnum class AccountSerializer(serializers.ModelSerializer): @@ -37,11 +37,15 @@ class UserMeConfigSerializer(serializers.Serializer): view_style_channel = serializers.ChoiceField(choices=["grid", "list"]) view_style_downloads = serializers.ChoiceField(choices=["grid", "list"]) view_style_playlist = serializers.ChoiceField(choices=["grid", "list"]) + vid_type_filter = serializers.ChoiceField( + choices=VideoTypeEnum.values_known(), allow_null=True + ) grid_items = serializers.IntegerField(max_value=7, min_value=3) - hide_watched = serializers.BooleanField() + hide_watched = serializers.BooleanField(allow_null=True) file_size_unit = serializers.ChoiceField(choices=["binary", "metric"]) show_ignored_only = serializers.BooleanField() - show_subed_only = serializers.BooleanField() + show_subed_only = serializers.BooleanField(allow_null=True) + show_subed_only_playlists = serializers.BooleanField(allow_null=True) show_help_text = serializers.BooleanField() diff --git a/backend/user/src/user_config.py b/backend/user/src/user_config.py index f691e980..5037c390 100644 --- a/backend/user/src/user_config.py +++ b/backend/user/src/user_config.py @@ -20,11 +20,13 @@ class UserConfigType(TypedDict, total=False): view_style_channel: str view_style_downloads: str view_style_playlist: str + vid_type_filter: str | None grid_items: int - hide_watched: bool + hide_watched: bool | None file_size_unit: str show_ignored_only: bool - show_subed_only: bool + show_subed_only: bool | None + show_subed_only_playlists: bool | None show_help_text: bool @@ -43,11 +45,13 @@ class UserConfig: view_style_channel="list", view_style_downloads="list", view_style_playlist="grid", + vid_type_filter=None, grid_items=3, hide_watched=False, file_size_unit="binary", show_ignored_only=False, - show_subed_only=False, + show_subed_only=None, + show_subed_only_playlists=None, show_help_text=True, ) diff --git a/backend/video/serializers.py b/backend/video/serializers.py index afd8b0b4..55d229f0 100644 --- a/backend/video/serializers.py +++ b/backend/video/serializers.py @@ -112,7 +112,7 @@ class VideoListQuerySerializer(serializers.Serializer): playlist = serializers.CharField(required=False) channel = serializers.CharField(required=False) watch = serializers.ChoiceField( - choices=WatchedEnum.values(), required=False + choices=WatchedEnum.values(), required=False, allow_null=True ) sort = serializers.ChoiceField(choices=SortEnum.names(), required=False) order = serializers.ChoiceField(choices=OrderEnum.values(), required=False) @@ -120,6 +120,7 @@ class VideoListQuerySerializer(serializers.Serializer): choices=VideoTypeEnum.values_known(), required=False ) page = serializers.IntegerField(required=False) + height = serializers.IntegerField(required=False) class CommentThreadItemSerializer(serializers.Serializer): diff --git a/backend/video/src/query_building.py b/backend/video/src/query_building.py index fe4bc0b7..cb8ee8b9 100644 --- a/backend/video/src/query_building.py +++ b/backend/video/src/query_building.py @@ -35,7 +35,7 @@ class QueryBuilder: must_list.append({"match": {"playlist.keyword": playlist}}) watch = self.request_params.get("watch") - if watch: + if watch is not None: watch_must_list = self.parse_watch(watch) must_list.append(watch_must_list) @@ -44,6 +44,11 @@ class QueryBuilder: type_list_list = self.parse_type(video_type) must_list.append(type_list_list) + height = self.request_params.get("height") + if height: + height_must = self.parse_height(height) + must_list.append(height_must) + query = {"bool": {"must": must_list}} return query @@ -83,6 +88,11 @@ class QueryBuilder: return {"match": {"vid_type": vid_type}} + def parse_height(self, height: str): + """parse height to int""" + + return {"term": {"streams.height": {"value": height}}} + def parse_sort(self) -> dict | None: """build sort key""" playlist = self.request_params.get("playlist") diff --git a/backend/video/views.py b/backend/video/views.py index ef5c011a..4bb95d87 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -31,6 +31,7 @@ class VideoApiListView(ApiBaseView): - sort:enum=published|downloaded|views|likes|duration|filesize - order:enum=asc|desc - type:enum=videos|streams|shorts + - height:int=px """ search_base = "ta_video/_search/" diff --git a/frontend/public/img/icon-filter.svg b/frontend/public/img/icon-filter.svg new file mode 100644 index 00000000..5c586ebb --- /dev/null +++ b/frontend/public/img/icon-filter.svg @@ -0,0 +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..685cb68c --- /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/api/actions/updateUserConfig.ts b/frontend/src/api/actions/updateUserConfig.ts index 9d2aac7a..d9a4b7f8 100644 --- a/frontend/src/api/actions/updateUserConfig.ts +++ b/frontend/src/api/actions/updateUserConfig.ts @@ -1,6 +1,6 @@ import { ViewStylesType } from '../../configuration/constants/ViewStyle'; import APIClient from '../../functions/APIClient'; -import { SortByType, SortOrderType } from '../loader/loadVideoListByPage'; +import { SortByType, SortOrderType, VideoTypes } from '../loader/loadVideoListByPage'; export type ColourVariants = | 'dark.css' @@ -31,11 +31,13 @@ export type UserConfigType = { view_style_channel: ViewStylesType; view_style_downloads: ViewStylesType; view_style_playlist: ViewStylesType; + vid_type_filter: VideoTypes | null; grid_items: number; - hide_watched: boolean; + hide_watched: boolean | null; file_size_unit: 'binary' | 'metric'; show_ignored_only: boolean; - show_subed_only: boolean; + show_subed_only: boolean | null; + show_subed_only_playlists: boolean | null; show_help_text: boolean; }; diff --git a/frontend/src/api/loader/loadChannelList.ts b/frontend/src/api/loader/loadChannelList.ts index 928fe911..a46a9574 100644 --- a/frontend/src/api/loader/loadChannelList.ts +++ b/frontend/src/api/loader/loadChannelList.ts @@ -9,11 +9,13 @@ export type ChannelsListResponse = { config?: ConfigType; }; -const loadChannelList = async (page: number, showSubscribed: boolean) => { +const loadChannelList = async (page: number, showSubscribed: boolean | null) => { const searchParams = new URLSearchParams(); if (page) searchParams.append('page', page.toString()); - if (showSubscribed) searchParams.append('filter', 'subscribed'); + if (showSubscribed !== null) { + searchParams.append('filter', showSubscribed ? 'subscribed' : 'unsubscribed'); + } const endpoint = `/api/channel/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; diff --git a/frontend/src/api/loader/loadPlaylistList.ts b/frontend/src/api/loader/loadPlaylistList.ts index d03c9d91..b2cb31f7 100644 --- a/frontend/src/api/loader/loadPlaylistList.ts +++ b/frontend/src/api/loader/loadPlaylistList.ts @@ -12,7 +12,7 @@ type PlaylistCategoryType = 'regular' | 'custom'; type LoadPlaylistListProps = { channel?: string; page?: number | undefined; - subscribed?: boolean; + subscribed?: boolean | null; type?: PlaylistCategoryType; }; @@ -21,7 +21,8 @@ const loadPlaylistList = async ({ channel, page, subscribed, type }: LoadPlaylis if (channel) searchParams.append('channel', channel); if (page) searchParams.append('page', page.toString()); - if (subscribed) searchParams.append('subscribed', subscribed.toString()); + if (subscribed !== undefined && subscribed !== null) + searchParams.append('subscribed', subscribed.toString()); if (type) searchParams.append('type', type); const endpoint = `/api/playlist/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; diff --git a/frontend/src/api/loader/loadVideoListByPage.ts b/frontend/src/api/loader/loadVideoListByPage.ts index b7a4af93..726279ae 100644 --- a/frontend/src/api/loader/loadVideoListByPage.ts +++ b/frontend/src/api/loader/loadVideoListByPage.ts @@ -36,7 +36,7 @@ export const SortOrderEnum = { export type VideoTypes = 'videos' | 'streams' | 'shorts'; -export type WatchTypes = 'watched' | 'unwatched' | 'continue'; +export type WatchTypes = 'watched' | 'unwatched' | 'continue' | null; export const WatchTypesEnum = { Watched: 'watched', Unwatched: 'unwatched', @@ -51,6 +51,7 @@ type FilterType = { sort?: SortByType; order?: SortOrderType; type?: VideoTypes; + height?: string; }; const loadVideoListByFilter = async (filter: FilterType) => { @@ -67,6 +68,7 @@ const loadVideoListByFilter = async (filter: FilterType) => { if (filter.sort) searchParams.append('sort', filter.sort); if (filter.order) searchParams.append('order', filter.order); if (filter.type) searchParams.append('type', filter.type); + if (filter.height) searchParams.append('height', filter.height.toString()); const endpoint = `/api/video/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; return APIClient(endpoint); diff --git a/frontend/src/components/Filterbar.tsx b/frontend/src/components/Filterbar.tsx index 8219bea1..a03e5392 100644 --- a/frontend/src/components/Filterbar.tsx +++ b/frontend/src/components/Filterbar.tsx @@ -5,6 +5,8 @@ import iconSubstract from '/img/icon-substract.svg'; 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'; @@ -13,18 +15,33 @@ import { SortByType, SortOrderEnum, SortOrderType, + 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 = { - hideToggleText: string; viewStyle: ViewStyleNamesType; showSort?: boolean; + showTypeFilter?: boolean; }; -const Filterbar = ({ hideToggleText, viewStyle, showSort = true }: 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 } = + useFilterBarTempConf(); const currentViewStyle = userConfig[viewStyle]; const isGridView = currentViewStyle === ViewStylesEnum.Grid; @@ -50,120 +67,203 @@ const Filterbar = ({ hideToggleText, viewStyle, showSort = true }: FilterbarProp } }; - return ( -
-
- {hideToggleText} -
- { - handleUserConfigUpdate({ hide_watched: !userConfig.hide_watched }); - }} - /> + const redownloadSelected = async (ids: string[]) => { + updateDownloadQueue({ + youtubeIdStrings: ids.join(' '), + autostart: true, + force: true, + }); + }; - {userConfig.hide_watched ? ( - - ) : ( - + 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 ( + <> +
+
+ icon multi select setShowSelection(!showSelection)} + title={showSelection ? 'Hide multi select boxes' : 'Show multi select boxes'} + /> + {showFilterItems && ( +
+ Filter: + + {showTypeFilter && ( + + )} + setFilterHeight(e.target.value)} + /> +
)} + icon filter setShowFilterItems(!showFilterItems)} + /> + {showHidden && ( +
+ Sort: + + +
+ )} + {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" + />
- - {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/ChannelPlaylist.tsx b/frontend/src/pages/ChannelPlaylist.tsx index 03597913..efb87bf7 100644 --- a/frontend/src/pages/ChannelPlaylist.tsx +++ b/frontend/src/pages/ChannelPlaylist.tsx @@ -7,6 +7,7 @@ import ScrollToTopOnNavigate from '../components/ScrollToTop'; import loadPlaylistList, { PlaylistsResponseType } from '../api/loader/loadPlaylistList'; import iconGridView from '/img/icon-gridview.svg'; import iconListView from '/img/icon-listview.svg'; +import iconFilter from '/img/icon-filter.svg'; import { useUserConfigStore } from '../stores/UserConfigStore'; import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig'; import { ApiResponseType } from '../functions/APIClient'; @@ -18,6 +19,7 @@ const ChannelPlaylist = () => { const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const [refreshPlaylists, setRefreshPlaylists] = useState(false); + const [showFilterItems, setShowFilterItems] = useState(false); const [playlistsResponse, setPlaylistsResponse] = useState>(); @@ -28,7 +30,7 @@ const ChannelPlaylist = () => { const pagination = playlistsResponseData?.paginate; const viewStyle = userConfig.view_style_playlist; - const showSubedOnly = userConfig.show_subed_only; + const showSubedOnly = userConfig.show_subed_only_playlists; const handleUserConfigUpdate = async (config: Partial) => { const updatedUserConfig = await updateUserConfig(config); @@ -58,30 +60,35 @@ const ChannelPlaylist = () => {
-
- Show subscribed only: -
- { - handleUserConfigUpdate({ show_subed_only: !showSubedOnly }); - setRefreshPlaylists(true); - }} - type="checkbox" - /> - {!showSubedOnly && ( - - )} - {showSubedOnly && ( - - )} -
-
+ {showFilterItems && ( +
+ Filter: + +
+ )} + icon filter setShowFilterItems(!showFilterItems)} + /> { diff --git a/frontend/src/pages/ChannelVideo.tsx b/frontend/src/pages/ChannelVideo.tsx index 07c441b6..05f329a9 100644 --- a/frontend/src/pages/ChannelVideo.tsx +++ b/frontend/src/pages/ChannelVideo.tsx @@ -27,6 +27,7 @@ import humanFileSize from '../functions/humanFileSize'; import { useUserConfigStore } from '../stores/UserConfigStore'; import { FileSizeUnits } from '../api/actions/updateUserConfig'; import { ApiResponseType } from '../functions/APIClient'; +import { useFilterBarTempConf } from '../stores/FilterbarTempConf'; type ChannelParams = { channelId: string; @@ -44,6 +45,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => { const videoId = searchParams.get('videoId'); const [refresh, setRefresh] = useState(false); + const { filterHeight } = useFilterBarTempConf(); const [channelResponse, setChannelResponse] = useState>(); const [videoResponse, setVideoReponse] = @@ -63,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}` : ''; @@ -73,10 +75,16 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => { const videos = await loadVideoListByFilter({ channel: channelId, page: currentPage, - watch: userConfig.hide_watched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined, + watch: + userConfig.hide_watched === null + ? null + : ((userConfig.hide_watched + ? WatchTypesEnum.Watched + : WatchTypesEnum.Unwatched) as WatchTypes), sort: userConfig.sort_by, order: userConfig.sort_order, type: videoType, + height: filterHeight, }); const channelAggs = await loadChannelAggs(channelId); @@ -90,6 +98,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => { userConfig.sort_by, userConfig.sort_order, userConfig.hide_watched, + filterHeight, currentPage, channelId, pagination?.current_page, @@ -158,10 +167,7 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
- +
diff --git a/frontend/src/pages/Channels.tsx b/frontend/src/pages/Channels.tsx index 4477d363..2a6fb5f3 100644 --- a/frontend/src/pages/Channels.tsx +++ b/frontend/src/pages/Channels.tsx @@ -3,6 +3,7 @@ import loadChannelList, { ChannelsListResponse } from '../api/loader/loadChannel import iconGridView from '/img/icon-gridview.svg'; import iconListView from '/img/icon-listview.svg'; import iconAdd from '/img/icon-add.svg'; +import iconFilter from '/img/icon-filter.svg'; import { useEffect, useState } from 'react'; import Pagination from '../components/Pagination'; import { OutletContextType } from './Base'; @@ -52,6 +53,7 @@ const Channels = () => { useState>(); const [showAddForm, setShowAddForm] = useState(false); const [refresh, setRefresh] = useState(true); + const [showFilterItems, setShowFilterItems] = useState(false); const [showNotification, setShowNotification] = useState(false); const [channelsToSubscribeTo, setChannelsToSubscribeTo] = useState(''); @@ -144,31 +146,34 @@ const Channels = () => { />
-
- Show subscribed only: -
- { - handleUserConfigUpdate({ show_subed_only: !userConfig.show_subed_only }); - setRefresh(true); - }} - type="checkbox" - checked={userConfig.show_subed_only} - /> - {!userConfig.show_subed_only && ( - - )} - {userConfig.show_subed_only && ( - - )} -
-
+ {showFilterItems && ( +
+ Filter: + +
+ )} + icon filter setShowFilterItems(!showFilterItems)} + /> + { 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 e7b1c8d8..391c29f0 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -4,6 +4,7 @@ import Routes from '../configuration/routes/RouteList'; import Pagination from '../components/Pagination'; import loadVideoListByFilter, { VideoListByFilterResponseType, + VideoTypes, WatchTypes, WatchTypesEnum, } from '../api/loader/loadVideoListByPage'; @@ -21,6 +22,7 @@ import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer'; import { SponsorBlockType } from './Video'; import { useUserConfigStore } from '../stores/UserConfigStore'; import { ApiResponseType } from '../functions/APIClient'; +import { useFilterBarTempConf } from '../stores/FilterbarTempConf'; export type PlayerType = { watched: boolean; @@ -117,6 +119,8 @@ const Home = () => { const [continueVideoResponse, setContinueVideoResponse] = useState>(); + const { filterHeight } = useFilterBarTempConf(); + const { data: videoResponseData } = videoResponse ?? {}; const { data: continueVideoResponseData } = continueVideoResponse ?? {}; @@ -126,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; @@ -135,9 +139,16 @@ const Home = () => { (async () => { const videos = await loadVideoListByFilter({ page: currentPage, - watch: userConfig.hide_watched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined, + watch: + userConfig.hide_watched === null + ? null + : ((userConfig.hide_watched + ? WatchTypesEnum.Watched + : WatchTypesEnum.Unwatched) as WatchTypes), + type: userConfig.vid_type_filter as VideoTypes, sort: userConfig.sort_by, order: userConfig.sort_order, + height: filterHeight, }); try { @@ -157,6 +168,8 @@ const Home = () => { userConfig.sort_by, userConfig.sort_order, userConfig.hide_watched, + userConfig.vid_type_filter, + filterHeight, currentPage, pagination?.current_page, videoId, @@ -189,10 +202,7 @@ const Home = () => {

Recent Videos

- +
diff --git a/frontend/src/pages/Playlist.tsx b/frontend/src/pages/Playlist.tsx index 9bcb4c45..e0c7511e 100644 --- a/frontend/src/pages/Playlist.tsx +++ b/frontend/src/pages/Playlist.tsx @@ -24,6 +24,7 @@ import ScrollToTopOnNavigate from '../components/ScrollToTop'; import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer'; import Button from '../components/Button'; import loadVideoListByFilter, { + VideoTypes, WatchTypes, WatchTypesEnum, } from '../api/loader/loadVideoListByPage'; @@ -32,6 +33,7 @@ import { useUserConfigStore } from '../stores/UserConfigStore'; import { ApiResponseType } from '../functions/APIClient'; import NotFound from './NotFound'; import updatePlaylistSortOrder from '../api/actions/updatePlaylistSortOrder'; +import { useFilterBarTempConf } from '../stores/FilterbarTempConf'; export type VideoResponseType = { data?: VideoType[]; @@ -45,6 +47,7 @@ const Playlist = () => { const videoId = searchParams.get('videoId'); const { userConfig } = useUserConfigStore(); + const { filterHeight } = useFilterBarTempConf(); const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType; const isAdmin = useIsAdmin(); @@ -72,8 +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 hideWatched = userConfig.hide_watched; - const isGridView = viewStyle === ViewStylesEnum.Grid; + const isGridView = viewStyle === ViewStylesEnum.Grid || ViewStylesEnum.Table; const gridView = isGridView ? `boxed-${gridItems}` : ''; const gridViewGrid = isGridView ? `grid-${gridItems}` : ''; @@ -83,7 +85,14 @@ const Playlist = () => { const video = await loadVideoListByFilter({ playlist: playlistId, page: currentPage, - watch: hideWatched ? (WatchTypesEnum.Unwatched as WatchTypes) : undefined, + watch: + userConfig.hide_watched === null + ? null + : ((userConfig.hide_watched + ? WatchTypesEnum.Watched + : WatchTypesEnum.Unwatched) as WatchTypes), + type: userConfig.vid_type_filter as VideoTypes, + height: filterHeight, }); setPlaylistResponse(playlist); @@ -102,6 +111,8 @@ const Playlist = () => { }, [ playlistId, userConfig.hide_watched, + userConfig.vid_type_filter, + filterHeight, refresh, currentPage, pagination?.current_page, @@ -339,9 +350,9 @@ const Playlist = () => {
diff --git a/frontend/src/pages/Playlists.tsx b/frontend/src/pages/Playlists.tsx index 91820c97..d158a0b6 100644 --- a/frontend/src/pages/Playlists.tsx +++ b/frontend/src/pages/Playlists.tsx @@ -4,6 +4,7 @@ import { useOutletContext } from 'react-router-dom'; import iconAdd from '/img/icon-add.svg'; import iconGridView from '/img/icon-gridview.svg'; import iconListView from '/img/icon-listview.svg'; +import iconFilter from '/img/icon-filter.svg'; import { OutletContextType } from './Base'; import loadPlaylistList, { PlaylistsResponseType } from '../api/loader/loadPlaylistList'; @@ -28,6 +29,7 @@ const Playlists = () => { const [showAddForm, setShowAddForm] = useState(false); const [refresh, setRefresh] = useState(false); const [showNotification, setShowNotification] = useState(false); + const [showFilterItems, setShowFilterItems] = useState(false); const [playlistsToAddText, setPlaylistsToAddText] = useState(''); const [customPlaylistsToAddText, setCustomPlaylistsToAddText] = useState(''); @@ -39,7 +41,7 @@ const Playlists = () => { const pagination = playlistResponseData?.paginate; const viewStyle = userConfig.view_style_playlist; - const showSubedOnly = userConfig.show_subed_only; + const showSubedOnly = userConfig.show_subed_only_playlists; useEffect(() => { (async () => { @@ -53,7 +55,7 @@ const Playlists = () => { setShowNotification(false); })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [refresh, userConfig.show_subed_only, currentPage, pagination?.current_page]); + }, [refresh, userConfig.show_subed_only_playlists, currentPage, pagination?.current_page]); const handleUserConfigUpdate = async (config: Partial) => { const updatedUserConfig = await updateUserConfig(config); @@ -150,29 +152,35 @@ const Playlists = () => { />
-
- Show subscribed only: -
- { - handleUserConfigUpdate({ show_subed_only: !showSubedOnly }); - }} - type="checkbox" - /> - {!showSubedOnly && ( - - )} - {showSubedOnly && ( - - )} -
-
+ {showFilterItems && ( +
+ Filter: + +
+ )} + icon filter setShowFilterItems(!showFilterItems)} + /> { diff --git a/frontend/src/stores/FilterbarTempConf.ts b/frontend/src/stores/FilterbarTempConf.ts new file mode 100644 index 00000000..3f32113e --- /dev/null +++ b/frontend/src/stores/FilterbarTempConf.ts @@ -0,0 +1,17 @@ +// temporary values for filtering, not stored in backend + +import { create } from 'zustand'; + +interface FilterbarTempConfInterface { + filterHeight: string; + setFilterHeight: (filterHeight: string) => void; + showFilterItems: boolean; + setShowFilterItems: (filterItems: boolean) => void; +} + +export const useFilterBarTempConf = create(set => ({ + filterHeight: '', + setFilterHeight: (filterHeight: string) => set({ filterHeight }), + showFilterItems: false, + setShowFilterItems: (showFilterItems: boolean) => set({ showFilterItems }), +})); diff --git a/frontend/src/stores/UserConfigStore.ts b/frontend/src/stores/UserConfigStore.ts index 077715c5..a04b1bc1 100644 --- a/frontend/src/stores/UserConfigStore.ts +++ b/frontend/src/stores/UserConfigStore.ts @@ -18,11 +18,13 @@ export const useUserConfigStore = create(set => ({ view_style_channel: ViewStylesEnum.List as ViewStylesType, view_style_downloads: ViewStylesEnum.List as ViewStylesType, view_style_playlist: ViewStylesEnum.Grid as ViewStylesType, + vid_type_filter: null, grid_items: 3, hide_watched: false, file_size_unit: 'binary', show_ignored_only: false, - show_subed_only: false, + show_subed_only: null, + show_subed_only_playlists: null, show_help_text: true, }, setUserConfig: userConfig => set({ userConfig }), 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..55debd37 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; @@ -433,6 +438,7 @@ button:disabled:hover { .view-icons, .grid-count { display: flex; + flex-wrap: wrap; justify-content: end; align-items: center; } @@ -575,6 +581,7 @@ video:-webkit-full-screen { .video-item { overflow: hidden; + position: relative; } .video-item:hover .video-tags { @@ -588,6 +595,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)}