merge testing to beats model
|
@ -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*.
|
||||
|
||||
### 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:
|
||||
- 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 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.
|
||||
|
@ -66,7 +66,9 @@ IMPORTANT: When receiving help, contribute back to the community by improving th
|
|||
|
||||
## 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 **documentation changes**, make your PR directly against the *master* branch.
|
||||
|
|
32
Dockerfile
|
@ -1,5 +1,5 @@
|
|||
# multi stage to build tube archivist
|
||||
# first stage to build python wheel, copy into final image
|
||||
# build python wheel, download and extract ffmpeg, copy into final image
|
||||
|
||||
|
||||
# First stage to build python wheel
|
||||
|
@ -13,6 +13,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
COPY ./tubearchivist/requirements.txt /requirements.txt
|
||||
RUN pip install --user -r requirements.txt
|
||||
|
||||
# build ffmpeg
|
||||
FROM python:3.11.3-slim-bullseye as ffmpeg-builder
|
||||
COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
|
||||
RUN python ffmpeg_download.py $TARGETPLATFORM
|
||||
|
||||
# build final image
|
||||
FROM python:3.11.3-slim-bullseye as tubearchivist
|
||||
|
||||
|
@ -25,30 +30,15 @@ ENV PYTHONUNBUFFERED 1
|
|||
COPY --from=builder /root/.local /root/.local
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
|
||||
# copy ffmpeg
|
||||
COPY --from=ffmpeg-builder ./ffmpeg/ffmpeg /usr/bin/ffmpeg
|
||||
COPY --from=ffmpeg-builder ./ffprobe/ffprobe /usr/bin/ffprobe
|
||||
|
||||
# install distro packages needed
|
||||
RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recommends \
|
||||
nginx \
|
||||
atomicparsley \
|
||||
curl \
|
||||
xz-utils && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install patched ffmpeg build, default to linux64
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ] ; then \
|
||||
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
|
||||
| grep browser_download_url \
|
||||
| grep ".*master.*linuxarm64.*tar.xz" \
|
||||
| cut -d '"' -f 4 \
|
||||
| xargs curl -L --output ffmpeg.tar.xz ; \
|
||||
else \
|
||||
curl -s https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest \
|
||||
| grep browser_download_url \
|
||||
| grep ".*master.*linux64.*tar.xz" \
|
||||
| cut -d '"' -f 4 \
|
||||
| xargs curl -L --output ffmpeg.tar.xz ; \
|
||||
fi && \
|
||||
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffmpeg" && \
|
||||
tar -xf ffmpeg.tar.xz --strip-components=2 --no-anchored -C /usr/bin/ "ffprobe" && \
|
||||
rm ffmpeg.tar.xz
|
||||
curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# install debug tools for testing environment
|
||||
RUN if [ "$INSTALL_DEBUG" ] ; then \
|
||||
|
|
19
README.md
|
@ -34,8 +34,8 @@ Once your YouTube video collection grows, it becomes hard to search and find a s
|
|||
- [Discord](https://www.tubearchivist.com/discord): Connect with us on our Discord server.
|
||||
- [r/TubeArchivist](https://www.reddit.com/r/TubeArchivist/): Join our Subreddit.
|
||||
- [Browser Extension](https://github.com/tubearchivist/browser-extension) Tube Archivist Companion, for [Firefox](https://addons.mozilla.org/addon/tubearchivist-companion/) and [Chrome](https://chrome.google.com/webstore/detail/tubearchivist-companion/jjnkmicfnfojkkgobdfeieblocadmcie)
|
||||
- [Jellyfin Integration](https://github.com/tubearchivist/tubearchivist-jf): Add your videos to Jellyfin.
|
||||
- [Tube Archivist Metrics](https://github.com/tubearchivist/tubearchivist-metrics) to create statistics in Prometheus/OpenMetrics format.
|
||||
- [Jellyfin Plugin](https://github.com/tubearchivist/tubearchivist-jf-plugin): Add your videos to Jellyfin
|
||||
- [Plex Plugin](https://github.com/tubearchivist/tubearchivist-plex): Add your videos to Plex
|
||||
|
||||
## Installing
|
||||
For minimal system requirements, the Tube Archivist stack needs around 2GB of available memory for a small testing setup and around 4GB of available memory for a mid to large sized installation. Minimal with dual core with 4 threads, better quad core plus.
|
||||
|
@ -151,7 +151,7 @@ We have come far, nonetheless we are not short of ideas on how to improve and ex
|
|||
- [ ] User roles
|
||||
- [ ] Audio download
|
||||
- [ ] Podcast mode to serve channel as mp3
|
||||
- [ ] User created playlists, random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
|
||||
- [ ] Random and repeat controls ([#108](https://github.com/tubearchivist/tubearchivist/issues/108), [#220](https://github.com/tubearchivist/tubearchivist/issues/220))
|
||||
- [ ] Auto play or play next link ([#226](https://github.com/tubearchivist/tubearchivist/issues/226))
|
||||
- [ ] Multi language support
|
||||
- [ ] Show total video downloaded vs total videos available in channel
|
||||
|
@ -162,6 +162,7 @@ We have come far, nonetheless we are not short of ideas on how to improve and ex
|
|||
- [ ] Configure shorts, streams and video sizes per channel
|
||||
|
||||
Implemented:
|
||||
- [X] User created playlists [2024-04-10]
|
||||
- [X] Add statistics of index [2023-09-03]
|
||||
- [X] Implement [Apprise](https://github.com/caronc/apprise) for notifications [2023-08-05]
|
||||
- [X] Download video comments [2022-11-30]
|
||||
|
@ -188,9 +189,17 @@ Implemented:
|
|||
- [X] Scan your file system to index already downloaded videos [2021-09-14]
|
||||
|
||||
## 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.
|
||||
|
||||
- Example 1
|
||||
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
|
||||
The best donation to **Tube Archivist** is your time, take a look at the [contribution page](CONTRIBUTING.md) to get started.
|
||||
|
|
|
@ -40,7 +40,7 @@ services:
|
|||
depends_on:
|
||||
- archivist-es
|
||||
archivist-es:
|
||||
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.11.0
|
||||
image: bbilly1/tubearchivist-es # only for amd64, or use official es 8.13.2
|
||||
container_name: archivist-es
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
"""
|
||||
ffmpeg link builder
|
||||
copied as into build step in Dockerfile
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from enum import Enum
|
||||
|
||||
API_URL = "https://api.github.com/repos/yt-dlp/FFmpeg-Builds/releases/latest"
|
||||
BINARIES = ["ffmpeg", "ffprobe"]
|
||||
|
||||
|
||||
class PlatformFilter(Enum):
|
||||
"""options"""
|
||||
|
||||
ARM64 = "linuxarm64"
|
||||
AMD64 = "linux64"
|
||||
|
||||
|
||||
def get_assets():
|
||||
"""get all available assets from latest build"""
|
||||
with urllib.request.urlopen(API_URL) as f:
|
||||
all_links = json.loads(f.read().decode("utf-8"))
|
||||
|
||||
return all_links
|
||||
|
||||
|
||||
def pick_url(all_links, platform):
|
||||
"""pick url for platform"""
|
||||
filter_by = PlatformFilter[platform.split("/")[1].upper()].value
|
||||
options = [i for i in all_links["assets"] if filter_by in i["name"]]
|
||||
if not options:
|
||||
raise ValueError(f"no valid asset found for filter {filter_by}")
|
||||
|
||||
url_pick = options[0]["browser_download_url"]
|
||||
|
||||
return url_pick
|
||||
|
||||
|
||||
def download_extract(url):
|
||||
"""download and extract binaries"""
|
||||
print("download file")
|
||||
filename, _ = urllib.request.urlretrieve(url)
|
||||
print("extract file")
|
||||
with tarfile.open(filename, "r:xz") as tar:
|
||||
for member in tar.getmembers():
|
||||
member.name = os.path.basename(member.name)
|
||||
if member.name in BINARIES:
|
||||
print(f"extract {member.name}")
|
||||
tar.extract(member, member.name)
|
||||
|
||||
|
||||
def main():
|
||||
"""entry point"""
|
||||
args = sys.argv
|
||||
if len(args) == 1:
|
||||
platform = "linux/amd64"
|
||||
else:
|
||||
platform = args[1]
|
||||
|
||||
all_links = get_assets()
|
||||
url = pick_url(all_links, platform)
|
||||
download_extract(url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -14,11 +14,10 @@ fi
|
|||
python manage.py ta_envcheck
|
||||
python manage.py ta_connection
|
||||
python manage.py ta_startup
|
||||
python manage.py ta_migpath
|
||||
|
||||
# start all tasks
|
||||
nginx &
|
||||
celery -A home.celery worker --loglevel=INFO &
|
||||
celery -A home.tasks worker --loglevel=INFO --max-tasks-per-child 10 &
|
||||
celery -A home beat --loglevel=INFO \
|
||||
--scheduler django_celery_beat.schedulers:DatabaseScheduler &
|
||||
uwsgi --ini uwsgi.ini
|
||||
|
|
|
@ -42,7 +42,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,
|
||||
|
@ -464,12 +464,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)
|
||||
|
||||
|
@ -513,6 +527,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
|
||||
|
@ -520,6 +535,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")
|
||||
|
|
|
@ -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 os
|
||||
|
|
|
@ -12,6 +12,7 @@ from django.conf import settings
|
|||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django_celery_beat.models import CrontabSchedule
|
||||
from home.models import CustomPeriodicTask
|
||||
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
|
||||
|
@ -42,14 +43,16 @@ class Command(BaseCommand):
|
|||
self.stdout.write(TOPIC)
|
||||
self._sync_redis_state()
|
||||
self._make_folders()
|
||||
self._release_locks()
|
||||
self._clear_redis_keys()
|
||||
self._clear_tasks()
|
||||
self._clear_dl_cache()
|
||||
self._mig_clear_failed_versioncheck()
|
||||
self._version_check()
|
||||
self._mig_index_setup()
|
||||
self._mig_snapshot_check()
|
||||
self._mig_move_users_to_es()
|
||||
self._mig_schedule_store()
|
||||
self._mig_custom_playlist()
|
||||
|
||||
def _sync_redis_state(self):
|
||||
"""make sure redis gets new config.json values"""
|
||||
|
@ -80,10 +83,10 @@ class Command(BaseCommand):
|
|||
|
||||
self.stdout.write(self.style.SUCCESS(" ✓ expected folders created"))
|
||||
|
||||
def _release_locks(self):
|
||||
"""make sure there are no leftover locks set in redis"""
|
||||
self.stdout.write("[3] clear leftover locks in redis")
|
||||
all_locks = [
|
||||
def _clear_redis_keys(self):
|
||||
"""make sure there are no leftover locks or keys set in redis"""
|
||||
self.stdout.write("[3] clear leftover keys in redis")
|
||||
all_keys = [
|
||||
"dl_queue_id",
|
||||
"dl_queue",
|
||||
"downloading",
|
||||
|
@ -92,19 +95,22 @@ class Command(BaseCommand):
|
|||
"rescan",
|
||||
"run_backup",
|
||||
"startup_check",
|
||||
"reindex:ta_video",
|
||||
"reindex:ta_channel",
|
||||
"reindex:ta_playlist",
|
||||
]
|
||||
|
||||
redis_con = RedisArchivist()
|
||||
has_changed = False
|
||||
for lock in all_locks:
|
||||
if redis_con.del_message(lock):
|
||||
for key in all_keys:
|
||||
if redis_con.del_message(key):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" ✓ cleared lock {lock}")
|
||||
self.style.SUCCESS(f" ✓ cleared key {key}")
|
||||
)
|
||||
has_changed = True
|
||||
|
||||
if not has_changed:
|
||||
self.stdout.write(self.style.SUCCESS(" no locks found"))
|
||||
self.stdout.write(self.style.SUCCESS(" no keys found"))
|
||||
|
||||
def _clear_tasks(self):
|
||||
"""clear tasks and messages"""
|
||||
|
@ -152,6 +158,10 @@ class Command(BaseCommand):
|
|||
self.stdout.write("[MIGRATION] setup snapshots")
|
||||
ElasticSnapshot().setup()
|
||||
|
||||
def _mig_clear_failed_versioncheck(self):
|
||||
"""hotfix for v0.4.5, clearing faulty versioncheck"""
|
||||
ReleaseVersion().clear_fail()
|
||||
|
||||
def _mig_move_users_to_es(self): # noqa: C901
|
||||
"""migration: update from 0.4.1 to 0.4.2 move user config to ES"""
|
||||
self.stdout.write("[MIGRATION] move user configuration to ES")
|
||||
|
@ -367,3 +377,36 @@ class Command(BaseCommand):
|
|||
handler = Notifications(task_name)
|
||||
for url in urls:
|
||||
handler.add_url(url)
|
||||
|
||||
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)
|
||||
|
|
|
@ -270,4 +270,4 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
|
|||
|
||||
# TA application settings
|
||||
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
|
||||
TA_VERSION = "v0.4.5-unstable"
|
||||
TA_VERSION = "v0.4.7"
|
||||
|
|
|
@ -13,6 +13,7 @@ Including another URLconf
|
|||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"""custom models"""
|
||||
|
||||
from django.contrib.auth.models import (
|
||||
AbstractBaseUser,
|
||||
BaseUserManager,
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -62,8 +62,8 @@ class YtWrap:
|
|||
"""make extract request"""
|
||||
try:
|
||||
response = yt_dlp.YoutubeDL(self.obs).extract_info(url)
|
||||
except cookiejar.LoadError:
|
||||
print("cookie file is invalid")
|
||||
except cookiejar.LoadError as err:
|
||||
print(f"cookie file is invalid: {err}")
|
||||
return False
|
||||
except yt_dlp.utils.ExtractorError as err:
|
||||
print(f"{url}: failed to extract with message: {err}, continue...")
|
||||
|
|
|
@ -50,7 +50,7 @@ class DownloadPostProcess:
|
|||
return
|
||||
|
||||
print(f"auto delete older than {autodelete_days} days")
|
||||
now_lte = self.now - autodelete_days * 24 * 60 * 60
|
||||
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
|
||||
data = {
|
||||
"query": {"range": {"player.watched_date": {"lte": now_lte}}},
|
||||
"sort": [{"player.watched_date": {"order": "asc"}}],
|
||||
|
@ -63,7 +63,7 @@ class DownloadPostProcess:
|
|||
if "autodelete_days" in value:
|
||||
autodelete_days = value.get("autodelete_days")
|
||||
print(f"{channel_id}: delete older than {autodelete_days}d")
|
||||
now_lte = self.now - autodelete_days * 24 * 60 * 60
|
||||
now_lte = str(self.now - autodelete_days * 24 * 60 * 60)
|
||||
must_list = [
|
||||
{"range": {"player.watched_date": {"lte": now_lte}}},
|
||||
{"term": {"channel.channel_id": {"value": channel_id}}},
|
||||
|
@ -177,7 +177,7 @@ class VideoDownloader:
|
|||
if not success:
|
||||
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(
|
||||
youtube_id,
|
||||
|
@ -197,14 +197,16 @@ class VideoDownloader:
|
|||
|
||||
return self.videos
|
||||
|
||||
def _notify(self, video_data, message):
|
||||
def _notify(self, video_data, message, progress=False):
|
||||
"""send progress notification to task"""
|
||||
if not self.task:
|
||||
return
|
||||
|
||||
typ = VideoTypeEnum(video_data["vid_type"]).value.rstrip("s").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):
|
||||
"""get next item in queue"""
|
||||
|
|
|
@ -3,6 +3,7 @@ functionality:
|
|||
- wrapper around requests to call elastic search
|
||||
- reusable search_after to extract total index
|
||||
"""
|
||||
|
||||
# pylint: disable=missing-timeout
|
||||
|
||||
import json
|
||||
|
|
|
@ -105,8 +105,8 @@ class ApplicationSettingsForm(forms.Form):
|
|||
|
||||
COOKIE_IMPORT_CHOICES = [
|
||||
("", "-- change cookie settings"),
|
||||
("0", "disable cookie"),
|
||||
("1", "enable cookie"),
|
||||
("0", "remove cookie"),
|
||||
("1", "import cookie"),
|
||||
]
|
||||
|
||||
subscriptions_channel_size = forms.IntegerField(
|
||||
|
@ -221,6 +221,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"""
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ Functionality:
|
|||
- calculate pagination values
|
||||
"""
|
||||
|
||||
|
||||
from api.src.search_processor import SearchProcess
|
||||
from home.src.es.connect import ElasticWrap
|
||||
|
||||
|
|
|
@ -31,8 +31,8 @@ class YoutubeChannel(YouTubeItem):
|
|||
self.task = task
|
||||
|
||||
def build_yt_url(self):
|
||||
"""build youtube url"""
|
||||
return f"{self.yt_base}{self.youtube_id}/featured"
|
||||
"""overwrite base to use channel about page"""
|
||||
return f"{self.yt_base}{self.youtube_id}/about"
|
||||
|
||||
def build_json(self, upload=False, fallback=False):
|
||||
"""get from es or from youtube"""
|
||||
|
@ -199,11 +199,21 @@ class YoutubeChannel(YouTubeItem):
|
|||
}
|
||||
_, _ = ElasticWrap("ta_comment/_delete_by_query").post(data)
|
||||
|
||||
def delete_es_subtitles(self):
|
||||
"""delete all subtitles from this channel"""
|
||||
data = {
|
||||
"query": {
|
||||
"term": {"subtitle_channel_id": {"value": self.youtube_id}}
|
||||
}
|
||||
}
|
||||
_, _ = ElasticWrap("ta_subtitle/_delete_by_query").post(data)
|
||||
|
||||
def delete_playlists(self):
|
||||
"""delete all indexed playlist from es"""
|
||||
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):
|
||||
|
@ -229,6 +239,7 @@ class YoutubeChannel(YouTubeItem):
|
|||
print(f"{self.youtube_id}: delete indexed videos")
|
||||
self.delete_es_videos()
|
||||
self.delete_es_comments()
|
||||
self.delete_es_subtitles()
|
||||
self.del_in_es()
|
||||
|
||||
def index_channel_playlists(self):
|
||||
|
|
|
@ -68,6 +68,7 @@ class Comments:
|
|||
"youtube": {
|
||||
"max_comments": max_comments_list,
|
||||
"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)
|
||||
|
||||
if not comment.get("author"):
|
||||
comment["author"] = comment.get("author_id", "Unknown")
|
||||
|
||||
cleaned_comment = {
|
||||
"comment_id": comment["id"],
|
||||
"comment_text": comment["text"].replace("\xa0", ""),
|
||||
|
@ -126,7 +130,7 @@ class Comments:
|
|||
"comment_author_id": comment["author_id"],
|
||||
"comment_author_thumbnail": comment["author_thumbnail"],
|
||||
"comment_author_is_uploader": comment.get(
|
||||
"comment_author_is_uploader", False
|
||||
"author_is_uploader", False
|
||||
),
|
||||
"comment_parent": comment["parent"],
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -257,7 +257,7 @@ class Reindex(ReindexBase):
|
|||
return
|
||||
|
||||
for name, index_config in self.REINDEX_CONFIG.items():
|
||||
if not RedisQueue(index_config["queue_name"]).has_item():
|
||||
if not RedisQueue(index_config["queue_name"]).length():
|
||||
continue
|
||||
|
||||
self.total = RedisQueue(index_config["queue_name"]).length()
|
||||
|
@ -306,6 +306,9 @@ class Reindex(ReindexBase):
|
|||
|
||||
# read current state
|
||||
video.get_from_es()
|
||||
if not video.json_data:
|
||||
return
|
||||
|
||||
es_meta = video.json_data.copy()
|
||||
|
||||
# get new
|
||||
|
@ -343,6 +346,9 @@ class Reindex(ReindexBase):
|
|||
# read current state
|
||||
channel = YoutubeChannel(channel_id)
|
||||
channel.get_from_es()
|
||||
if not channel.json_data:
|
||||
return
|
||||
|
||||
es_meta = channel.json_data.copy()
|
||||
|
||||
# get new
|
||||
|
@ -371,6 +377,12 @@ class Reindex(ReindexBase):
|
|||
self._get_all_videos()
|
||||
playlist = YoutubePlaylist(playlist_id)
|
||||
playlist.get_from_es()
|
||||
if (
|
||||
not playlist.json_data
|
||||
or playlist.json_data["playlist_type"] == "custom"
|
||||
):
|
||||
return
|
||||
|
||||
subscribed = playlist.json_data["playlist_subscribed"]
|
||||
playlist.all_youtube_ids = self.all_indexed_ids
|
||||
playlist.build_json(scrape=True)
|
||||
|
|
|
@ -128,6 +128,10 @@ class YoutubeSubtitle:
|
|||
print(response.text)
|
||||
continue
|
||||
|
||||
if not response.text:
|
||||
print(f"{self.video.youtube_id}: skip empty subtitle")
|
||||
continue
|
||||
|
||||
parser = SubtitleParser(response.text, lang, source)
|
||||
parser.process()
|
||||
if not parser.all_cues:
|
||||
|
|
|
@ -177,6 +177,7 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||
|
||||
def _process_youtube_meta(self):
|
||||
"""extract relevant fields from youtube"""
|
||||
self._validate_id()
|
||||
# extract
|
||||
self.channel_id = self.youtube_meta["channel_id"]
|
||||
upload_date = self.youtube_meta["upload_date"]
|
||||
|
@ -202,6 +203,19 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
|
|||
"active": True,
|
||||
}
|
||||
|
||||
def _validate_id(self):
|
||||
"""validate expected video ID, raise value error on mismatch"""
|
||||
remote_id = self.youtube_meta["id"]
|
||||
|
||||
if not self.youtube_id == remote_id:
|
||||
# unexpected redirect
|
||||
message = (
|
||||
f"[reindex][{self.youtube_id}] got an unexpected redirect "
|
||||
+ f"to {remote_id}, you are probably getting blocked by YT. "
|
||||
"See FAQ for more details."
|
||||
)
|
||||
raise ValueError(message)
|
||||
|
||||
def _add_channel(self):
|
||||
"""add channel dict to video json_data"""
|
||||
channel = ta_channel.YoutubeChannel(self.channel_id)
|
||||
|
@ -305,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):
|
||||
|
|
|
@ -126,14 +126,13 @@ class ReleaseVersion:
|
|||
REMOTE_URL = "https://www.tubearchivist.com/api/release/latest/"
|
||||
NEW_KEY = "versioncheck:new"
|
||||
|
||||
def __init__(self):
|
||||
self.local_version = self._parse_version(settings.TA_VERSION)
|
||||
self.is_unstable = settings.TA_VERSION.endswith("-unstable")
|
||||
self.remote_version = False
|
||||
self.is_breaking = False
|
||||
self.response = False
|
||||
def __init__(self) -> None:
|
||||
self.local_version: str = settings.TA_VERSION
|
||||
self.is_unstable: bool = settings.TA_VERSION.endswith("-unstable")
|
||||
self.remote_version: str = ""
|
||||
self.is_breaking: bool = False
|
||||
|
||||
def check(self):
|
||||
def check(self) -> None:
|
||||
"""check version"""
|
||||
print(f"[{self.local_version}]: look for updates")
|
||||
self.get_remote_version()
|
||||
|
@ -147,50 +146,63 @@ class ReleaseVersion:
|
|||
RedisArchivist().set_message(self.NEW_KEY, message)
|
||||
print(f"[{self.local_version}]: found new version {new_version}")
|
||||
|
||||
def get_local_version(self):
|
||||
def get_local_version(self) -> str:
|
||||
"""read version from local"""
|
||||
return self.local_version
|
||||
|
||||
def get_remote_version(self):
|
||||
def get_remote_version(self) -> None:
|
||||
"""read version from remote"""
|
||||
sleep(randint(0, 60))
|
||||
self.response = requests.get(self.REMOTE_URL, timeout=20).json()
|
||||
remote_version_str = self.response["release_version"]
|
||||
self.remote_version = self._parse_version(remote_version_str)
|
||||
self.is_breaking = self.response["breaking_changes"]
|
||||
response = requests.get(self.REMOTE_URL, timeout=20).json()
|
||||
self.remote_version = response["release_version"]
|
||||
self.is_breaking = response["breaking_changes"]
|
||||
|
||||
def _has_update(self):
|
||||
def _has_update(self) -> str | bool:
|
||||
"""check if there is an update"""
|
||||
if self.remote_version > self.local_version:
|
||||
remote_parsed = self._parse_version(self.remote_version)
|
||||
local_parsed = self._parse_version(self.local_version)
|
||||
if remote_parsed > local_parsed:
|
||||
return self.remote_version
|
||||
|
||||
if self.is_unstable and self.local_version == self.remote_version:
|
||||
if self.is_unstable and local_parsed == remote_parsed:
|
||||
return self.remote_version
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_version(version):
|
||||
def _parse_version(version) -> tuple[int, ...]:
|
||||
"""return version parts"""
|
||||
clean = version.rstrip("-unstable").lstrip("v")
|
||||
return tuple((int(i) for i in clean.split(".")))
|
||||
|
||||
def is_updated(self):
|
||||
def is_updated(self) -> str | bool:
|
||||
"""check if update happened in the mean time"""
|
||||
message = self.get_update()
|
||||
if not message:
|
||||
return False
|
||||
|
||||
if self.local_version >= self._parse_version(message.get("version")):
|
||||
local_parsed = self._parse_version(self.local_version)
|
||||
message_parsed = self._parse_version(message.get("version"))
|
||||
|
||||
if local_parsed >= message_parsed:
|
||||
RedisArchivist().del_message(self.NEW_KEY)
|
||||
return settings.TA_VERSION
|
||||
|
||||
return False
|
||||
|
||||
def get_update(self):
|
||||
def get_update(self) -> dict:
|
||||
"""return new version dict if available"""
|
||||
message = RedisArchivist().get_message(self.NEW_KEY)
|
||||
if not message.get("status"):
|
||||
return False
|
||||
return {}
|
||||
|
||||
return message
|
||||
|
||||
def clear_fail(self) -> None:
|
||||
"""clear key, catch previous error in v0.4.5"""
|
||||
message = self.get_update()
|
||||
if not message:
|
||||
return
|
||||
|
||||
if isinstance(message.get("version"), list):
|
||||
RedisArchivist().del_message(self.NEW_KEY)
|
||||
|
|
|
@ -20,6 +20,7 @@ class RedisBase:
|
|||
self.conn = redis.Redis(
|
||||
host=EnvironmentSettings.REDIS_HOST,
|
||||
port=EnvironmentSettings.REDIS_PORT,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
|
||||
|
@ -82,7 +83,7 @@ class RedisArchivist(RedisBase):
|
|||
if not reply:
|
||||
return []
|
||||
|
||||
return [i.decode().lstrip(self.NAME_SPACE) for i in reply]
|
||||
return [i.lstrip(self.NAME_SPACE) for i in reply]
|
||||
|
||||
def list_items(self, query: str) -> list:
|
||||
"""list all matches"""
|
||||
|
@ -99,65 +100,49 @@ class RedisArchivist(RedisBase):
|
|||
|
||||
|
||||
class RedisQueue(RedisBase):
|
||||
"""dynamically interact with queues in redis"""
|
||||
"""
|
||||
dynamically interact with queues in redis using sorted set
|
||||
- low score number is first in queue
|
||||
- add new items with high score number
|
||||
"""
|
||||
|
||||
def __init__(self, queue_name: str):
|
||||
super().__init__()
|
||||
self.key = f"{self.NAME_SPACE}{queue_name}"
|
||||
|
||||
def get_all(self):
|
||||
def get_all(self) -> list[str]:
|
||||
"""return all elements in list"""
|
||||
result = self.conn.execute_command("LRANGE", self.key, 0, -1)
|
||||
all_elements = [i.decode() for i in result]
|
||||
return all_elements
|
||||
result = self.conn.zrange(self.key, 0, -1)
|
||||
return result
|
||||
|
||||
def length(self) -> int:
|
||||
"""return total elements in list"""
|
||||
return self.conn.execute_command("LLEN", self.key)
|
||||
return self.conn.zcard(self.key)
|
||||
|
||||
def in_queue(self, element) -> str | bool:
|
||||
"""check if element is in list"""
|
||||
result = self.conn.execute_command("LPOS", self.key, element)
|
||||
result = self.conn.zrank(self.key, element)
|
||||
if result is not None:
|
||||
return "in_queue"
|
||||
|
||||
return False
|
||||
|
||||
def add_list(self, to_add):
|
||||
def add_list(self, to_add: list) -> None:
|
||||
"""add list to queue"""
|
||||
self.conn.execute_command("RPUSH", self.key, *to_add)
|
||||
|
||||
def add_priority(self, to_add: str) -> None:
|
||||
"""add single video to front of queue"""
|
||||
item: str = json.dumps(to_add)
|
||||
self.clear_item(item)
|
||||
self.conn.execute_command("LPUSH", self.key, item)
|
||||
mapping = {i: "+inf" for i in to_add}
|
||||
self.conn.zadd(self.key, mapping)
|
||||
|
||||
def get_next(self) -> str | bool:
|
||||
"""return next element in the queue, False if none"""
|
||||
result = self.conn.execute_command("LPOP", self.key)
|
||||
"""return next element in the queue, if available"""
|
||||
result = self.conn.zpopmin(self.key)
|
||||
if not result:
|
||||
return False
|
||||
|
||||
next_element = result.decode()
|
||||
return next_element
|
||||
return result[0][0]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""delete list from redis"""
|
||||
self.conn.execute_command("DEL", self.key)
|
||||
|
||||
def clear_item(self, to_clear: str) -> None:
|
||||
"""remove single item from list if it's there"""
|
||||
self.conn.execute_command("LREM", self.key, 0, to_clear)
|
||||
|
||||
def trim(self, size: int) -> None:
|
||||
"""trim the queue based on settings amount"""
|
||||
self.conn.execute_command("LTRIM", self.key, 0, size)
|
||||
|
||||
def has_item(self) -> bool:
|
||||
"""check if queue as at least one pending item"""
|
||||
result = self.conn.execute_command("LRANGE", self.key, 0, 0)
|
||||
return bool(result)
|
||||
self.conn.delete(self.key)
|
||||
|
||||
|
||||
class TaskRedis(RedisBase):
|
||||
|
@ -170,7 +155,7 @@ class TaskRedis(RedisBase):
|
|||
def get_all(self) -> list:
|
||||
"""return all tasks"""
|
||||
all_keys = self.conn.execute_command("KEYS", f"{self.BASE}*")
|
||||
return [i.decode().replace(self.BASE, "") for i in all_keys]
|
||||
return [i.replace(self.BASE, "") for i in all_keys]
|
||||
|
||||
def get_single(self, task_id: str) -> dict:
|
||||
"""return content of single task"""
|
||||
|
@ -178,7 +163,7 @@ class TaskRedis(RedisBase):
|
|||
if not result:
|
||||
return {}
|
||||
|
||||
return json.loads(result.decode())
|
||||
return json.loads(result)
|
||||
|
||||
def set_key(
|
||||
self, task_id: str, message: dict, expire: bool | int = False
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -33,11 +33,9 @@
|
|||
<body>
|
||||
<div class="main-content">
|
||||
<div class="boxed-content">
|
||||
<div class="top-banner">
|
||||
<a href="{% url 'home' %}">
|
||||
<img alt="tube-archivist-banner">
|
||||
</a>
|
||||
</div>
|
||||
<a href="{% url 'home' %}">
|
||||
<div class="top-banner"></div>
|
||||
</a>
|
||||
<div class="top-nav">
|
||||
<div class="nav-items">
|
||||
<a href="{% url 'home' %}">
|
||||
|
|
|
@ -11,13 +11,18 @@
|
|||
{% if request.user|has_group:"admin" or request.user.is_staff %}
|
||||
|
||||
<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">
|
||||
<form id="hidden-form" action="/playlist/" method="post">
|
||||
{% csrf_token %}
|
||||
{{ subscribe_form }}
|
||||
<button type="submit">Subscribe</button>
|
||||
</form>
|
||||
<form id="hidden-form2" action="/playlist/" method="post">
|
||||
{% csrf_token %}
|
||||
{{ create_form }}
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -51,14 +56,18 @@
|
|||
</a>
|
||||
</div>
|
||||
<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>
|
||||
<p>Last refreshed: {{ playlist.playlist_last_refresh }}</p>
|
||||
{% if playlist.playlist_subscribed %}
|
||||
<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>
|
||||
{% else %}
|
||||
<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 %}
|
||||
{% if playlist.playlist_type != "custom" %}
|
||||
{% if playlist.playlist_subscribed %}
|
||||
<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>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
|
|
|
@ -9,37 +9,42 @@
|
|||
<h1>{{ playlist_info.playlist_name }}</h1>
|
||||
</div>
|
||||
<div class="info-box info-box-3">
|
||||
<div class="info-box-item">
|
||||
<div class="round-img">
|
||||
<a href="{% url 'channel_id' channel_info.channel_id %}">
|
||||
<img src="/cache/channels/{{ channel_info.channel_id }}_thumb.jpg" alt="channel-thumb">
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h3><a href="{% url 'channel_id' channel_info.channel_id %}">{{ channel_info.channel_name }}</a></h3>
|
||||
{% if channel_info.channel_subs >= 1000000 %}
|
||||
<span>Subscribers: {{ channel_info.channel_subs|intword }}</span>
|
||||
{% else %}
|
||||
<span>Subscribers: {{ channel_info.channel_subs|intcomma }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if playlist_info.playlist_type != "custom" %}
|
||||
<div class="info-box-item">
|
||||
<div class="round-img">
|
||||
<a href="{% url 'channel_id' channel_info.channel_id %}">
|
||||
<img src="/cache/channels/{{ channel_info.channel_id }}_thumb.jpg" alt="channel-thumb">
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h3><a href="{% url 'channel_id' channel_info.channel_id %}">{{ channel_info.channel_name }}</a></h3>
|
||||
{% if channel_info.channel_subs >= 1000000 %}
|
||||
<span>Subscribers: {{ channel_info.channel_subs|intword }}</span>
|
||||
{% else %}
|
||||
<span>Subscribers: {{ channel_info.channel_subs|intcomma }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="info-box-item">
|
||||
<div>
|
||||
<p>Last refreshed: {{ playlist_info.playlist_last_refresh }}</p>
|
||||
<p>Playlist:
|
||||
{% if playlist_info.playlist_subscribed %}
|
||||
{% if request.user|has_group:"admin" or request.user.is_staff %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if playlist_info.playlist_active %}
|
||||
<p>Youtube: <a href="https://www.youtube.com/playlist?list={{ playlist_info.playlist_id }}" target="_blank">Active</a></p>
|
||||
{% else %}
|
||||
<p>Youtube: Deactivated</p>
|
||||
{% if playlist_info.playlist_type != "custom" %}
|
||||
<p>Playlist:
|
||||
{% if playlist_info.playlist_subscribed %}
|
||||
{% if request.user|has_group:"admin" or request.user.is_staff %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if playlist_info.playlist_active %}
|
||||
<p>Youtube: <a href="https://www.youtube.com/playlist?list={{ playlist_info.playlist_id }}" target="_blank">Active</a></p>
|
||||
{% else %}
|
||||
<p>Youtube: Deactivated</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<button onclick="deleteConfirm()" id="delete-item">Delete Playlist</button>
|
||||
<div class="delete-confirm" id="delete-button">
|
||||
|
@ -63,7 +68,9 @@
|
|||
<p>Reindex scheduled</p>
|
||||
{% else %}
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -138,15 +145,35 @@
|
|||
{% endif %}
|
||||
<span>{{ video.published }} | {{ video.player.duration_str }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a class="video-more" href="{% url 'video' video.youtube_id %}"><h2>{{ video.title }}</h2></a>
|
||||
<div class="video-desc-details">
|
||||
<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>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
</div>
|
||||
{% 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 class="info-box-item">
|
||||
|
|
|
@ -3,8 +3,10 @@ Functionality:
|
|||
- all views for home app
|
||||
- holds base classes to inherit from
|
||||
"""
|
||||
|
||||
import enum
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from time import sleep
|
||||
|
||||
from api.src.search_processor import SearchProcess, process_aggs
|
||||
|
@ -27,6 +29,7 @@ from home.src.frontend.forms import (
|
|||
AddToQueueForm,
|
||||
ApplicationSettingsForm,
|
||||
ChannelOverwriteForm,
|
||||
CreatePlaylistForm,
|
||||
CustomAuthForm,
|
||||
MultiSearchForm,
|
||||
SubscribeToChannelForm,
|
||||
|
@ -527,7 +530,7 @@ class ChannelIdView(ChannelIdBaseView):
|
|||
|
||||
self.context.update(
|
||||
{
|
||||
"title": "Channel: " + channel_name,
|
||||
"title": f"Channel: {channel_name}",
|
||||
"channel_info": channel_info,
|
||||
}
|
||||
)
|
||||
|
@ -745,12 +748,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):
|
||||
|
@ -808,6 +811,7 @@ class PlaylistView(ArchivistResultsView):
|
|||
{
|
||||
"title": "Playlists",
|
||||
"subscribe_form": SubscribeToPlaylistForm(),
|
||||
"create_form": CreatePlaylistForm(),
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -842,12 +846,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")
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
apprise==1.6.0
|
||||
apprise==1.7.5
|
||||
celery==5.3.6
|
||||
Django==4.2.7
|
||||
django-auth-ldap==4.6.0
|
||||
Django==5.0.4
|
||||
django-auth-ldap==4.8.0
|
||||
django-celery-beat==2.5.0
|
||||
django-cors-headers==4.3.1
|
||||
djangorestframework==3.14.0
|
||||
Pillow==10.1.0
|
||||
djangorestframework==3.15.1
|
||||
Pillow==10.3.0
|
||||
redis==5.0.0
|
||||
requests==2.31.0
|
||||
ryd-client==0.0.6
|
||||
uWSGI==2.0.23
|
||||
uWSGI==2.0.24
|
||||
whitenoise==6.6.0
|
||||
yt-dlp==2023.11.16
|
||||
yt-dlp==2024.4.9
|
||||
|
|
|
@ -159,13 +159,13 @@ button:hover {
|
|||
}
|
||||
|
||||
.top-banner {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.top-banner img {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
content: var(--banner);
|
||||
background-image: var(--banner);
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
height: 10vh;
|
||||
min-height: 80px;
|
||||
max-height: 120px;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
@ -371,12 +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,
|
||||
#text-reveal button {
|
||||
#hidden-form2 {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
#hidden-form button, #hidden-form2 button {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
|
@ -565,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;
|
||||
|
@ -593,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);
|
||||
|
@ -683,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%;
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 24 KiB |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -12,7 +12,7 @@ checkMessages();
|
|||
// start to look for messages
|
||||
function checkMessages() {
|
||||
let notifications = document.getElementById('notifications');
|
||||
if (notifications) {
|
||||
if (notifications && notifications.childNodes.length === 0 ) {
|
||||
let dataOrigin = notifications.getAttribute('data');
|
||||
getMessages(dataOrigin);
|
||||
}
|
||||
|
|
|
@ -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 = '<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) {
|
||||
let youtube_id = button.getAttribute('data-id');
|
||||
let apiEndpoint = '/api/download/' + youtube_id + '/';
|
||||
|
@ -462,7 +593,7 @@ function createPlayer(button) {
|
|||
}
|
||||
let videoName = videoData.data.title;
|
||||
|
||||
let videoTag = createVideoTag(videoData, videoProgress);
|
||||
let videoTag = createVideoTag(videoData, videoProgress, true);
|
||||
|
||||
let playlist = '';
|
||||
let videoPlaylists = videoData.data.playlist; // Array of playlists the video is in
|
||||
|
@ -540,7 +671,7 @@ function insertVideoTag(videoData, 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 videoUrl = videoData.data.media_url;
|
||||
let videoThumbUrl = videoData.data.vid_thumb_url;
|
||||
|
@ -557,7 +688,9 @@ function createVideoTag(videoData, videoProgress) {
|
|||
}
|
||||
|
||||
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}">
|
||||
${subtitles}
|
||||
</video>
|
||||
|
@ -661,6 +794,7 @@ function onVideoProgress() {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (currentTime < 10) return;
|
||||
if ((currentTime % 10).toFixed(1) <= 0.2) {
|
||||
// Check progress every 10 seconds or else progress is checked a few times a second
|
||||
postVideoProgress(videoId, currentTime);
|
||||
|
@ -712,6 +846,7 @@ function watchedThreshold(currentTime, duration) {
|
|||
function onVideoPause() {
|
||||
let videoId = getVideoPlayerVideoId();
|
||||
let currentTime = getVideoPlayerCurrentTime();
|
||||
if (currentTime < 10) return;
|
||||
postVideoProgress(videoId, currentTime);
|
||||
}
|
||||
|
||||
|
@ -780,6 +915,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/';
|
||||
|
@ -1390,8 +1532,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';
|
||||
|
|