Bulk redownload, #build
Changed: - Added bulk selection framework - Added bulk redownload - Make filters nullable - Align filter UI/UX
This commit is contained in:
commit
2ab64b565f
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class AddToDownloadQuerySerializer(serializers.Serializer):
|
|||
|
||||
autostart = serializers.BooleanField(required=False)
|
||||
flat = serializers.BooleanField(required=False)
|
||||
force = serializers.BooleanField(required=False)
|
||||
|
||||
|
||||
class BulkUpdateDowloadQuerySerializer(serializers.Serializer):
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ class PendingList(PendingIndex):
|
|||
task=None,
|
||||
auto_start=False,
|
||||
flat=False,
|
||||
force=False,
|
||||
):
|
||||
super().__init__()
|
||||
self.config = AppConfig().config
|
||||
|
|
@ -116,6 +117,7 @@ class PendingList(PendingIndex):
|
|||
self.task = task
|
||||
self.auto_start = auto_start
|
||||
self.flat = flat
|
||||
self.force = force
|
||||
self.to_skip = False
|
||||
self.missing_videos: list[dict] = []
|
||||
self.added = 0
|
||||
|
|
@ -173,10 +175,16 @@ class PendingList(PendingIndex):
|
|||
PendingInteract(youtube_id=url, status="priority").update_status()
|
||||
return None
|
||||
|
||||
if url in self.missing_videos or url in self.to_skip:
|
||||
if not self.force and (
|
||||
url in self.missing_videos or url in self.to_skip
|
||||
):
|
||||
print(f"{url}: skipped adding already indexed video to download.")
|
||||
return None
|
||||
|
||||
if self.force and url in self.all_ignored or url in self.all_pending:
|
||||
print(f"{url}: skipped adding force video already in queue.")
|
||||
return None
|
||||
|
||||
to_add = self._parse_video(url, vid_type)
|
||||
if to_add:
|
||||
self.missing_videos.append(to_add)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class YtWrap:
|
|||
"extractor_args": {
|
||||
"youtube": {
|
||||
"po_token": [potoken],
|
||||
"player-client": ["web", "default"],
|
||||
"player-client": ["mweb", "default"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}` : ''}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}` : ''}`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { VideoType } from '../pages/Home';
|
|||
import iconPlay from '/img/icon-play.svg';
|
||||
import iconDotMenu from '/img/icon-dot-menu.svg';
|
||||
import iconClose from '/img/icon-close.svg';
|
||||
import iconChecked from '/img/icon-seen.svg';
|
||||
import iconUnchecked from '/img/icon-unseen.svg';
|
||||
import updateWatchedState from '../api/actions/updateWatchedState';
|
||||
import formatDate from '../functions/formatDates';
|
||||
import WatchedCheckBox from './WatchedCheckBox';
|
||||
|
|
@ -12,6 +14,7 @@ import { useState } from 'react';
|
|||
import deleteVideoProgressById from '../api/actions/deleteVideoProgressById';
|
||||
import VideoThumbnail from './VideoThumbail';
|
||||
import { ViewStylesType } from '../configuration/constants/ViewStyle';
|
||||
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
|
||||
|
||||
type VideoListItemProps = {
|
||||
video: VideoType;
|
||||
|
|
@ -31,6 +34,8 @@ const VideoListItem = ({
|
|||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const [showReorderMenu, setShowReorderMenu] = useState(false);
|
||||
const { selectedVideoIds, appendVideoId, removeVideoId, showSelection } =
|
||||
useVideoSelectionStore();
|
||||
|
||||
if (!video) {
|
||||
return <p>No video found.</p>;
|
||||
|
|
@ -38,6 +43,19 @@ const VideoListItem = ({
|
|||
|
||||
return (
|
||||
<div className={`video-item ${viewStyle}`}>
|
||||
{showSelection && (
|
||||
<div className="video-item-select-wrapper">
|
||||
<img
|
||||
src={selectedVideoIds.includes(video.youtube_id) ? iconChecked : iconUnchecked}
|
||||
className="video-item-select"
|
||||
onClick={() =>
|
||||
selectedVideoIds.includes(video.youtube_id)
|
||||
? removeVideoId(video.youtube_id)
|
||||
: appendVideoId(video.youtube_id)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
onClick={() => {
|
||||
setSearchParams(params => {
|
||||
|
|
@ -75,28 +93,32 @@ const VideoListItem = ({
|
|||
</a>
|
||||
<div className={`video-desc ${viewStyle}`}>
|
||||
<div className="video-desc-player" id={`video-info-${video.youtube_id}`}>
|
||||
<WatchedCheckBox
|
||||
watched={video.player.watched}
|
||||
onClick={async status => {
|
||||
await updateWatchedState({
|
||||
id: video.youtube_id,
|
||||
is_watched: status,
|
||||
});
|
||||
}}
|
||||
onDone={() => {
|
||||
refreshVideoList(true);
|
||||
}}
|
||||
/>
|
||||
{video.player.progress && (
|
||||
<img
|
||||
src={iconClose}
|
||||
className="video-popup-menu-close-button"
|
||||
title="Delete watch progress"
|
||||
onClick={async () => {
|
||||
await deleteVideoProgressById(video.youtube_id);
|
||||
refreshVideoList(true);
|
||||
}}
|
||||
/>
|
||||
{!showSelection && (
|
||||
<>
|
||||
<WatchedCheckBox
|
||||
watched={video.player.watched}
|
||||
onClick={async status => {
|
||||
await updateWatchedState({
|
||||
id: video.youtube_id,
|
||||
is_watched: status,
|
||||
});
|
||||
}}
|
||||
onDone={() => {
|
||||
refreshVideoList(true);
|
||||
}}
|
||||
/>
|
||||
{video.player.progress && (
|
||||
<img
|
||||
src={iconClose}
|
||||
className="video-popup-menu-close-button"
|
||||
title="Delete watch progress"
|
||||
onClick={async () => {
|
||||
await deleteVideoProgressById(video.youtube_id);
|
||||
refreshVideoList(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{formatDate(video.published)} | {video.player.duration_str}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import { ViewStylesType } from '../configuration/constants/ViewStyle';
|
|||
import humanFileSize from '../functions/humanFileSize';
|
||||
import { FileSizeUnits } from '../api/actions/updateUserConfig';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import { useVideoSelectionStore } from '../stores/VideoSelectionStore';
|
||||
import iconChecked from '/img/icon-seen.svg';
|
||||
import iconUnchecked from '/img/icon-unseen.svg';
|
||||
|
||||
const StreamsTypeEmun = {
|
||||
Video: 'video',
|
||||
|
|
@ -18,6 +21,8 @@ type VideoListItemProps = {
|
|||
|
||||
const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
|
||||
const { userConfig } = useUserConfigStore();
|
||||
const { showSelection, selectedVideoIds, removeVideoId, appendVideoId } =
|
||||
useVideoSelectionStore();
|
||||
|
||||
const useSiUnits = userConfig.file_size_unit === FileSizeUnits.Metric;
|
||||
|
||||
|
|
@ -26,8 +31,9 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
|
|||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
{showSelection && <th />}
|
||||
<th>Title</th>
|
||||
<th>Channel</th>
|
||||
<th>Type</th>
|
||||
<th>Resolution</th>
|
||||
<th>Media size</th>
|
||||
|
|
@ -45,12 +51,25 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
|
|||
|
||||
return (
|
||||
<tr key={youtube_id}>
|
||||
<td className="no-nowrap">
|
||||
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
|
||||
</td>
|
||||
{showSelection && (
|
||||
<td>
|
||||
<img
|
||||
src={selectedVideoIds.includes(youtube_id) ? iconChecked : iconUnchecked}
|
||||
className="video-item-select"
|
||||
onClick={() =>
|
||||
selectedVideoIds.includes(youtube_id)
|
||||
? removeVideoId(youtube_id)
|
||||
: appendVideoId(youtube_id)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="no-nowrap title">
|
||||
<Link to={Routes.Video(youtube_id)}>{title}</Link>
|
||||
</td>
|
||||
<td className="no-nowrap">
|
||||
<Link to={Routes.Channel(channel.channel_id)}>{channel.channel_name}</Link>
|
||||
</td>
|
||||
<td>{vid_type}</td>
|
||||
<td>{`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}</td>
|
||||
<td>{humanFileSize(media_size, useSiUnits)}</td>
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
interface SelectionState {
|
||||
selectedVideoIds: string[];
|
||||
appendVideoId: (id: string) => void;
|
||||
removeVideoId: (id: string) => void;
|
||||
clearSelected: () => void;
|
||||
showSelection: boolean;
|
||||
setShowSelection: (showSelection: boolean) => void;
|
||||
selectedAction: ((ids: string[]) => void) | null;
|
||||
setSelectedAction: (fn: ((ids: string[]) => void) | null) => void;
|
||||
}
|
||||
|
||||
export const useVideoSelectionStore = create<SelectionState>(set => ({
|
||||
selectedVideoIds: [],
|
||||
|
||||
appendVideoId: id =>
|
||||
set(state => {
|
||||
if (state.selectedVideoIds.includes(id)) {
|
||||
return state; // avoid duplicates
|
||||
}
|
||||
return { selectedVideoIds: [...state.selectedVideoIds, id] };
|
||||
}),
|
||||
|
||||
removeVideoId: id =>
|
||||
set(state => ({
|
||||
selectedVideoIds: state.selectedVideoIds.filter(item => item !== id),
|
||||
})),
|
||||
|
||||
clearSelected: () => set({ selectedVideoIds: [] }),
|
||||
|
||||
showSelection: false,
|
||||
setShowSelection: (showSelection: boolean) => set({ showSelection }),
|
||||
|
||||
selectedAction: null,
|
||||
setSelectedAction: fn => set({ selectedAction: fn }),
|
||||
}));
|
||||
|
|
@ -178,6 +178,11 @@ button:disabled:hover {
|
|||
min-width: 100px;
|
||||
}
|
||||
|
||||
.video-item.table td img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.button-box {
|
||||
padding: 5px 2px;
|
||||
display: inline-flex;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue