From 090d88c3368a2ecc1d549d7714706cb16b8e6ae1 Mon Sep 17 00:00:00 2001 From: Greg <134153833+greg321321@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:57:59 -0400 Subject: [PATCH] Feature 590 custom playlist (#620) * add remove custom playlist * custom playlist page, move video controls * align to existing code patterns * cleanup * resolve merge conflict * cleanup * cleanup * polish * polish * some fixes for lint * resolve merge conflict * bugfix on delete video/playlist/channel - preserve custom playlist but delete corresponding videos in custom playlist * cleanup * ./deploy.sh validate isort fix - validate runs clean now * sync to latest master branch * sync to master * updates per admin guidance. sync to master * attempt to resolve merge conflict * attempt to resolve merge conflict - reintroduce changes to file. * validate playlist_type * validate playlist custom action * move custom id creation to view * stricter custom playlist matching * revert unreachable playlist delete check * undo unneeded playlist matching --------- Co-authored-by: Simon --- tubearchivist/api/views.py | 44 ++++- .../config/management/commands/ta_startup.py | 35 ++++ tubearchivist/home/src/download/thumbnails.py | 15 +- tubearchivist/home/src/frontend/forms.py | 14 ++ tubearchivist/home/src/index/channel.py | 1 + tubearchivist/home/src/index/playlist.py | 182 ++++++++++++++++++ tubearchivist/home/src/index/reindex.py | 5 +- tubearchivist/home/src/index/video.py | 2 + tubearchivist/home/src/ta/urlparser.py | 2 +- .../home/templates/home/playlist.html | 23 ++- .../home/templates/home/playlist_id.html | 91 ++++++--- tubearchivist/home/templates/home/video.html | 1 + tubearchivist/home/views.py | 34 ++-- tubearchivist/static/css/style.css | 39 +++- .../static/img/default-playlist-thumb.jpg | Bin 0 -> 24479 bytes .../static/img/icon-arrow-bottom.svg | 14 ++ tubearchivist/static/img/icon-arrow-down.svg | 6 + tubearchivist/static/img/icon-arrow-top.svg | 7 + tubearchivist/static/img/icon-arrow-up.svg | 6 + tubearchivist/static/img/icon-dot-menu.svg | 4 + tubearchivist/static/img/icon-remove.svg | 4 + tubearchivist/static/script.js | 144 +++++++++++++- 22 files changed, 611 insertions(+), 62 deletions(-) create mode 100644 tubearchivist/static/img/default-playlist-thumb.jpg create mode 100644 tubearchivist/static/img/icon-arrow-bottom.svg create mode 100644 tubearchivist/static/img/icon-arrow-down.svg create mode 100644 tubearchivist/static/img/icon-arrow-top.svg create mode 100644 tubearchivist/static/img/icon-arrow-up.svg create mode 100644 tubearchivist/static/img/icon-dot-menu.svg create mode 100644 tubearchivist/static/img/icon-remove.svg diff --git a/tubearchivist/api/views.py b/tubearchivist/api/views.py index e026dba..77c345a 100644 --- a/tubearchivist/api/views.py +++ b/tubearchivist/api/views.py @@ -40,7 +40,7 @@ from home.tasks import ( run_restore_backup, subscribe_to, ) -from rest_framework import permissions +from rest_framework import permissions, status from rest_framework.authentication import ( SessionAuthentication, TokenAuthentication, @@ -462,12 +462,26 @@ class PlaylistApiListView(ApiBaseView): search_base = "ta_playlist/_search/" permission_classes = [AdminWriteOnly] + valid_playlist_type = ["regular", "custom"] def get(self, request): """handle get request""" - self.data.update( - {"sort": [{"playlist_name.keyword": {"order": "asc"}}]} - ) + playlist_type = request.GET.get("playlist_type", None) + query = {"sort": [{"playlist_name.keyword": {"order": "asc"}}]} + if playlist_type is not None: + if playlist_type not in self.valid_playlist_type: + message = f"invalid playlist_type {playlist_type}" + return Response({"message": message}, status=400) + + query.update( + { + "query": { + "term": {"playlist_type": {"value": playlist_type}} + }, + } + ) + + self.data.update(query) self.get_document_list(request) return Response(self.response) @@ -511,6 +525,7 @@ class PlaylistApiView(ApiBaseView): search_base = "ta_playlist/_doc/" permission_classes = [AdminWriteOnly] + valid_custom_actions = ["create", "remove", "up", "down", "top", "bottom"] def get(self, request, playlist_id): # pylint: disable=unused-argument @@ -518,6 +533,27 @@ class PlaylistApiView(ApiBaseView): self.get_document(playlist_id) return Response(self.response, status=self.status_code) + def post(self, request, playlist_id): + """post to custom playlist to add a video to list""" + playlist = YoutubePlaylist(playlist_id) + if not playlist.is_custom_playlist(): + message = f"playlist with ID {playlist_id} is not custom" + return Response({"message": message}, status=400) + + action = request.data.get("action") + if action not in self.valid_custom_actions: + message = f"invalid action: {action}" + return Response({"message": message}, status=400) + + video_id = request.data.get("video_id") + if action == "create": + playlist.add_video_to_playlist(video_id) + else: + hide = UserConfig(request.user.id).get_value("hide_watched") + playlist.move_video(video_id, action, hide_watched=hide) + + return Response({"success": True}, status=status.HTTP_201_CREATED) + def delete(self, request, playlist_id): """delete playlist""" print(f"{playlist_id}: delete playlist") diff --git a/tubearchivist/config/management/commands/ta_startup.py b/tubearchivist/config/management/commands/ta_startup.py index b387fb4..16d43d2 100644 --- a/tubearchivist/config/management/commands/ta_startup.py +++ b/tubearchivist/config/management/commands/ta_startup.py @@ -8,6 +8,7 @@ import os from time import sleep from django.core.management.base import BaseCommand, CommandError +from home.src.es.connect import ElasticWrap from home.src.es.index_setup import ElasitIndexWrap from home.src.es.snapshot import ElasticSnapshot from home.src.ta.config import AppConfig, ReleaseVersion @@ -44,6 +45,7 @@ class Command(BaseCommand): self._mig_index_setup() self._mig_snapshot_check() self._mig_move_users_to_es() + self._mig_custom_playlist() def _sync_redis_state(self): """make sure redis gets new config.json values""" @@ -242,3 +244,36 @@ class Command(BaseCommand): " ✓ Settings for all users migrated to ES" ) ) + + def _mig_custom_playlist(self): + """migration for custom playlist""" + self.stdout.write("[MIGRATION] custom playlist") + data = { + "query": { + "bool": {"must_not": [{"exists": {"field": "playlist_type"}}]} + }, + "script": {"source": "ctx._source['playlist_type'] = 'regular'"}, + } + path = "ta_playlist/_update_by_query" + response, status_code = ElasticWrap(path).post(data=data) + if status_code == 200: + updated = response.get("updated", 0) + if updated: + self.stdout.write( + self.style.SUCCESS( + f" ✓ {updated} playlist_type updated in ta_playlist" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + " no playlist_type needed updating in ta_playlist" + ) + ) + return + + message = " 🗙 ta_playlist playlist_type update failed" + self.stdout.write(self.style.ERROR(message)) + self.stdout.write(response) + sleep(60) + raise CommandError(message) diff --git a/tubearchivist/home/src/download/thumbnails.py b/tubearchivist/home/src/download/thumbnails.py index 896f603..a2698fd 100644 --- a/tubearchivist/home/src/download/thumbnails.py +++ b/tubearchivist/home/src/download/thumbnails.py @@ -75,7 +75,7 @@ class ThumbManagerBase: app_root, "static/img/default-video-thumb.jpg" ), "playlist": os.path.join( - app_root, "static/img/default-video-thumb.jpg" + app_root, "static/img/default-playlist-thumb.jpg" ), "icon": os.path.join( app_root, "static/img/default-channel-icon.jpg" @@ -202,7 +202,18 @@ class ThumbManager(ThumbManagerBase): if skip_existing and os.path.exists(thumb_path): return - img_raw = self.download_raw(url) + img_raw = ( + self.download_raw(url) + if not isinstance(url, str) or url.startswith("http") + else Image.open(os.path.join(self.CACHE_DIR, url)) + ) + width, height = img_raw.size + + if not width / height == 16 / 9: + new_height = width / 16 * 9 + offset = (height - new_height) / 2 + img_raw = img_raw.crop((0, offset, width, height - offset)) + img_raw = img_raw.resize((336, 189)) img_raw.convert("RGB").save(thumb_path) def delete_video_thumb(self): diff --git a/tubearchivist/home/src/frontend/forms.py b/tubearchivist/home/src/frontend/forms.py index 5044456..dde9021 100644 --- a/tubearchivist/home/src/frontend/forms.py +++ b/tubearchivist/home/src/frontend/forms.py @@ -265,6 +265,20 @@ class SubscribeToPlaylistForm(forms.Form): ) +class CreatePlaylistForm(forms.Form): + """text area form to create a single custom playlist""" + + create = forms.CharField( + label="Or create custom playlist", + widget=forms.Textarea( + attrs={ + "rows": 1, + "placeholder": "Input playlist name", + } + ), + ) + + class ChannelOverwriteForm(forms.Form): """custom overwrites for channel settings""" diff --git a/tubearchivist/home/src/index/channel.py b/tubearchivist/home/src/index/channel.py index 3bfaec1..08166f7 100644 --- a/tubearchivist/home/src/index/channel.py +++ b/tubearchivist/home/src/index/channel.py @@ -213,6 +213,7 @@ class YoutubeChannel(YouTubeItem): all_playlists = self.get_indexed_playlists() for playlist in all_playlists: playlist_id = playlist["playlist_id"] + playlist = YoutubePlaylist(playlist_id) YoutubePlaylist(playlist_id).delete_metadata() def delete_channel(self): diff --git a/tubearchivist/home/src/index/playlist.py b/tubearchivist/home/src/index/playlist.py index 656da4a..196a884 100644 --- a/tubearchivist/home/src/index/playlist.py +++ b/tubearchivist/home/src/index/playlist.py @@ -66,6 +66,7 @@ class YoutubePlaylist(YouTubeItem): "playlist_thumbnail": playlist_thumbnail, "playlist_description": self.youtube_meta["description"] or False, "playlist_last_refresh": int(datetime.now().timestamp()), + "playlist_type": "regular", } def get_entries(self, playlistend=False): @@ -178,6 +179,7 @@ class YoutubePlaylist(YouTubeItem): def delete_metadata(self): """delete metadata for playlist""" + self.delete_videos_metadata() script = ( "ctx._source.playlist.removeAll(" + "Collections.singleton(params.playlist)) " @@ -195,6 +197,30 @@ class YoutubePlaylist(YouTubeItem): _, _ = ElasticWrap("ta_video/_update_by_query").post(data) self.del_in_es() + def is_custom_playlist(self): + self.get_from_es() + return self.json_data["playlist_type"] == "custom" + + def delete_videos_metadata(self, channel_id=None): + """delete video metadata for a specific channel""" + self.get_from_es() + playlist = self.json_data["playlist_entries"] + i = 0 + while i < len(playlist): + video_id = playlist[i]["youtube_id"] + video = YoutubeVideo(video_id) + video.get_from_es() + if ( + channel_id is None + or video.json_data["channel"]["channel_id"] == channel_id + ): + playlist.pop(i) + self.remove_playlist_from_video(video_id) + i -= 1 + i += 1 + self.set_playlist_thumbnail() + self.upload_to_es() + def delete_videos_playlist(self): """delete playlist with all videos""" print(f"{self.youtube_id}: delete playlist") @@ -208,3 +234,159 @@ class YoutubePlaylist(YouTubeItem): YoutubeVideo(youtube_id).delete_media_file() self.delete_metadata() + + def create(self, name): + self.json_data = { + "playlist_id": self.youtube_id, + "playlist_active": False, + "playlist_name": name, + "playlist_last_refresh": int(datetime.now().timestamp()), + "playlist_entries": [], + "playlist_type": "custom", + "playlist_channel": None, + "playlist_channel_id": None, + "playlist_description": False, + "playlist_thumbnail": False, + "playlist_subscribed": False, + } + self.upload_to_es() + self.get_playlist_art() + return True + + def add_video_to_playlist(self, video_id): + self.get_from_es() + video_metadata = self.get_video_metadata(video_id) + video_metadata["idx"] = len(self.json_data["playlist_entries"]) + + if not self.playlist_entries_contains(video_id): + self.json_data["playlist_entries"].append(video_metadata) + self.json_data["playlist_last_refresh"] = int( + datetime.now().timestamp() + ) + self.set_playlist_thumbnail() + self.upload_to_es() + video = YoutubeVideo(video_id) + video.get_from_es() + if "playlist" not in video.json_data: + video.json_data["playlist"] = [] + video.json_data["playlist"].append(self.youtube_id) + video.upload_to_es() + return True + + def remove_playlist_from_video(self, video_id): + video = YoutubeVideo(video_id) + video.get_from_es() + if video.json_data is not None and "playlist" in video.json_data: + video.json_data["playlist"].remove(self.youtube_id) + video.upload_to_es() + + def move_video(self, video_id, action, hide_watched=False): + self.get_from_es() + video_index = self.get_video_index(video_id) + playlist = self.json_data["playlist_entries"] + item = playlist[video_index] + playlist.pop(video_index) + if action == "remove": + self.remove_playlist_from_video(item["youtube_id"]) + else: + if action == "up": + while True: + video_index = max(0, video_index - 1) + if ( + not hide_watched + or video_index == 0 + or ( + not self.get_video_is_watched( + playlist[video_index]["youtube_id"] + ) + ) + ): + break + elif action == "down": + while True: + video_index = min(len(playlist), video_index + 1) + if ( + not hide_watched + or video_index == len(playlist) + or ( + not self.get_video_is_watched( + playlist[video_index - 1]["youtube_id"] + ) + ) + ): + break + elif action == "top": + video_index = 0 + else: + video_index = len(playlist) + playlist.insert(video_index, item) + self.json_data["playlist_last_refresh"] = int( + datetime.now().timestamp() + ) + + for i, item in enumerate(playlist): + item["idx"] = i + + self.set_playlist_thumbnail() + self.upload_to_es() + + return True + + def del_video(self, video_id): + playlist = self.json_data["playlist_entries"] + + i = 0 + while i < len(playlist): + if video_id == playlist[i]["youtube_id"]: + playlist.pop(i) + self.set_playlist_thumbnail() + i -= 1 + i += 1 + + def get_video_index(self, video_id): + for i, child in enumerate(self.json_data["playlist_entries"]): + if child["youtube_id"] == video_id: + return i + return -1 + + def playlist_entries_contains(self, video_id): + return ( + len( + list( + filter( + lambda x: x["youtube_id"] == video_id, + self.json_data["playlist_entries"], + ) + ) + ) + > 0 + ) + + def get_video_is_watched(self, video_id): + video = YoutubeVideo(video_id) + video.get_from_es() + return video.json_data["player"]["watched"] + + def set_playlist_thumbnail(self): + playlist = self.json_data["playlist_entries"] + self.json_data["playlist_thumbnail"] = False + + for video in playlist: + url = ThumbManager(video["youtube_id"]).vid_thumb_path() + if url is not None: + self.json_data["playlist_thumbnail"] = url + break + self.get_playlist_art() + + def get_video_metadata(self, video_id): + video = YoutubeVideo(video_id) + video.get_from_es() + video_json_data = { + "youtube_id": video.json_data["youtube_id"], + "title": video.json_data["title"], + "uploader": video.json_data["channel"]["channel_name"], + "idx": 0, + "downloaded": "date_downloaded" in video.json_data + and video.json_data["date_downloaded"] > 0, + } + return video_json_data diff --git a/tubearchivist/home/src/index/reindex.py b/tubearchivist/home/src/index/reindex.py index fe4d986..10a5596 100644 --- a/tubearchivist/home/src/index/reindex.py +++ b/tubearchivist/home/src/index/reindex.py @@ -363,7 +363,10 @@ class Reindex(ReindexBase): self._get_all_videos() playlist = YoutubePlaylist(playlist_id) playlist.get_from_es() - if not playlist.json_data: + if ( + not playlist.json_data + or playlist.json_data["playlist_type"] == "custom" + ): return subscribed = playlist.json_data["playlist_subscribed"] diff --git a/tubearchivist/home/src/index/video.py b/tubearchivist/home/src/index/video.py index 1b258ae..41f0114 100644 --- a/tubearchivist/home/src/index/video.py +++ b/tubearchivist/home/src/index/video.py @@ -319,6 +319,8 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle): playlist.json_data["playlist_entries"][idx].update( {"downloaded": False} ) + if playlist.json_data["playlist_type"] == "custom": + playlist.del_video(self.youtube_id) playlist.upload_to_es() def delete_subtitles(self, subtitles=False): diff --git a/tubearchivist/home/src/ta/urlparser.py b/tubearchivist/home/src/ta/urlparser.py index 04429fa..743af98 100644 --- a/tubearchivist/home/src/ta/urlparser.py +++ b/tubearchivist/home/src/ta/urlparser.py @@ -92,7 +92,7 @@ class Parser: item_type = "video" elif len_id_str == 24: item_type = "channel" - elif len_id_str in (34, 26, 18): + elif len_id_str in (34, 26, 18) or id_str.startswith("TA_playlist_"): item_type = "playlist" else: raise ValueError(f"not a valid id_str: {id_str}") diff --git a/tubearchivist/home/templates/home/playlist.html b/tubearchivist/home/templates/home/playlist.html index ab2d788..ea7baba 100644 --- a/tubearchivist/home/templates/home/playlist.html +++ b/tubearchivist/home/templates/home/playlist.html @@ -11,13 +11,18 @@ {% if request.user|has_group:"admin" or request.user.is_staff %}
- add-icon + add-icon
{% csrf_token %} {{ subscribe_form }}
+
+ {% csrf_token %} + {{ create_form }} + +
{% endif %} @@ -51,14 +56,18 @@
-

{{ playlist.playlist_channel }}

+ {% if playlist.playlist_type != "custom" %} +

{{ playlist.playlist_channel }}

+ {% endif %}

{{ playlist.playlist_name }}

Last refreshed: {{ playlist.playlist_last_refresh }}

- {% if playlist.playlist_subscribed %} - - {% else %} - - {% endif %} + {% if playlist.playlist_type != "custom" %} + {% if playlist.playlist_subscribed %} + + {% else %} + + {% endif %} + {% endif %}
{% endfor %} diff --git a/tubearchivist/home/templates/home/playlist_id.html b/tubearchivist/home/templates/home/playlist_id.html index 5fbe194..9a234c1 100644 --- a/tubearchivist/home/templates/home/playlist_id.html +++ b/tubearchivist/home/templates/home/playlist_id.html @@ -9,37 +9,42 @@

{{ playlist_info.playlist_name }}

-
-
- - channel-thumb - -
-
-

{{ channel_info.channel_name }}

- {% if channel_info.channel_subs >= 1000000 %} - Subscribers: {{ channel_info.channel_subs|intword }} - {% else %} - Subscribers: {{ channel_info.channel_subs|intcomma }} - {% endif %} -
-
+ {% if playlist_info.playlist_type != "custom" %} +
+
+ + channel-thumb + +
+
+

{{ channel_info.channel_name }}

+ {% if channel_info.channel_subs >= 1000000 %} + Subscribers: {{ channel_info.channel_subs|intword }} + {% else %} + Subscribers: {{ channel_info.channel_subs|intcomma }} + {% endif %} +
+
+ {% endif %} +

Last refreshed: {{ playlist_info.playlist_last_refresh }}

-

Playlist: - {% if playlist_info.playlist_subscribed %} - {% if request.user|has_group:"admin" or request.user.is_staff %} - - {% endif %} - {% else %} - - {% endif %} -

- {% if playlist_info.playlist_active %} -

Youtube: Active

- {% else %} -

Youtube: Deactivated

+ {% if playlist_info.playlist_type != "custom" %} +

Playlist: + {% if playlist_info.playlist_subscribed %} + {% if request.user|has_group:"admin" or request.user.is_staff %} + + {% endif %} + {% else %} + + {% endif %} +

+ {% if playlist_info.playlist_active %} +

Youtube: Active

+ {% else %} +

Youtube: Deactivated

+ {% endif %} {% endif %}
@@ -63,7 +68,9 @@

Reindex scheduled

{% else %}
- + {% if playlist_info.playlist_type != "custom" %} + + {% endif %}
{% endif %} @@ -138,15 +145,35 @@ {% endif %} {{ video.published }} | {{ video.player.duration_str }}
-
-

{{ video.title }}

+
+
+ {% if playlist_info.playlist_type == "custom" %} +

{{ video.channel.channel_name }}

+ {% endif %} +

{{ video.title }}

+
+ {% if playlist_info.playlist_type == "custom" %} + {% if pagination %} + {% if pagination.last_page > 0 %} + dot-menu-icon + {% else %} + dot-menu-icon + {% endif %} + {% else %} + dot-menu-icon + {% endif %} + {% endif %}
{% endfor %} {% else %}

No videos found...

-

Try going to the downloads page to start the scan and download tasks.

+ {% if playlist_info.playlist_type == "custom" %} +

Try going to the home page to add videos to this playlist.

+ {% else %} +

Try going to the downloads page to start the scan and download tasks.

+ {% endif %} {% endif %}
diff --git a/tubearchivist/home/templates/home/video.html b/tubearchivist/home/templates/home/video.html index b9ad04a..bb78267 100644 --- a/tubearchivist/home/templates/home/video.html +++ b/tubearchivist/home/templates/home/video.html @@ -95,6 +95,7 @@ Are you sure? {% endif %} +
diff --git a/tubearchivist/home/views.py b/tubearchivist/home/views.py index 36acf17..e87cc27 100644 --- a/tubearchivist/home/views.py +++ b/tubearchivist/home/views.py @@ -6,6 +6,7 @@ Functionality: import enum import urllib.parse +import uuid from time import sleep from api.src.search_processor import SearchProcess, process_aggs @@ -27,6 +28,7 @@ from home.src.frontend.forms import ( AddToQueueForm, ApplicationSettingsForm, ChannelOverwriteForm, + CreatePlaylistForm, CustomAuthForm, MultiSearchForm, SchedulerSettingsForm, @@ -740,12 +742,12 @@ class PlaylistIdView(ArchivistResultsView): # playlist details es_path = f"ta_playlist/_doc/{playlist_id}" playlist_info = self.single_lookup(es_path) - - # channel details - channel_id = playlist_info["playlist_channel_id"] - es_path = f"ta_channel/_doc/{channel_id}" - channel_info = self.single_lookup(es_path) - + channel_info = None + if playlist_info["playlist_type"] != "custom": + # channel details + channel_id = playlist_info["playlist_channel_id"] + es_path = f"ta_channel/_doc/{channel_id}" + channel_info = self.single_lookup(es_path) return playlist_info, channel_info def _update_view_data(self, playlist_id, playlist_info): @@ -803,6 +805,7 @@ class PlaylistView(ArchivistResultsView): { "title": "Playlists", "subscribe_form": SubscribeToPlaylistForm(), + "create_form": CreatePlaylistForm(), } ) @@ -837,12 +840,19 @@ class PlaylistView(ArchivistResultsView): @method_decorator(user_passes_test(check_admin), name="dispatch") @staticmethod def post(request): - """handle post from search form""" - subscribe_form = SubscribeToPlaylistForm(data=request.POST) - if subscribe_form.is_valid(): - url_str = request.POST.get("subscribe") - print(url_str) - subscribe_to.delay(url_str, expected_type="playlist") + """handle post from subscribe or create form""" + if request.POST.get("create") is not None: + create_form = CreatePlaylistForm(data=request.POST) + if create_form.is_valid(): + name = request.POST.get("create") + playlist_id = f"TA_playlist_{uuid.uuid4()}" + YoutubePlaylist(playlist_id).create(name) + else: + subscribe_form = SubscribeToPlaylistForm(data=request.POST) + if subscribe_form.is_valid(): + url_str = request.POST.get("subscribe") + print(url_str) + subscribe_to.delay(url_str, expected_type="playlist") sleep(1) return redirect("playlist") diff --git a/tubearchivist/static/css/style.css b/tubearchivist/static/css/style.css index 4856105..67eba90 100644 --- a/tubearchivist/static/css/style.css +++ b/tubearchivist/static/css/style.css @@ -371,11 +371,23 @@ button:hover { filter: var(--img-filter); } +.video-popup-menu { + border-top: 2px solid; + border-color: var(--accent-font-dark); + margin: 5px 0; + padding-top: 10px; +} + #hidden-form { display: none; } -#hidden-form button { +#hidden-form2 { + display: none; + margin-top: 10px; +} + +#hidden-form button, #hidden-form2 button { margin-right: 1rem; } @@ -564,6 +576,12 @@ video:-webkit-full-screen { margin-right: 10px; } +.video-popup-menu img.move-video-button { + width: 24px; + cursor: pointer; + filter: var(--img-filter); +} + .video-desc a { text-decoration: none; text-align: left; @@ -592,7 +610,13 @@ video:-webkit-full-screen { align-items: center; } +.video-desc-details { + display: flex; + justify-content: space-between; +} + .watch-button, +.dot-button, .close-button { cursor: pointer; filter: var(--img-filter); @@ -682,6 +706,19 @@ video:-webkit-full-screen { width: 100%; } +.video-popup-menu img { + width: 12px; + cursor: pointer; + filter: var(--img-filter); +} + + +.video-popup-menu-close-button { + cursor: pointer; + filter: var(--img-filter); + float:right; +} + .description-text { width: 100%; } diff --git a/tubearchivist/static/img/default-playlist-thumb.jpg b/tubearchivist/static/img/default-playlist-thumb.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6bbaa227032e31daad7edcf1143834c4a6d7cdad GIT binary patch literal 24479 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

}y74`JEz=h18L+2$yyr~U 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 DTV~}6 literal 0 HcmV?d00001 diff --git a/tubearchivist/static/img/icon-arrow-bottom.svg b/tubearchivist/static/img/icon-arrow-bottom.svg new file mode 100644 index 0000000..e829250 --- /dev/null +++ b/tubearchivist/static/img/icon-arrow-bottom.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/tubearchivist/static/img/icon-arrow-down.svg b/tubearchivist/static/img/icon-arrow-down.svg new file mode 100644 index 0000000..0d7adba --- /dev/null +++ b/tubearchivist/static/img/icon-arrow-down.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/tubearchivist/static/img/icon-arrow-top.svg b/tubearchivist/static/img/icon-arrow-top.svg new file mode 100644 index 0000000..bb4b80c --- /dev/null +++ b/tubearchivist/static/img/icon-arrow-top.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tubearchivist/static/img/icon-arrow-up.svg b/tubearchivist/static/img/icon-arrow-up.svg new file mode 100644 index 0000000..71c9652 --- /dev/null +++ b/tubearchivist/static/img/icon-arrow-up.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/tubearchivist/static/img/icon-dot-menu.svg b/tubearchivist/static/img/icon-dot-menu.svg new file mode 100644 index 0000000..9aa4411 --- /dev/null +++ b/tubearchivist/static/img/icon-dot-menu.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tubearchivist/static/img/icon-remove.svg b/tubearchivist/static/img/icon-remove.svg new file mode 100644 index 0000000..f73c5a6 --- /dev/null +++ b/tubearchivist/static/img/icon-remove.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tubearchivist/static/script.js b/tubearchivist/static/script.js index b1e6ad1..8bf726c 100644 --- a/tubearchivist/static/script.js +++ b/tubearchivist/static/script.js @@ -197,6 +197,137 @@ function addToQueue(autostart = false) { showForm(); } +//shows the video sub menu popup +function showAddToPlaylistMenu(input1) { + let dataId, playlists, form_code, buttonId; + dataId = input1.getAttribute('data-id'); + buttonId = input1.getAttribute('id'); + playlists = getCustomPlaylists(); + + //hide the invoking button + input1.style.visibility = "hidden"; + + //show the form + form_code = '

'; + input1.parentNode.parentNode.innerHTML += form_code; +} + +//handles user action of adding a video to a custom playlist +function addToCustomPlaylist(input, video_id, playlist_id) { + let apiEndpoint = '/api/playlist/' + playlist_id + '/'; + let data = { "action": "create", "video_id": video_id }; + apiRequest(apiEndpoint, 'POST', data); + + //mark the item added in the ui + input.firstChild.src='/static/img/icon-seen.svg'; +} + +function removeDotMenu(input1, button_id) { + + //show the menu button + document.getElementById(button_id).style.visibility = "visible"; + + //remove the form + input1.parentNode.remove(); +} + +//shows the video sub menu popup on custom playlist page +function showCustomPlaylistMenu(input1, playlist_id, current_page, last_page) { + let dataId, form_code, buttonId; + dataId = input1.getAttribute('data-id'); + buttonId = input1.getAttribute('id'); + + //hide the invoking button + input1.style.visibility = "hidden"; + + //show the form + form_code = '

Move Video

'; + + form_code += ''; + form_code += ''; + form_code += ''; + form_code += ''; + form_code += ''; + + + form_code += '
'; + input1.parentNode.parentNode.innerHTML += form_code; +} + +//process custom playlist form actions +function moveCustomPlaylistVideo(input1, playlist_id, current_page, last_page) { + let dataId, dataContext; + dataId = input1.getAttribute('data-id'); + dataContext = input1.getAttribute('data-context'); + + let apiEndpoint = '/api/playlist/' + playlist_id + '/'; + let data = { "action": dataContext, "video_id": dataId }; + apiRequest(apiEndpoint, 'POST', data); + + let itemDom = input1.parentElement.parentElement.parentElement; + let listDom = itemDom.parentElement; + + if (dataContext === "up") + { + let sibling = itemDom.previousElementSibling; + if (sibling !== null) + { + sibling.before(itemDom); + } + else if (current_page > 1) + { + itemDom.remove(); + } + } + else if (dataContext === "down") + { + let sibling = itemDom.nextElementSibling; + if (sibling !== null) + { + sibling.after(itemDom); + } + else if (current_page !== last_page) + { + itemDom.remove(); + } + } + else if (dataContext === "top") + { + let sibling = listDom.firstElementChild; + if (sibling !== null) + { + sibling.before(itemDom); + } + if (current_page > 1) + { + itemDom.remove(); + } + } + else if (dataContext === "bottom") + { + let sibling = listDom.lastElementChild; + if (sibling !== null) + { + sibling.after(itemDom); + } + if (current_page !== last_page) + { + itemDom.remove(); + } + } + else if (dataContext === "remove") + { + itemDom.remove(); + } +} + function toIgnore(button) { let youtube_id = button.getAttribute('data-id'); let apiEndpoint = '/api/download/' + youtube_id + '/'; @@ -773,6 +904,13 @@ function getPlaylistData(playlistId) { return playlistData.data; } +// Gets custom playlists +function getCustomPlaylists() { + let apiEndpoint = '/api/playlist/?playlist_type=custom'; + let playlistData = apiRequest(apiEndpoint, 'GET'); + return playlistData.data; +} + // Get video progress data when passed video ID function getVideoProgress(videoId) { let apiEndpoint = '/api/video/' + videoId + '/progress/'; @@ -1383,8 +1521,10 @@ function textExpandButtonVisibilityUpdate() { document.addEventListener('readystatechange', textExpandButtonVisibilityUpdate); window.addEventListener('resize', textExpandButtonVisibilityUpdate); -function showForm() { - let formElement = document.getElementById('hidden-form'); +function showForm(id) { + + let id2 = id === undefined ? 'hidden-form' : id; + let formElement = document.getElementById(id2); let displayStyle = formElement.style.display; if (displayStyle === '') { formElement.style.display = 'block';