Bulk redownload, #build

Changed:
- Added bulk selection framework
- Added bulk redownload
- Make filters nullable
- Align filter UI/UX
This commit is contained in:
Simon 2025-08-14 17:23:31 +07:00
commit 2ab64b565f
No known key found for this signature in database
GPG Key ID: 2C15AA5E89985DD4
40 changed files with 730 additions and 289 deletions

View File

@ -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"
},

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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"""

View File

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

View File

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

View File

@ -57,7 +57,7 @@ class YtWrap:
"extractor_args": {
"youtube": {
"po_token": [potoken],
"player-client": ["web", "default"],
"player-client": ["mweb", "default"],
},
}
}

View File

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

View File

@ -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
)

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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()

View File

@ -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,
)

View File

@ -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):

View File

@ -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")

View File

@ -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/"

View File

@ -0,0 +1,41 @@
<?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>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

View File

@ -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;
};

View File

@ -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()}` : ''}`;

View File

@ -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()}` : ''}`;

View File

@ -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<VideoListByFilterResponseType>(endpoint);

View File

@ -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 (
<div className="view-controls three">
<div className="toggle">
<span>{hideToggleText}</span>
<div className="toggleBox">
<input
id="hide_watched"
type="checkbox"
checked={userConfig.hide_watched}
onChange={() => {
handleUserConfigUpdate({ hide_watched: !userConfig.hide_watched });
}}
/>
const redownloadSelected = async (ids: string[]) => {
updateDownloadQueue({
youtubeIdStrings: ids.join(' '),
autostart: true,
force: true,
});
};
{userConfig.hide_watched ? (
<label htmlFor="" className="onbtn">
On
</label>
) : (
<label htmlFor="" className="ofbtn">
Off
</label>
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">
<div className="view-icons">
<img
alt="icon multi select"
src={iconMultiSelect}
onClick={() => setShowSelection(!showSelection)}
title={showSelection ? 'Hide multi select boxes' : 'Show multi select boxes'}
/>
{showFilterItems && (
<div>
<span>Filter:</span>
<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 && (
<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)}
/>
</div>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
{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"
/>
)}
{userConfig.grid_items !== undefined && isGridView && (
<div className="grid-count">
{userConfig.grid_items < 7 && (
<img
src={iconAdd}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 });
}}
alt="grid plus row"
/>
)}
{userConfig.grid_items > 3 && (
<img
src={iconSubstract}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 });
}}
alt="grid minus row"
/>
)}
</div>
)}
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
}}
alt="table view"
/>
</div>
</div>
{showHidden && (
<div className="sort">
<div id="form">
<span>Sort by:</span>
<select
name="sort_by"
id="sort"
value={userConfig.sort_by}
onChange={event => {
handleUserConfigUpdate({ sort_by: event.target.value as SortByType });
}}
>
{Object.entries(SortByEnum).map(([key, value]) => {
return <option value={value}>{key}</option>;
})}
</select>
<select
name="sort_order"
id="sort-order"
value={userConfig.sort_order}
onChange={event => {
handleUserConfigUpdate({ sort_order: event.target.value as SortOrderType });
}}
>
{Object.entries(SortOrderEnum).map(([key, value]) => {
return <option value={value}>{key}</option>;
})}
</select>
</div>
{showSelection && (
<div className="info-box-item">
<p>
Selected Videos: {selectedVideoIds.length} -{' '}
<Button onClick={clearSelected}>Clear</Button>
</p>
<p>Apply action:</p>
<select onChange={handleActionSelectChange}>
<option value="">---</option>
{actionList.map((actionItem, idx) => (
<option key={idx} value={idx}>
{actionItem.label}
</option>
))}
</select>
{selectedAction !== null && <Button onClick={handleSelectedActionRun}>Apply</Button>}
</div>
)}
<div className="view-icons">
{showSort && (
<img
src={iconSort}
alt="sort-icon"
onClick={() => {
setShowHidden(!showHidden);
}}
id="animate-icon"
/>
)}
{userConfig.grid_items !== undefined && isGridView && (
<div className="grid-count">
{userConfig.grid_items < 7 && (
<img
src={iconAdd}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items + 1 });
}}
alt="grid plus row"
/>
)}
{userConfig.grid_items > 3 && (
<img
src={iconSubstract}
onClick={() => {
handleUserConfigUpdate({ grid_items: userConfig.grid_items - 1 });
}}
alt="grid minus row"
/>
)}
</div>
)}
<img
src={iconGridView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Grid });
}}
alt="grid view"
/>
<img
src={iconListView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.List });
}}
alt="list view"
/>
<img
src={iconTableView}
onClick={() => {
handleUserConfigUpdate({ [viewStyle]: ViewStylesEnum.Table });
}}
alt="table view"
/>
</div>
</div>
</>
);
};

View File

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

View File

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

View File

@ -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<ApiResponseType<PlaylistsResponseType>>();
@ -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<UserConfigType>) => {
const updatedUserConfig = await updateUserConfig(config);
@ -58,30 +60,35 @@ const ChannelPlaylist = () => {
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
checked={showSubedOnly}
onChange={() => {
handleUserConfigUpdate({ show_subed_only: !showSubedOnly });
setRefreshPlaylists(true);
}}
type="checkbox"
/>
{!showSubedOnly && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{showSubedOnly && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
{showFilterItems && (
<div>
<span>Filter:</span>
<select
value={
userConfig.show_subed_only_playlists === null
? ''
: userConfig.show_subed_only_playlists.toString()
}
onChange={event => {
handleUserConfigUpdate({
show_subed_only_playlists:
event.target.value === '' ? null : event.target.value === 'true',
});
setRefreshPlaylists(true);
}}
>
<option value="">All subscribe state</option>
<option value="true">Subscribed only</option>
<option value="false">Unsubscribed only</option>
</select>
</div>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
<img
src={iconGridView}
onClick={() => {

View File

@ -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<ApiResponseType<ChannelResponseType>>();
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) => {
</div>
<div className={`boxed-content ${gridView}`}>
<Filterbar
hideToggleText={'Hide watched videos:'}
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
/>
<Filterbar viewStyle={ViewStyleNames.Home as ViewStyleNamesType} />
</div>
<EmbeddableVideoPlayer videoId={videoId} />

View File

@ -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<ApiResponseType<ChannelsListResponse>>();
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 = () => {
/>
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
id="show_subed_only"
onChange={async () => {
handleUserConfigUpdate({ show_subed_only: !userConfig.show_subed_only });
setRefresh(true);
}}
type="checkbox"
checked={userConfig.show_subed_only}
/>
{!userConfig.show_subed_only && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{userConfig.show_subed_only && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
{showFilterItems && (
<div>
<span>Filter:</span>
<select
value={
userConfig.show_subed_only === null ? '' : userConfig.show_subed_only.toString()
}
onChange={event => {
handleUserConfigUpdate({
show_subed_only:
event.target.value === '' ? null : event.target.value === 'true',
});
setRefresh(true);
}}
>
<option value="">All subscribe state</option>
<option value="true">Subscribed only</option>
<option value="false">Unsubscribed only</option>
</select>
</div>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
<img
src={iconGridView}
onClick={() => {

View File

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

View File

@ -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<ApiResponseType<VideoListByFilterResponseType>>();
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 = () => {
<h1>Recent Videos</h1>
</div>
<Filterbar
hideToggleText="Show unwatched only:"
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
/>
<Filterbar viewStyle={ViewStyleNames.Home as ViewStyleNamesType} showTypeFilter={true} />
</div>
<div className={`boxed-content ${gridView}`}>

View File

@ -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 = () => {
<div className={`boxed-content ${gridView}`}>
<Filterbar
hideToggleText="Hide watched videos:"
viewStyle={ViewStyleNames.Home as ViewStyleNamesType} // its a list of videos, so ViewStyleNames.Home
showSort={false}
showTypeFilter={true}
/>
</div>

View File

@ -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<UserConfigType>) => {
const updatedUserConfig = await updateUserConfig(config);
@ -150,29 +152,35 @@ const Playlists = () => {
/>
<div className="view-controls">
<div className="toggle">
<span>Show subscribed only:</span>
<div className="toggleBox">
<input
checked={showSubedOnly}
onChange={() => {
handleUserConfigUpdate({ show_subed_only: !showSubedOnly });
}}
type="checkbox"
/>
{!showSubedOnly && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{showSubedOnly && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="view-icons">
{showFilterItems && (
<div>
<span>Filter:</span>
<select
value={
userConfig.show_subed_only_playlists === null
? ''
: userConfig.show_subed_only_playlists.toString()
}
onChange={event => {
handleUserConfigUpdate({
show_subed_only_playlists:
event.target.value === '' ? null : event.target.value === 'true',
});
setRefresh(true);
}}
>
<option value="">All subscribe state</option>
<option value="true">Subscribed only</option>
<option value="false">Unsubscribed only</option>
</select>
</div>
)}
<img
src={iconFilter}
alt="icon filter"
onClick={() => setShowFilterItems(!showFilterItems)}
/>
<img
src={iconGridView}
onClick={() => {

View File

@ -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<FilterbarTempConfInterface>(set => ({
filterHeight: '',
setFilterHeight: (filterHeight: string) => set({ filterHeight }),
showFilterItems: false,
setShowFilterItems: (showFilterItems: boolean) => set({ showFilterItems }),
}));

View File

@ -18,11 +18,13 @@ export const useUserConfigStore = create<UserConfigState>(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 }),

View File

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

View File

@ -178,6 +178,11 @@ button:disabled:hover {
min-width: 100px;
}
.video-item.table td img {
width: 20px;
height: 20px;
}
.button-box {
padding: 5px 2px;
display: inline-flex;
@ -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;