Compare commits

...

27 Commits

Author SHA1 Message Date
Simon
0c487e6339 bump TA_VERSION 2024-04-10 18:40:55 +02:00
Simon
f7ad1000c7 bump es version 2024-04-10 18:40:40 +02:00
Simon
aecd189d04 bump requirements 2024-04-10 18:40:30 +02:00
Simon
b735a770e3 Notification improvements, #build
Changed:
- Changed more robust channel title building
- Ensure 100 progress message is sent
- Remove mig_path at startup
- Fix comment extraction fail due to redirect
- Fix duplicate notification message threads
2024-04-05 15:11:24 +02:00
Simon
5c84a2cbf8 fix getMessages getting called multiple times in parallel 2024-04-05 15:03:44 +02:00
Simon
a4d062fa52 fix comment extraction player_client for redirect workaround 2024-04-05 14:40:22 +02:00
Simon
9c34bb01d9 fix spelling 2024-04-03 21:04:26 +02:00
Simon
8c38a2eb69 clarify feature requests and contributing details 2024-04-03 21:02:33 +02:00
Simon
25edff28e7 Merge branch 'master' into testing 2024-04-03 16:52:24 +02:00
lamusmaser
731f4b6111 Add additional user scripts. (#682)
* Add additional user scripts.

* Add new repo.

* clarify license

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2024-04-03 21:52:06 +07:00
Simon
e512329599 remove migpath call at startup, #687 2024-04-03 16:40:16 +02:00
Simon
e26b039899 bump requirements 2024-04-03 16:39:24 +02:00
Simon
8bf7f71351 ensure 100 download progress is sent 2024-04-03 16:31:36 +02:00
Simon
a72be27982 more robust channel title building 2024-03-15 12:01:09 +01:00
Simon
b2c1b417e5 add unstable tag 2024-03-11 21:57:03 +01:00
Simon
a348b4a810 Custom user created Playlists, #build
Changed:
- Added playlist create form
- Removed autoplay
- Disable progress less than 10s
- Better cookie jar error logs
- bump yt-dlp
2024-03-11 21:30:18 +01:00
Simon
bb8db53f7d bump yt-dlp 2024-03-11 18:18:59 +01:00
Simon
2711537a4d clarify cookie import choices, #672 2024-03-11 18:12:56 +01:00
dot-mike
45f455070d Fix rare edge case where comment author is None. (#676)
This happens mostly for older YT profiles that has not set-up a @-handle.
2024-03-11 23:56:18 +07:00
Simon
6dcef70b8e skip empty subtitle, #663 2024-03-11 17:50:50 +01:00
Simon
c993a5de5c bump dependencies 2024-03-10 17:38:23 +01:00
Greg
090d88c336 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 <simobilleter@gmail.com>
2024-03-10 22:57:59 +07:00
Nick
0e967d721f log cookiejar.LoadError (#669) 2024-03-10 22:35:15 +07:00
Simon
c32dbf8bc8 Merge branch 'master' into testing 2024-03-10 16:34:19 +01:00
dot-mike
df08a6d591 Add in user script (#680)
* remove autoplay, disable video progress less than 10s

* Update readme.md. Add in user script. Format user scripts as bulleted list

* Revert "remove autoplay, disable video progress less than 10s"

This reverts commit 8778546577.

---------

Co-authored-by: Simon <simobilleter@gmail.com>
2024-03-10 22:32:40 +07:00
DarkFighterLuke
9339b9227e Add base URL setup in README.md User Scripts section (#664) 2024-03-10 22:11:10 +07:00
Simon
8778546577 remove autoplay, disable video progress less than 10s 2024-02-05 21:55:05 +01:00
34 changed files with 667 additions and 91 deletions

View File

@@ -37,12 +37,12 @@ Please keep in mind:
- A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*. - A bug that can't be reproduced, is difficult or sometimes even impossible to fix. Provide very clear steps *how to reproduce*.
### Feature Request ### Feature Request
This project needs your help to grow further. There is no shortage of ideas, see the open [issues on GH](https://github.com/tubearchivist/tubearchivist/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) and the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), what this project lacks is contributors to implement these ideas. This project needs your help to grow further. There is no shortage of ideas, see the open [issues on GH](https://github.com/tubearchivist/tubearchivist/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) and the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), what this project lacks is contributors interested in helping with overall improvements of the application. Focus is *not* on adding new features, but improving existing ones.
Existing ideas are easily *multiple years* worth of development effort, at least at current speed. Best and fastest way to implement your feature is to do it yourself, that's why this project is open source after all. This project is *very* selective with accepting new feature requests at this point. Existing ideas are easily *multiple years* worth of development effort, at least at current speed. This project is *very* selective with accepting new feature requests at this point.
Good feature requests usually fall into one or more of these categories: Good feature requests usually fall into one or more of these categories:
- You want to work on your own idea within the next few days or weeks. - You want to work on your own small scoped idea within the next few days or weeks.
- Your idea is beneficial for a wide range of users, not just for you. - Your idea is beneficial for a wide range of users, not just for you.
- Your idea extends the current project by building on and improving existing functionality. - Your idea extends the current project by building on and improving existing functionality.
- Your idea is quick and easy to implement, for an experienced as well as for a first time contributor. - Your idea is quick and easy to implement, for an experienced as well as for a first time contributor.
@@ -66,7 +66,9 @@ IMPORTANT: When receiving help, contribute back to the community by improving th
## How to make a Pull Request ## How to make a Pull Request
Thank you for contributing and helping improve this project. This is a quick checklist to help streamline the process: Thank you for contributing and helping improve this project. Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application.
This is a quick checklist to help streamline the process:
- For **code changes**, make your PR against the [testing branch](https://github.com/tubearchivist/tubearchivist/tree/testing). That's where all active development happens. This simplifies the later merging into *master*, minimizes any conflicts and usually allows for easy and convenient *fast-forward* merging. - For **code changes**, make your PR against the [testing branch](https://github.com/tubearchivist/tubearchivist/tree/testing). That's where all active development happens. This simplifies the later merging into *master*, minimizes any conflicts and usually allows for easy and convenient *fast-forward* merging.
- For **documentation changes**, make your PR directly against the *master* branch. - For **documentation changes**, make your PR directly against the *master* branch.

View File

@@ -188,9 +188,17 @@ Implemented:
- [X] Scan your file system to index already downloaded videos [2021-09-14] - [X] Scan your file system to index already downloaded videos [2021-09-14]
## User Scripts ## User Scripts
This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. This is your time to shine, [read this](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#user-scripts) then open a PR to add your script here. This is a list of useful user scripts, generously created from folks like you to extend this project and its functionality. Make sure to check the respective repository links for detailed license information.
[danieljue/ta_dl_page_script](https://github.com/danieljue/ta_dl_page_script): Helper browser script to prioritize a channels' videos in download queue. This is your time to shine, [read this](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#user-scripts) then open a PR to add your script here.
- [danieljue/ta_dl_page_script](https://github.com/danieljue/ta_dl_page_script): Helper browser script to prioritize a channels' videos in download queue.
- [dot-mike/ta-scripts](https://github.com/dot-mike/ta-scripts): A collection of personal scripts for managing TubeArchivist.
- [DarkFighterLuke/ta_base_url_nginx](https://gist.github.com/DarkFighterLuke/4561b6bfbf83720493dc59171c58ac36): Set base URL with Nginx when you can't use subdomains.
- [lamusmaser/ta_migration_helper](https://github.com/lamusmaser/ta_migration_helper): Advanced helper script for migration issues to TubeArchivist v0.4.4 or later.
- [lamusmaser/create_info_json](https://gist.github.com/lamusmaser/837fb58f73ea0cad784a33497932e0dd): Script to generate `.info.json` files using `ffmpeg` collecting information from downloaded videos.
- [lamusmaser/ta_fix_for_video_redirection](https://github.com/lamusmaser/ta_fix_for_video_redirection): Script to fix videos that were incorrectly indexed by YouTube's "Video is Unavailable" response.
- [RoninTech/ta-helper](https://github.com/RoninTech/ta-helper): Helper script to provide a symlink association to reference TubeArchivist videos with their original titles.
## Donate ## Donate
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started. The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.

View File

@@ -40,7 +40,7 @@ services:
depends_on: depends_on:
- archivist-es - archivist-es
archivist-es: archivist-es:
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.12.0 image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.13.2
container_name: archivist-es container_name: archivist-es
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -14,7 +14,6 @@ fi
python manage.py ta_envcheck python manage.py ta_envcheck
python manage.py ta_connection python manage.py ta_connection
python manage.py ta_startup python manage.py ta_startup
python manage.py ta_migpath
# start all tasks # start all tasks
nginx & nginx &

View File

@@ -40,7 +40,7 @@ from home.tasks import (
run_restore_backup, run_restore_backup,
subscribe_to, subscribe_to,
) )
from rest_framework import permissions from rest_framework import permissions, status
from rest_framework.authentication import ( from rest_framework.authentication import (
SessionAuthentication, SessionAuthentication,
TokenAuthentication, TokenAuthentication,
@@ -462,12 +462,26 @@ class PlaylistApiListView(ApiBaseView):
search_base = "ta_playlist/_search/" search_base = "ta_playlist/_search/"
permission_classes = [AdminWriteOnly] permission_classes = [AdminWriteOnly]
valid_playlist_type = ["regular", "custom"]
def get(self, request): def get(self, request):
"""handle get request""" """handle get request"""
self.data.update( playlist_type = request.GET.get("playlist_type", None)
{"sort": [{"playlist_name.keyword": {"order": "asc"}}]} 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) self.get_document_list(request)
return Response(self.response) return Response(self.response)
@@ -511,6 +525,7 @@ class PlaylistApiView(ApiBaseView):
search_base = "ta_playlist/_doc/" search_base = "ta_playlist/_doc/"
permission_classes = [AdminWriteOnly] permission_classes = [AdminWriteOnly]
valid_custom_actions = ["create", "remove", "up", "down", "top", "bottom"]
def get(self, request, playlist_id): def get(self, request, playlist_id):
# pylint: disable=unused-argument # pylint: disable=unused-argument
@@ -518,6 +533,27 @@ class PlaylistApiView(ApiBaseView):
self.get_document(playlist_id) self.get_document(playlist_id)
return Response(self.response, status=self.status_code) 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): def delete(self, request, playlist_id):
"""delete playlist""" """delete playlist"""
print(f"{playlist_id}: delete playlist") print(f"{playlist_id}: delete playlist")

View File

@@ -1,4 +1,8 @@
"""filepath migration from v0.3.6 to v0.3.7""" """
filepath migration from v0.3.6 to v0.3.7
not getting called at startup any more, to run manually if needed:
python manage.py ta_migpath
"""
import json import json
import os import os

View File

@@ -8,6 +8,7 @@ import os
from time import sleep from time import sleep
from django.core.management.base import BaseCommand, CommandError 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.index_setup import ElasitIndexWrap
from home.src.es.snapshot import ElasticSnapshot from home.src.es.snapshot import ElasticSnapshot
from home.src.ta.config import AppConfig, ReleaseVersion from home.src.ta.config import AppConfig, ReleaseVersion
@@ -44,6 +45,7 @@ class Command(BaseCommand):
self._mig_index_setup() self._mig_index_setup()
self._mig_snapshot_check() self._mig_snapshot_check()
self._mig_move_users_to_es() self._mig_move_users_to_es()
self._mig_custom_playlist()
def _sync_redis_state(self): def _sync_redis_state(self):
"""make sure redis gets new config.json values""" """make sure redis gets new config.json values"""
@@ -242,3 +244,36 @@ class Command(BaseCommand):
" ✓ Settings for all users migrated to ES" " ✓ 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)

View File

@@ -269,4 +269,4 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings # TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist" TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.4.6" TA_VERSION = "v0.4.7"

View File

@@ -75,7 +75,7 @@ class ThumbManagerBase:
app_root, "static/img/default-video-thumb.jpg" app_root, "static/img/default-video-thumb.jpg"
), ),
"playlist": os.path.join( "playlist": os.path.join(
app_root, "static/img/default-video-thumb.jpg" app_root, "static/img/default-playlist-thumb.jpg"
), ),
"icon": os.path.join( "icon": os.path.join(
app_root, "static/img/default-channel-icon.jpg" app_root, "static/img/default-channel-icon.jpg"
@@ -202,7 +202,18 @@ class ThumbManager(ThumbManagerBase):
if skip_existing and os.path.exists(thumb_path): if skip_existing and os.path.exists(thumb_path):
return 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) img_raw.convert("RGB").save(thumb_path)
def delete_video_thumb(self): def delete_video_thumb(self):

View File

@@ -62,8 +62,8 @@ class YtWrap:
"""make extract request""" """make extract request"""
try: try:
response = yt_dlp.YoutubeDL(self.obs).extract_info(url) response = yt_dlp.YoutubeDL(self.obs).extract_info(url)
except cookiejar.LoadError: except cookiejar.LoadError as err:
print("cookie file is invalid") print(f"cookie file is invalid: {err}")
return False return False
except yt_dlp.utils.ExtractorError as err: except yt_dlp.utils.ExtractorError as err:
print(f"{url}: failed to extract with message: {err}, continue...") print(f"{url}: failed to extract with message: {err}, continue...")

View File

@@ -177,7 +177,7 @@ class VideoDownloader:
if not success: if not success:
continue continue
self._notify(video_data, "Add video metadata to index") self._notify(video_data, "Add video metadata to index", progress=1)
vid_dict = index_new_video( vid_dict = index_new_video(
youtube_id, youtube_id,
@@ -197,14 +197,16 @@ class VideoDownloader:
return self.videos return self.videos
def _notify(self, video_data, message): def _notify(self, video_data, message, progress=False):
"""send progress notification to task""" """send progress notification to task"""
if not self.task: if not self.task:
return return
typ = VideoTypeEnum(video_data["vid_type"]).value.rstrip("s").title() typ = VideoTypeEnum(video_data["vid_type"]).value.rstrip("s").title()
title = video_data.get("title") title = video_data.get("title")
self.task.send_progress([f"Processing {typ}: {title}", message]) self.task.send_progress(
[f"Processing {typ}: {title}", message], progress=progress
)
def _get_next(self, auto_only): def _get_next(self, auto_only):
"""get next item in queue""" """get next item in queue"""

View File

@@ -105,8 +105,8 @@ class ApplicationSettingsForm(forms.Form):
COOKIE_IMPORT_CHOICES = [ COOKIE_IMPORT_CHOICES = [
("", "-- change cookie settings"), ("", "-- change cookie settings"),
("0", "disable cookie"), ("0", "remove cookie"),
("1", "enable cookie"), ("1", "import cookie"),
] ]
subscriptions_channel_size = forms.IntegerField( subscriptions_channel_size = forms.IntegerField(
@@ -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): class ChannelOverwriteForm(forms.Form):
"""custom overwrites for channel settings""" """custom overwrites for channel settings"""

View File

@@ -213,6 +213,7 @@ class YoutubeChannel(YouTubeItem):
all_playlists = self.get_indexed_playlists() all_playlists = self.get_indexed_playlists()
for playlist in all_playlists: for playlist in all_playlists:
playlist_id = playlist["playlist_id"] playlist_id = playlist["playlist_id"]
playlist = YoutubePlaylist(playlist_id)
YoutubePlaylist(playlist_id).delete_metadata() YoutubePlaylist(playlist_id).delete_metadata()
def delete_channel(self): def delete_channel(self):

View File

@@ -68,6 +68,7 @@ class Comments:
"youtube": { "youtube": {
"max_comments": max_comments_list, "max_comments": max_comments_list,
"comment_sort": [comment_sort], "comment_sort": [comment_sort],
"player_client": ["ios", "web"], # workaround yt-dlp #9554
} }
}, },
} }
@@ -115,6 +116,9 @@ class Comments:
time_text = time_text_datetime.strftime(format_string) time_text = time_text_datetime.strftime(format_string)
if not comment.get("author"):
comment["author"] = comment.get("author_id", "Unknown")
cleaned_comment = { cleaned_comment = {
"comment_id": comment["id"], "comment_id": comment["id"],
"comment_text": comment["text"].replace("\xa0", ""), "comment_text": comment["text"].replace("\xa0", ""),

View File

@@ -66,6 +66,7 @@ class YoutubePlaylist(YouTubeItem):
"playlist_thumbnail": playlist_thumbnail, "playlist_thumbnail": playlist_thumbnail,
"playlist_description": self.youtube_meta["description"] or False, "playlist_description": self.youtube_meta["description"] or False,
"playlist_last_refresh": int(datetime.now().timestamp()), "playlist_last_refresh": int(datetime.now().timestamp()),
"playlist_type": "regular",
} }
def get_entries(self, playlistend=False): def get_entries(self, playlistend=False):
@@ -178,6 +179,7 @@ class YoutubePlaylist(YouTubeItem):
def delete_metadata(self): def delete_metadata(self):
"""delete metadata for playlist""" """delete metadata for playlist"""
self.delete_videos_metadata()
script = ( script = (
"ctx._source.playlist.removeAll(" "ctx._source.playlist.removeAll("
+ "Collections.singleton(params.playlist)) " + "Collections.singleton(params.playlist)) "
@@ -195,6 +197,30 @@ class YoutubePlaylist(YouTubeItem):
_, _ = ElasticWrap("ta_video/_update_by_query").post(data) _, _ = ElasticWrap("ta_video/_update_by_query").post(data)
self.del_in_es() 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): def delete_videos_playlist(self):
"""delete playlist with all videos""" """delete playlist with all videos"""
print(f"{self.youtube_id}: delete playlist") print(f"{self.youtube_id}: delete playlist")
@@ -208,3 +234,159 @@ class YoutubePlaylist(YouTubeItem):
YoutubeVideo(youtube_id).delete_media_file() YoutubeVideo(youtube_id).delete_media_file()
self.delete_metadata() 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

View File

@@ -363,7 +363,10 @@ class Reindex(ReindexBase):
self._get_all_videos() self._get_all_videos()
playlist = YoutubePlaylist(playlist_id) playlist = YoutubePlaylist(playlist_id)
playlist.get_from_es() playlist.get_from_es()
if not playlist.json_data: if (
not playlist.json_data
or playlist.json_data["playlist_type"] == "custom"
):
return return
subscribed = playlist.json_data["playlist_subscribed"] subscribed = playlist.json_data["playlist_subscribed"]

View File

@@ -128,6 +128,10 @@ class YoutubeSubtitle:
print(response.text) print(response.text)
continue continue
if not response.text:
print(f"{self.video.youtube_id}: skip empty subtitle")
continue
parser = SubtitleParser(response.text, lang, source) parser = SubtitleParser(response.text, lang, source)
parser.process() parser.process()
if not parser.all_cues: if not parser.all_cues:

View File

@@ -319,6 +319,8 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
playlist.json_data["playlist_entries"][idx].update( playlist.json_data["playlist_entries"][idx].update(
{"downloaded": False} {"downloaded": False}
) )
if playlist.json_data["playlist_type"] == "custom":
playlist.del_video(self.youtube_id)
playlist.upload_to_es() playlist.upload_to_es()
def delete_subtitles(self, subtitles=False): def delete_subtitles(self, subtitles=False):

View File

@@ -92,7 +92,7 @@ class Parser:
item_type = "video" item_type = "video"
elif len_id_str == 24: elif len_id_str == 24:
item_type = "channel" 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" item_type = "playlist"
else: else:
raise ValueError(f"not a valid id_str: {id_str}") raise ValueError(f"not a valid id_str: {id_str}")

View File

@@ -11,13 +11,18 @@
{% if request.user|has_group:"admin" or request.user.is_staff %} {% if request.user|has_group:"admin" or request.user.is_staff %}
<div class="title-split-form"> <div class="title-split-form">
<img id="animate-icon" onclick="showForm()" src="{% static 'img/icon-add.svg' %}" alt="add-icon" title="Subscribe to Playlists"> <img id="animate-icon" onclick="showForm();showForm('hidden-form2')" src="{% static 'img/icon-add.svg' %}" alt="add-icon" title="Subscribe to Playlists">
<div class="show-form"> <div class="show-form">
<form id="hidden-form" action="/playlist/" method="post"> <form id="hidden-form" action="/playlist/" method="post">
{% csrf_token %} {% csrf_token %}
{{ subscribe_form }} {{ subscribe_form }}
<button type="submit">Subscribe</button> <button type="submit">Subscribe</button>
</form> </form>
<form id="hidden-form2" action="/playlist/" method="post">
{% csrf_token %}
{{ create_form }}
<button type="submit">Create</button>
</form>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -51,14 +56,18 @@
</a> </a>
</div> </div>
<div class="playlist-desc {{ view_style }}"> <div class="playlist-desc {{ view_style }}">
<a href="{% url 'channel_id' playlist.playlist_channel_id %}"><h3>{{ playlist.playlist_channel }}</h3></a> {% if playlist.playlist_type != "custom" %}
<a href="{% url 'channel_id' playlist.playlist_channel_id %}"><h3>{{ playlist.playlist_channel }}</h3></a>
{% endif %}
<a href="{% url 'playlist_id' playlist.playlist_id %}"><h2>{{ playlist.playlist_name }}</h2></a> <a href="{% url 'playlist_id' playlist.playlist_id %}"><h2>{{ playlist.playlist_name }}</h2></a>
<p>Last refreshed: {{ playlist.playlist_last_refresh }}</p> <p>Last refreshed: {{ playlist.playlist_last_refresh }}</p>
{% if playlist.playlist_subscribed %} {% if playlist.playlist_type != "custom" %}
<button class="unsubscribe" type="button" data-type="playlist" data-subscribe="" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ playlist.playlist_name }}">Unsubscribe</button> {% if playlist.playlist_subscribed %}
{% else %} <button class="unsubscribe" type="button" data-type="playlist" data-subscribe="" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ playlist.playlist_name }}">Unsubscribe</button>
<button type="button" data-type="playlist" data-subscribe="true" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ playlist.playlist_name }}">Subscribe</button> {% else %}
{% endif %} <button type="button" data-type="playlist" data-subscribe="true" data-id="{{ playlist.playlist_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ playlist.playlist_name }}">Subscribe</button>
{% endif %}
{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@@ -9,37 +9,42 @@
<h1>{{ playlist_info.playlist_name }}</h1> <h1>{{ playlist_info.playlist_name }}</h1>
</div> </div>
<div class="info-box info-box-3"> <div class="info-box info-box-3">
<div class="info-box-item"> {% if playlist_info.playlist_type != "custom" %}
<div class="round-img"> <div class="info-box-item">
<a href="{% url 'channel_id' channel_info.channel_id %}"> <div class="round-img">
<img src="/cache/channels/{{ channel_info.channel_id }}_thumb.jpg" alt="channel-thumb"> <a href="{% url 'channel_id' channel_info.channel_id %}">
</a> <img src="/cache/channels/{{ channel_info.channel_id }}_thumb.jpg" alt="channel-thumb">
</div> </a>
<div> </div>
<h3><a href="{% url 'channel_id' channel_info.channel_id %}">{{ channel_info.channel_name }}</a></h3> <div>
{% if channel_info.channel_subs >= 1000000 %} <h3><a href="{% url 'channel_id' channel_info.channel_id %}">{{ channel_info.channel_name }}</a></h3>
<span>Subscribers: {{ channel_info.channel_subs|intword }}</span> {% if channel_info.channel_subs >= 1000000 %}
{% else %} <span>Subscribers: {{ channel_info.channel_subs|intword }}</span>
<span>Subscribers: {{ channel_info.channel_subs|intcomma }}</span> {% else %}
{% endif %} <span>Subscribers: {{ channel_info.channel_subs|intcomma }}</span>
</div> {% endif %}
</div> </div>
</div>
{% endif %}
<div class="info-box-item"> <div class="info-box-item">
<div> <div>
<p>Last refreshed: {{ playlist_info.playlist_last_refresh }}</p> <p>Last refreshed: {{ playlist_info.playlist_last_refresh }}</p>
<p>Playlist: {% if playlist_info.playlist_type != "custom" %}
{% if playlist_info.playlist_subscribed %} <p>Playlist:
{% if request.user|has_group:"admin" or request.user.is_staff %} {% if playlist_info.playlist_subscribed %}
<button class="unsubscribe" type="button" data-type="playlist" data-subscribe="" data-id="{{ playlist_info.playlist_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ playlist_info.playlist_name }}">Unsubscribe</button> {% if request.user|has_group:"admin" or request.user.is_staff %}
{% endif %} <button class="unsubscribe" type="button" data-type="playlist" data-subscribe="" data-id="{{ playlist_info.playlist_id }}" onclick="subscribeStatus(this)" title="Unsubscribe from {{ playlist_info.playlist_name }}">Unsubscribe</button>
{% else %} {% endif %}
<button type="button" data-type="playlist" data-subscribe="true" data-id="{{ playlist_info.playlist_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ playlist_info.playlist_name }}">Subscribe</button> {% else %}
{% endif %} <button type="button" data-type="playlist" data-subscribe="true" data-id="{{ playlist_info.playlist_id }}" onclick="subscribeStatus(this)" title="Subscribe to {{ playlist_info.playlist_name }}">Subscribe</button>
</p> {% endif %}
{% if playlist_info.playlist_active %} </p>
<p>Youtube: <a href="https://www.youtube.com/playlist?list={{ playlist_info.playlist_id }}" target="_blank">Active</a></p> {% if playlist_info.playlist_active %}
{% else %} <p>Youtube: <a href="https://www.youtube.com/playlist?list={{ playlist_info.playlist_id }}" target="_blank">Active</a></p>
<p>Youtube: Deactivated</p> {% else %}
<p>Youtube: Deactivated</p>
{% endif %}
{% endif %} {% endif %}
<button onclick="deleteConfirm()" id="delete-item">Delete Playlist</button> <button onclick="deleteConfirm()" id="delete-item">Delete Playlist</button>
<div class="delete-confirm" id="delete-button"> <div class="delete-confirm" id="delete-button">
@@ -63,7 +68,9 @@
<p>Reindex scheduled</p> <p>Reindex scheduled</p>
{% else %} {% else %}
<div id="reindex-button" class="button-box"> <div id="reindex-button" class="button-box">
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" onclick="reindex(this)" title="Reindex Playlist {{ playlist_info.playlist_name }}">Reindex</button> {% if playlist_info.playlist_type != "custom" %}
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" onclick="reindex(this)" title="Reindex Playlist {{ playlist_info.playlist_name }}">Reindex</button>
{% endif %}
<button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" data-extract-videos="true" onclick="reindex(this)" title="Reindex Videos of {{ playlist_info.playlist_name }}">Reindex Videos</button> <button data-id="{{ playlist_info.playlist_id }}" data-type="playlist" data-extract-videos="true" onclick="reindex(this)" title="Reindex Videos of {{ playlist_info.playlist_name }}">Reindex Videos</button>
</div> </div>
{% endif %} {% endif %}
@@ -138,15 +145,35 @@
{% endif %} {% endif %}
<span>{{ video.published }} | {{ video.player.duration_str }}</span> <span>{{ video.published }} | {{ video.player.duration_str }}</span>
</div> </div>
<div> <div class="video-desc-details">
<a class="video-more" href="{% url 'video' video.youtube_id %}"><h2>{{ video.title }}</h2></a> <div>
{% if playlist_info.playlist_type == "custom" %}
<a href="{% url 'channel_id' video.channel.channel_id %}"><h3>{{ video.channel.channel_name }}</h3></a>
{% endif %}
<a class="video-more" href="{% url 'video' video.youtube_id %}"><h2>{{ video.title }}</h2></a>
</div>
{% if playlist_info.playlist_type == "custom" %}
{% if pagination %}
{% if pagination.last_page > 0 %}
<img id="{{ video.youtube_id }}-button" src="{% static 'img/icon-dot-menu.svg' %}" alt="dot-menu-icon" data-id="{{ video.youtube_id }}" data-context="video" onclick="showCustomPlaylistMenu(this,'{{playlist_info.playlist_id}}',{{pagination.current_page}},{{pagination.last_page}})" class="dot-button" title="More actions">
{% else %}
<img id="{{ video.youtube_id }}-button" src="{% static 'img/icon-dot-menu.svg' %}" alt="dot-menu-icon" data-id="{{ video.youtube_id }}" data-context="video" onclick="showCustomPlaylistMenu(this,'{{playlist_info.playlist_id}}',{{pagination.current_page}},{{pagination.current_page}})" class="dot-button" title="More actions">
{% endif %}
{% else %}
<img id="{{ video.youtube_id }}-button" src="{% static 'img/icon-dot-menu.svg' %}" alt="dot-menu-icon" data-id="{{ video.youtube_id }}" data-context="video" onclick="showCustomPlaylistMenu(this,'{{playlist_info.playlist_id}}',0,0)" class="dot-button" title="More actions">
{% endif %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<h2>No videos found...</h2> <h2>No videos found...</h2>
<p>Try going to the <a href="{% url 'downloads' %}">downloads page</a> to start the scan and download tasks.</p> {% if playlist_info.playlist_type == "custom" %}
<p>Try going to the <a href="{% url 'home' %}">home page</a> to add videos to this playlist.</p>
{% else %}
<p>Try going to the <a href="{% url 'downloads' %}">downloads page</a> to start the scan and download tasks.</p>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -95,6 +95,7 @@
<span>Are you sure? </span><button class="danger-button" onclick="deleteVideo(this)" data-id="{{ video.youtube_id }}" data-redirect = "{{ video.channel.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button> <span>Are you sure? </span><button class="danger-button" onclick="deleteVideo(this)" data-id="{{ video.youtube_id }}" data-redirect = "{{ video.channel.channel_id }}">Delete</button> <button onclick="cancelDelete()">Cancel</button>
</div> </div>
{% endif %} {% endif %}
<button id="{{ video.youtube_id }}-button" data-id="{{ video.youtube_id }}" data-context="video" onclick="showAddToPlaylistMenu(this)">Add To Playlist</button>
</div> </div>
</div> </div>
<div class="info-box-item"> <div class="info-box-item">

View File

@@ -6,6 +6,7 @@ Functionality:
import enum import enum
import urllib.parse import urllib.parse
import uuid
from time import sleep from time import sleep
from api.src.search_processor import SearchProcess, process_aggs from api.src.search_processor import SearchProcess, process_aggs
@@ -27,6 +28,7 @@ from home.src.frontend.forms import (
AddToQueueForm, AddToQueueForm,
ApplicationSettingsForm, ApplicationSettingsForm,
ChannelOverwriteForm, ChannelOverwriteForm,
CreatePlaylistForm,
CustomAuthForm, CustomAuthForm,
MultiSearchForm, MultiSearchForm,
SchedulerSettingsForm, SchedulerSettingsForm,
@@ -522,7 +524,7 @@ class ChannelIdView(ChannelIdBaseView):
self.context.update( self.context.update(
{ {
"title": "Channel: " + channel_name, "title": f"Channel: {channel_name}",
"channel_info": channel_info, "channel_info": channel_info,
} }
) )
@@ -740,12 +742,12 @@ class PlaylistIdView(ArchivistResultsView):
# playlist details # playlist details
es_path = f"ta_playlist/_doc/{playlist_id}" es_path = f"ta_playlist/_doc/{playlist_id}"
playlist_info = self.single_lookup(es_path) playlist_info = self.single_lookup(es_path)
channel_info = None
# channel details if playlist_info["playlist_type"] != "custom":
channel_id = playlist_info["playlist_channel_id"] # channel details
es_path = f"ta_channel/_doc/{channel_id}" channel_id = playlist_info["playlist_channel_id"]
channel_info = self.single_lookup(es_path) es_path = f"ta_channel/_doc/{channel_id}"
channel_info = self.single_lookup(es_path)
return playlist_info, channel_info return playlist_info, channel_info
def _update_view_data(self, playlist_id, playlist_info): def _update_view_data(self, playlist_id, playlist_info):
@@ -803,6 +805,7 @@ class PlaylistView(ArchivistResultsView):
{ {
"title": "Playlists", "title": "Playlists",
"subscribe_form": SubscribeToPlaylistForm(), "subscribe_form": SubscribeToPlaylistForm(),
"create_form": CreatePlaylistForm(),
} }
) )
@@ -837,12 +840,19 @@ class PlaylistView(ArchivistResultsView):
@method_decorator(user_passes_test(check_admin), name="dispatch") @method_decorator(user_passes_test(check_admin), name="dispatch")
@staticmethod @staticmethod
def post(request): def post(request):
"""handle post from search form""" """handle post from subscribe or create form"""
subscribe_form = SubscribeToPlaylistForm(data=request.POST) if request.POST.get("create") is not None:
if subscribe_form.is_valid(): create_form = CreatePlaylistForm(data=request.POST)
url_str = request.POST.get("subscribe") if create_form.is_valid():
print(url_str) name = request.POST.get("create")
subscribe_to.delay(url_str, expected_type="playlist") 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) sleep(1)
return redirect("playlist") return redirect("playlist")

View File

@@ -1,13 +1,13 @@
apprise==1.7.1 apprise==1.7.5
celery==5.3.6 celery==5.3.6
Django==4.2.9 Django==5.0.4
django-auth-ldap==4.6.0 django-auth-ldap==4.8.0
django-cors-headers==4.3.1 django-cors-headers==4.3.1
djangorestframework==3.14.0 djangorestframework==3.15.1
Pillow==10.2.0 Pillow==10.3.0
redis==5.0.0 redis==5.0.0
requests==2.31.0 requests==2.31.0
ryd-client==0.0.6 ryd-client==0.0.6
uWSGI==2.0.23 uWSGI==2.0.24
whitenoise==6.6.0 whitenoise==6.6.0
yt-dlp==2023.12.30 yt-dlp==2024.4.9

View File

@@ -371,11 +371,23 @@ button:hover {
filter: var(--img-filter); 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 { #hidden-form {
display: none; display: none;
} }
#hidden-form button { #hidden-form2 {
display: none;
margin-top: 10px;
}
#hidden-form button, #hidden-form2 button {
margin-right: 1rem; margin-right: 1rem;
} }
@@ -564,6 +576,12 @@ video:-webkit-full-screen {
margin-right: 10px; margin-right: 10px;
} }
.video-popup-menu img.move-video-button {
width: 24px;
cursor: pointer;
filter: var(--img-filter);
}
.video-desc a { .video-desc a {
text-decoration: none; text-decoration: none;
text-align: left; text-align: left;
@@ -592,7 +610,13 @@ video:-webkit-full-screen {
align-items: center; align-items: center;
} }
.video-desc-details {
display: flex;
justify-content: space-between;
}
.watch-button, .watch-button,
.dot-button,
.close-button { .close-button {
cursor: pointer; cursor: pointer;
filter: var(--img-filter); filter: var(--img-filter);
@@ -682,6 +706,19 @@ video:-webkit-full-screen {
width: 100%; 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 { .description-text {
width: 100%; width: 100%;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="svg1303" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 500 500"
style="enable-background:new 0 0 500 500;" xml:space="preserve">
<g>
<path d="M231.3,382c4.8,5.3,11.6,8.3,18.7,8.3c7.1,0,13.9-3,18.7-8.3l112.1-124.2c6.7-7.4,8.4-18,4.3-27.1
c-4-9.1-13.1-14.9-23-14.9h-52.9V64.9c0-13.9-11.3-25.2-25.2-25.2h-68c-13.9,0-25.2,11.3-25.2,25.2v150.9h-52.9
c-10,0-19,5.9-23,14.9c-4,9.1-2.3,19.7,4.3,27.1L231.3,382z"/>
<path d="M436.1,408.8H63.9c-13.6,0-24.7,11.1-24.7,24.7c0,13.6,11.1,24.7,24.7,24.7h372.2c13.6,0,24.7-11.1,24.7-24.7
C460.8,419.9,449.7,408.8,436.1,408.8z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xmlns="http://www.w3.org/2000/svg">
<g style="" transform="matrix(0.9999999999999999, 0, 0, -0.9999999999999999, 0, 497.9000392526395)">
<path d="M 231.3 48 C 236.1 42.7 242.9 39.7 250 39.7 C 257.1 39.7 263.9 42.7 268.7 48 L 380.8 172.2 C 387.5 179.6 389.2 190.2 385.1 199.3 C 381.1 208.4 372 214.2 362.1 214.2 L 309.2 214.2 L 309.2 365.1 C 309.2 379 297.9 390.3 284 390.3 L 216 390.3 C 202.1 390.3 190.8 379 190.8 365.1 L 190.8 214.2 L 137.9 214.2 C 127.9 214.2 118.9 208.3 114.9 199.3 C 110.9 190.2 112.6 179.6 119.2 172.2 L 231.3 48 Z" style=""/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xmlns="http://www.w3.org/2000/svg">
<g style="" transform="matrix(0.9999999999999999, 0, 0, -0.9999999999999999, 0, 497.9000392526395)">
<path d="M231.3,382c4.8,5.3,11.6,8.3,18.7,8.3c7.1,0,13.9-3,18.7-8.3l112.1-124.2c6.7-7.4,8.4-18,4.3-27.1 c-4-9.1-13.1-14.9-23-14.9h-52.9V64.9c0-13.9-11.3-25.2-25.2-25.2h-68c-13.9,0-25.2,11.3-25.2,25.2v150.9h-52.9 c-10,0-19,5.9-23,14.9c-4,9.1-2.3,19.7,4.3,27.1L231.3,382z"/>
<path d="M436.1,408.8H63.9c-13.6,0-24.7,11.1-24.7,24.7c0,13.6,11.1,24.7,24.7,24.7h372.2c13.6,0,24.7-11.1,24.7-24.7 C460.8,419.9,449.7,408.8,436.1,408.8z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 698 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xmlns="http://www.w3.org/2000/svg">
<g style="" transform="matrix(0.9999999999999999, 0, 0, -0.9999999999999999, 0, 497.9000392526395)">
<path d="M231.3,382c4.8,5.3,11.6,8.3,18.7,8.3c7.1,0,13.9-3,18.7-8.3l112.1-124.2c6.7-7.4,8.4-18,4.3-27.1 c-4-9.1-13.1-14.9-23-14.9h-52.9V64.9c0-13.9-11.3-25.2-25.2-25.2h-68c-13.9,0-25.2,11.3-25.2,25.2v150.9h-52.9 c-10,0-19,5.9-23,14.9c-4,9.1-2.3,19.7,4.3,27.1L231.3,382z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 538 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="#000000" class="bi bi-three-dots-vertical">
<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xmlns="http://www.w3.org/2000/svg">
<path d="M 408.514 358.563 L 303.835 255.003 L 408.441 149.115 C 424.1 133.406 424.1 107.662 408.441 91.953 C 392.783 76.245 367.12 76.245 351.463 91.953 L 246.857 197.841 L 142.323 91.881 C 126.665 76.172 101.002 76.172 85.344 91.881 C 69.613 107.662 69.613 133.334 85.272 149.115 L 189.805 255.003 L 84.401 359.291 C 68.743 375 68.743 400.744 84.401 416.453 C 100.06 432.161 125.722 432.161 141.381 416.453 L 246.784 312.165 L 351.39 415.726 C 367.048 431.434 392.711 431.434 408.369 415.726 C 424.172 400.017 424.172 374.345 408.514 358.563 Z" style="" transform="matrix(0.9999999999999999, 0, 0, 0.9999999999999999, 0, 0)"/>
</svg>

After

Width:  |  Height:  |  Size: 782 B

View File

@@ -12,7 +12,7 @@ checkMessages();
// start to look for messages // start to look for messages
function checkMessages() { function checkMessages() {
let notifications = document.getElementById('notifications'); let notifications = document.getElementById('notifications');
if (notifications) { if (notifications && notifications.childNodes.length === 0 ) {
let dataOrigin = notifications.getAttribute('data'); let dataOrigin = notifications.getAttribute('data');
getMessages(dataOrigin); getMessages(dataOrigin);
} }

View File

@@ -197,6 +197,137 @@ function addToQueue(autostart = false) {
showForm(); 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 = '<div class="video-popup-menu"><img src="/static/img/icon-close.svg" class="video-popup-menu-close-button" title="Close menu" onclick="removeDotMenu(this, \''+buttonId+'\')"/><h3>Add video to...</h3>';
for(let i = 0; i < playlists.length; i++) {
let obj = playlists[i];
form_code += '<p onclick="addToCustomPlaylist(this, \''+dataId+'\',\''+obj.playlist_id+'\')"><img class="p-button" src="/static/img/icon-unseen.svg"/>'+obj.playlist_name+'</p>';
}
form_code += '<p><a href="/playlist">Create playlist</a></p></div>';
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 = '<div class="video-popup-menu"><img src="/static/img/icon-close.svg" class="video-popup-menu-close-button" title="Close menu" onclick="removeDotMenu(this, \''+buttonId+'\')"/><h3>Move Video</h3>';
form_code += '<img class="move-video-button" data-id="'+dataId+'" data-context="top" onclick="moveCustomPlaylistVideo(this,\''+playlist_id+'\','+current_page+','+last_page+')" src="/static/img/icon-arrow-top.svg" title="Move to top"/>';
form_code += '<img class="move-video-button" data-id="'+dataId+'" data-context="up" onclick="moveCustomPlaylistVideo(this,\''+playlist_id+'\','+current_page+','+last_page+')" src="/static/img/icon-arrow-up.svg" title="Move up"/>';
form_code += '<img class="move-video-button" data-id="'+dataId+'" data-context="down" onclick="moveCustomPlaylistVideo(this,\''+playlist_id+'\','+current_page+','+last_page+')" src="/static/img/icon-arrow-down.svg" title="Move down"/>';
form_code += '<img class="move-video-button" data-id="'+dataId+'" data-context="bottom" onclick="moveCustomPlaylistVideo(this,\''+playlist_id+'\','+current_page+','+last_page+')" src="/static/img/icon-arrow-bottom.svg" title="Move to bottom"/>';
form_code += '<img class="move-video-button" data-id="'+dataId+'" data-context="remove" onclick="moveCustomPlaylistVideo(this,\''+playlist_id+'\','+current_page+','+last_page+')" src="/static/img/icon-remove.svg" title="Remove from playlist"/>';
form_code += '</div>';
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) { function toIgnore(button) {
let youtube_id = button.getAttribute('data-id'); let youtube_id = button.getAttribute('data-id');
let apiEndpoint = '/api/download/' + youtube_id + '/'; let apiEndpoint = '/api/download/' + youtube_id + '/';
@@ -451,7 +582,7 @@ function createPlayer(button) {
} }
let videoName = videoData.data.title; let videoName = videoData.data.title;
let videoTag = createVideoTag(videoData, videoProgress); let videoTag = createVideoTag(videoData, videoProgress, true);
let playlist = ''; let playlist = '';
let videoPlaylists = videoData.data.playlist; // Array of playlists the video is in let videoPlaylists = videoData.data.playlist; // Array of playlists the video is in
@@ -529,7 +660,7 @@ function insertVideoTag(videoData, videoProgress) {
} }
// Generates a video tag with subtitles when passed videoData and videoProgress. // Generates a video tag with subtitles when passed videoData and videoProgress.
function createVideoTag(videoData, videoProgress) { function createVideoTag(videoData, videoProgress, autoplay = false) {
let videoId = videoData.data.youtube_id; let videoId = videoData.data.youtube_id;
let videoUrl = videoData.data.media_url; let videoUrl = videoData.data.media_url;
let videoThumbUrl = videoData.data.vid_thumb_url; let videoThumbUrl = videoData.data.vid_thumb_url;
@@ -546,7 +677,9 @@ function createVideoTag(videoData, videoProgress) {
} }
let videoTag = ` let videoTag = `
<video poster="${videoThumbUrl}" onvolumechange="onVolumeChange(this)" onloadstart="this.volume=getPlayerVolume()" ontimeupdate="onVideoProgress()" onpause="onVideoPause()" onended="onVideoEnded()" controls autoplay width="100%" playsinline id="video-item"> <video poster="${videoThumbUrl}" onvolumechange="onVolumeChange(this)" onloadstart="this.volume=getPlayerVolume()" ontimeupdate="onVideoProgress()" onpause="onVideoPause()" onended="onVideoEnded()" ${
autoplay ? 'autoplay' : ''
} controls width="100%" playsinline id="video-item">
<source src="${videoUrl}#t=${videoProgress}" type="video/mp4" id="video-source" videoid="${videoId}"> <source src="${videoUrl}#t=${videoProgress}" type="video/mp4" id="video-source" videoid="${videoId}">
${subtitles} ${subtitles}
</video> </video>
@@ -650,6 +783,7 @@ function onVideoProgress() {
} }
} }
} }
if (currentTime < 10) return;
if ((currentTime % 10).toFixed(1) <= 0.2) { if ((currentTime % 10).toFixed(1) <= 0.2) {
// Check progress every 10 seconds or else progress is checked a few times a second // Check progress every 10 seconds or else progress is checked a few times a second
postVideoProgress(videoId, currentTime); postVideoProgress(videoId, currentTime);
@@ -701,6 +835,7 @@ function watchedThreshold(currentTime, duration) {
function onVideoPause() { function onVideoPause() {
let videoId = getVideoPlayerVideoId(); let videoId = getVideoPlayerVideoId();
let currentTime = getVideoPlayerCurrentTime(); let currentTime = getVideoPlayerCurrentTime();
if (currentTime < 10) return;
postVideoProgress(videoId, currentTime); postVideoProgress(videoId, currentTime);
} }
@@ -769,6 +904,13 @@ function getPlaylistData(playlistId) {
return playlistData.data; 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 // Get video progress data when passed video ID
function getVideoProgress(videoId) { function getVideoProgress(videoId) {
let apiEndpoint = '/api/video/' + videoId + '/progress/'; let apiEndpoint = '/api/video/' + videoId + '/progress/';
@@ -1379,8 +1521,10 @@ function textExpandButtonVisibilityUpdate() {
document.addEventListener('readystatechange', textExpandButtonVisibilityUpdate); document.addEventListener('readystatechange', textExpandButtonVisibilityUpdate);
window.addEventListener('resize', textExpandButtonVisibilityUpdate); window.addEventListener('resize', textExpandButtonVisibilityUpdate);
function showForm() { function showForm(id) {
let formElement = document.getElementById('hidden-form');
let id2 = id === undefined ? 'hidden-form' : id;
let formElement = document.getElementById(id2);
let displayStyle = formElement.style.display; let displayStyle = formElement.style.display;
if (displayStyle === '') { if (displayStyle === '') {
formElement.style.display = 'block'; formElement.style.display = 'block';