Merge branch 'testing' into pr_test_996

This commit is contained in:
Simon 2025-07-11 17:10:57 +07:00
commit e717eead8d
67 changed files with 2129 additions and 1264 deletions

2
.gitignore vendored
View File

@ -12,3 +12,5 @@ backend/.env
# JavaScript stuff
node_modules
.editorconfig

View File

@ -1,20 +1,24 @@
# multi stage to build tube archivist
# build python wheel, download and extract ffmpeg, copy into final image
FROM node:lts-alpine AS npm-builder
COPY frontend/package.json frontend/package-lock.json /
RUN npm i
FROM node:lts-alpine AS node-builder
# RUN npm config set registry https://registry.npmjs.org/
COPY --from=npm-builder ./node_modules /frontend/node_modules
COPY ./frontend /frontend
WORKDIR /frontend
RUN npm i
RUN npm run build:deploy
WORKDIR /
# First stage to build python wheel
FROM python:3.11.8-slim-bookworm AS builder
FROM python:3.11.13-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libldap2-dev libsasl2-dev libssl-dev git
@ -24,7 +28,7 @@ COPY ./backend/requirements.txt /requirements.txt
RUN pip install --user -r requirements.txt
# build ffmpeg
FROM python:3.11.8-slim-bookworm AS ffmpeg-builder
FROM python:3.11.13-slim-bookworm AS ffmpeg-builder
ARG TARGETPLATFORM
@ -32,7 +36,7 @@ COPY docker_assets/ffmpeg_download.py ffmpeg_download.py
RUN python ffmpeg_download.py $TARGETPLATFORM
# build final image
FROM python:3.11.8-slim-bookworm AS tubearchivist
FROM python:3.11.13-slim-bookworm AS tubearchivist
ARG INSTALL_DEBUG
@ -54,9 +58,9 @@ RUN apt-get clean && apt-get -y update && apt-get -y install --no-install-recomm
# install debug tools for testing environment
RUN if [ "$INSTALL_DEBUG" ] ; then \
apt-get -y update && apt-get -y install --no-install-recommends \
vim htop bmon net-tools iputils-ping procps lsof \
&& pip install --user ipython pytest pytest-django \
apt-get -y update && apt-get -y install --no-install-recommends \
vim htop bmon net-tools iputils-ping procps lsof \
&& pip install --user ipython pytest pytest-django \
; fi
# make folders

View File

@ -71,6 +71,7 @@ All environment variables are explained in detail in the docs [here](https://doc
| ELASTIC_USER | Change the default ElasticSearch user | Optional |
| TA_LDAP | Configure TA to use LDAP Authentication | [Read more](https://docs.tubearchivist.com/configuration/ldap/) |
| DISABLE_STATIC_AUTH | Remove authentication from media files, (Google Cast...) | [Read more](https://docs.tubearchivist.com/installation/env-vars/#disable_static_auth) |
| TA_AUTO_UPDATE_YTDLP | Configure TA to automatically install the latest yt-dlp on container start | Optional |
| DJANGO_DEBUG | Return additional error messages, for debug only | Optional |
| TA_LOGIN_AUTH_MODE | Configure the order of login authentication backends (Default: single) | Optional |

View File

@ -461,6 +461,15 @@
"playlist_description": {
"type": "text"
},
"playlist_subscribed": {
"type": "boolean"
},
"playlist_type": {
"type": "keyword"
},
"playlist_active": {
"type": "boolean"
},
"playlist_name": {
"type": "text",
"analyzer": "english",
@ -497,6 +506,9 @@
"type": "date",
"format": "epoch_second"
},
"playlist_sort_order": {
"type": "keyword"
},
"playlist_entries": {
"properties": {
"downloaded": {

View File

@ -25,6 +25,7 @@ class AppConfigSubSerializer(
live_channel_size = serializers.IntegerField(required=False)
shorts_channel_size = serializers.IntegerField(required=False)
auto_start = serializers.BooleanField(required=False)
extract_flat = serializers.BooleanField(required=False)
class AppConfigDownloadsSerializer(

View File

@ -22,6 +22,7 @@ class SubscriptionsConfigType(TypedDict):
live_channel_size: int
shorts_channel_size: int
auto_start: bool
extract_flat: bool
class DownloadsConfigType(TypedDict):
@ -73,6 +74,7 @@ class AppConfig:
"live_channel_size": 50,
"shorts_channel_size": 50,
"auto_start": False,
"extract_flat": False,
},
"downloads": {
"limit_speed": None,

View File

@ -11,11 +11,11 @@ from typing import Callable, TypedDict
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from channel.src.remote_query import get_last_channel_videos
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import rand_sleep
from common.src.ta_redis import RedisQueue
from download.src.subscriptions import ChannelSubscription
from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import CookieHandler
from playlist.src.index import YoutubePlaylist
@ -376,7 +376,7 @@ class Reindex(ReindexBase):
channel.upload_to_es()
channel.sync_to_videos()
ChannelFullScan(channel_id).scan()
ChannelFullScan(channel_id, self.config).scan()
self.processed["channels"] += 1
def _reindex_single_playlist(self, playlist_id: str) -> None:
@ -493,13 +493,11 @@ class ReindexProgress(ReindexBase):
class ChannelFullScan:
"""
update from v0.3.0 to v0.3.1
full scan of channel to fix vid_type mismatch
"""
"""full scan of channel to fix vid_type mismatch"""
def __init__(self, channel_id):
def __init__(self, channel_id, config):
self.channel_id = channel_id
self.config = config
self.to_update = False
def scan(self):
@ -510,12 +508,14 @@ class ChannelFullScan:
self.to_update = []
for video in all_local_videos:
video_id = video["youtube_id"]
remote_match = [i for i in all_remote_videos if i[0] == video_id]
remote_match = [
i for i in all_remote_videos if i["id"] == video_id
]
if not remote_match:
print(f"{video_id}: no remote match found")
continue
expected_type = remote_match[0][-1]
expected_type = remote_match[0]["vid_type"]
if video["vid_type"] != expected_type:
self.to_update.append(
{
@ -528,9 +528,8 @@ class ChannelFullScan:
def _get_all_remote(self):
"""get all channel videos"""
sub = ChannelSubscription()
all_remote_videos = sub.get_last_youtube_videos(
self.channel_id, limit=False
all_remote_videos = get_last_channel_videos(
self.channel_id, self.config, limit=False
)
return all_remote_videos

View File

@ -4,7 +4,6 @@ functionality:
- index and update in es
"""
import json
import os
from datetime import datetime
@ -121,27 +120,6 @@ class YoutubeChannel(YouTubeItem):
"channel_thumb_url": False,
"channel_views": 0,
}
self._info_json_fallback()
def _info_json_fallback(self):
"""read channel info.json for additional metadata"""
info_json = os.path.join(
EnvironmentSettings.CACHE_DIR,
"import",
f"{self.youtube_id}.info.json",
)
if os.path.exists(info_json):
print(f"{self.youtube_id}: read info.json file")
with open(info_json, "r", encoding="utf-8") as f:
content = json.loads(f.read())
self.json_data.update(
{
"channel_subs": content.get("channel_follower_count", 0),
"channel_description": content.get("description", False),
}
)
os.remove(info_json)
def get_channel_art(self):
"""download channel art for new channels"""
@ -177,48 +155,15 @@ class YoutubeChannel(YouTubeItem):
update_path = f"ta_video/_update_by_query?pipeline={self.youtube_id}"
_, _ = ElasticWrap(update_path).post(data)
def get_folder_path(self):
"""get folder where media files get stored"""
folder_path = os.path.join(
EnvironmentSettings.MEDIA_DIR,
self.json_data["channel_id"],
)
return folder_path
def change_subscribe(self, new_subscribe_state: bool):
"""change subscribe status"""
if not self.json_data:
self.build_json()
def delete_es_videos(self):
"""delete all channel documents from elasticsearch"""
data = {
"query": {
"term": {"channel.channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
def delete_es_comments(self):
"""delete all comments from this channel"""
data = {
"query": {
"term": {"comment_channel_id": {"value": self.youtube_id}}
}
}
_, _ = 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"""
from playlist.src.index import YoutubePlaylist
all_playlists = self.get_indexed_playlists()
for playlist in all_playlists:
YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
self.json_data["channel_subscribed"] = new_subscribe_state
self.upload_to_es()
self.sync_to_videos()
return self.json_data
def delete_channel(self):
"""delete channel and all videos"""
@ -227,24 +172,7 @@ class YoutubeChannel(YouTubeItem):
if not self.json_data:
raise FileNotFoundError
folder_path = self.get_folder_path()
print(f"{self.youtube_id}: delete all media files")
try:
all_videos = os.listdir(folder_path)
for video in all_videos:
video_path = os.path.join(folder_path, video)
os.remove(video_path)
os.rmdir(folder_path)
except FileNotFoundError:
print(f"no videos found for {folder_path}")
print(f"{self.youtube_id}: delete indexed playlists")
self.delete_playlists()
print(f"{self.youtube_id}: delete indexed videos")
self.delete_es_videos()
self.delete_es_comments()
self.delete_es_subtitles()
self.del_in_es()
ChannelDelete(json_data=self.json_data).delete()
def index_channel_playlists(self):
"""add all playlists of channel to index"""
@ -266,6 +194,21 @@ class YoutubeChannel(YouTubeItem):
print("add playlist: " + playlist[1])
rand_sleep(self.config)
def get_all_playlists(self):
"""get all playlists owned by this channel"""
url = (
f"https://www.youtube.com/channel/{self.youtube_id}"
+ "/playlists?view=1&sort=dd&shelf_id=0"
)
obs = {"skip_download": True, "extract_flat": True}
playlists, _ = YtWrap(obs, self.config).extract(url)
if not playlists:
self.all_playlists = []
return
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
self.all_playlists = all_entries
def _notify_single_playlist(self, idx, total):
"""send notification"""
channel_name = self.json_data["channel_name"]
@ -294,34 +237,6 @@ class YoutubeChannel(YouTubeItem):
all_videos = IndexPaginate("ta_video", data).get_results()
return all_videos
def get_all_playlists(self):
"""get all playlists owned by this channel"""
url = (
f"https://www.youtube.com/channel/{self.youtube_id}"
+ "/playlists?view=1&sort=dd&shelf_id=0"
)
obs = {"skip_download": True, "extract_flat": True}
playlists = YtWrap(obs, self.config).extract(url)
if not playlists:
self.all_playlists = []
return
all_entries = [(i["id"], i["title"]) for i in playlists["entries"]]
self.all_playlists = all_entries
def get_indexed_playlists(self, active_only=False):
"""get all indexed playlists from channel"""
must_list = [
{"term": {"playlist_channel_id": {"value": self.youtube_id}}}
]
if active_only:
must_list.append({"term": {"playlist_active": {"value": True}}})
data = {"query": {"bool": {"must": must_list}}}
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def get_overwrites(self) -> dict:
"""get all per channel overwrites"""
return self.json_data.get("channel_overwrites", {})
@ -352,6 +267,93 @@ class YoutubeChannel(YouTubeItem):
self.json_data["channel_overwrites"] = to_write
class ChannelDelete(YouTubeItem):
"""delete and cleanup"""
index_name = "ta_channel"
def __init__(self, json_data):
super().__init__(youtube_id=json_data["channel_id"])
self.json_data = json_data
def delete(self):
"""delete channel and all videos"""
folder_path = self._get_folder_path()
print(f"{self.youtube_id}: delete all media files")
try:
all_videos = os.listdir(folder_path)
for video in all_videos:
video_path = os.path.join(folder_path, video)
os.remove(video_path)
os.rmdir(folder_path)
except FileNotFoundError:
print(f"no videos found for {folder_path}")
print(f"{self.youtube_id}: delete indexed playlists")
self._delete_playlists()
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 _get_folder_path(self):
"""get folder where media files get stored"""
folder_path = os.path.join(
EnvironmentSettings.MEDIA_DIR,
self.json_data["channel_id"],
)
return folder_path
def _delete_es_videos(self):
"""delete all channel documents from elasticsearch"""
data = {
"query": {
"term": {"channel.channel_id": {"value": self.youtube_id}}
}
}
_, _ = ElasticWrap("ta_video/_delete_by_query").post(data)
def _delete_es_comments(self):
"""delete all comments from this channel"""
data = {
"query": {
"term": {"comment_channel_id": {"value": self.youtube_id}}
}
}
_, _ = 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"""
from playlist.src.index import YoutubePlaylist
all_playlists = self._get_indexed_playlists()
for playlist in all_playlists:
YoutubePlaylist(playlist["playlist_id"]).delete_metadata()
def _get_indexed_playlists(self, active_only=False):
"""get all indexed playlists from channel"""
must_list = [
{"term": {"playlist_channel_id": {"value": self.youtube_id}}}
]
if active_only:
must_list.append({"term": {"playlist_active": {"value": True}}})
data = {"query": {"bool": {"must": must_list}}}
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def channel_overwrites(channel_id, overwrites):
"""collection to overwrite settings per channel"""
channel = YoutubeChannel(channel_id)

View File

@ -0,0 +1,124 @@
"""build queries for video extraction from channel subscriptions"""
from download.src.yt_dlp_base import YtWrap
from video.src.constants import VideoTypeEnum
class VideoQueryBuilder:
"""Build queries for yt-dlp."""
def __init__(self, config: dict, channel_overwrites: dict | None = None):
self.config = config
self.channel_overwrites = channel_overwrites or {}
def build_queries(
self, video_type: VideoTypeEnum | None, limit: bool = True
) -> list[tuple[VideoTypeEnum, int | None]]:
"""Build queries for all or specific video type."""
query_methods = {
VideoTypeEnum.VIDEOS: self.videos_query,
VideoTypeEnum.STREAMS: self.streams_query,
VideoTypeEnum.SHORTS: self.shorts_query,
}
if video_type:
# build query for specific type
query_method = query_methods.get(video_type)
if query_method:
query = query_method(limit)
if query[1] != 0:
return [query]
return []
# Build and return queries for all video types
queries = []
for build_query in query_methods.values():
query = build_query(limit)
if query[1] != 0:
queries.append(query)
return queries
def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for videos."""
return self._build_generic_query(
video_type=VideoTypeEnum.VIDEOS,
overwrite_key="subscriptions_channel_size",
config_key="channel_size",
limit=limit,
)
def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for streams."""
return self._build_generic_query(
video_type=VideoTypeEnum.STREAMS,
overwrite_key="subscriptions_live_channel_size",
config_key="live_channel_size",
limit=limit,
)
def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for shorts."""
return self._build_generic_query(
video_type=VideoTypeEnum.SHORTS,
overwrite_key="subscriptions_shorts_channel_size",
config_key="shorts_channel_size",
limit=limit,
)
def _build_generic_query(
self,
video_type: VideoTypeEnum,
overwrite_key: str,
config_key: str,
limit: bool,
) -> tuple[VideoTypeEnum, int | None]:
"""Generic query for video page scraping."""
if not limit:
return (video_type, None)
if (
overwrite_key in self.channel_overwrites
and self.channel_overwrites[overwrite_key] is not None
):
overwrite = self.channel_overwrites[overwrite_key]
return (video_type, overwrite)
if overwrite := self.config["subscriptions"].get(config_key):
return (video_type, overwrite)
return (video_type, 0)
def get_last_channel_videos(
channel_id,
config,
limit=None,
query_filter=None,
channel_overwrites=None,
):
"""get a list of last videos from channel"""
query_handler = VideoQueryBuilder(config, channel_overwrites)
queries = query_handler.build_queries(query_filter)
last_videos = []
for vid_type_enum, limit_amount in queries:
obs = {
"skip_download": True,
"extract_flat": True,
}
vid_type = vid_type_enum.value
if limit is not None:
obs.update({"playlist_items": f":{limit_amount}:1"})
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
channel_query, _ = YtWrap(obs, config).extract(url)
if not channel_query:
continue
for entry in channel_query["entries"]:
entry["vid_type"] = vid_type
last_videos.append(entry)
return last_videos

View File

@ -14,7 +14,6 @@ from channel.src.nav import ChannelNav
from common.serializers import ErrorResponseSerializer
from common.src.urlparser import Parser
from common.views_base import AdminWriteOnly, ApiBaseView
from download.src.subscriptions import ChannelSubscription
from drf_spectacular.utils import (
OpenApiParameter,
OpenApiResponse,
@ -89,9 +88,7 @@ class ChannelApiListView(ApiBaseView):
def _unsubscribe(channel_id: str):
"""unsubscribe"""
print(f"[{channel_id}] unsubscribe from channel")
ChannelSubscription().change_subscribe(
channel_id, channel_subscribed=False
)
YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=False)
class ChannelApiView(ApiBaseView):
@ -146,7 +143,9 @@ class ChannelApiView(ApiBaseView):
subscribed = validated_data.get("channel_subscribed")
if subscribed is not None:
ChannelSubscription().change_subscribe(channel_id, subscribed)
YoutubeChannel(channel_id).change_subscribe(
new_subscribe_state=subscribed
)
overwrites = validated_data.get("channel_overwrites")
if overwrites:

View File

@ -56,7 +56,7 @@ class ElasticWrap:
return response.json(), response.status_code
def post(
self, data: bool | dict = False, ndjson: bool = False
self, data: bool | dict | str = False, ndjson: bool = False
) -> tuple[dict, int]:
"""post data to es"""

View File

@ -103,8 +103,11 @@ def requests_headers() -> dict[str, str]:
return {"User-Agent": template}
def date_parser(timestamp: int | str) -> str:
def date_parser(timestamp: int | str | None) -> str | None:
"""return formatted date string"""
if timestamp is None:
return None
if isinstance(timestamp, int):
date_obj = datetime.fromtimestamp(timestamp, tz=timezone.utc)
elif isinstance(timestamp, str):
@ -152,9 +155,13 @@ def is_shorts(youtube_id: str) -> bool:
"""check if youtube_id is a shorts video, bot not it it's not a shorts"""
shorts_url = f"https://www.youtube.com/shorts/{youtube_id}"
cookies = {"SOCS": "CAI"}
response = requests.head(
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
)
try:
response = requests.head(
shorts_url, cookies=cookies, headers=requests_headers(), timeout=10
)
except requests.exceptions.RequestException:
# assume video on error
return False
return response.status_code == 200
@ -184,11 +191,12 @@ def get_duration_sec(file_path: str) -> int:
return duration_sec
def get_duration_str(seconds: int) -> str:
def get_duration_str(seconds: int | float) -> str:
"""Return a human-readable duration string from seconds."""
if not seconds:
return "NA"
seconds = int(seconds)
units = [("y", 31536000), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
duration_parts = []
@ -284,6 +292,50 @@ def get_channel_overwrites() -> dict[str, dict[str, Any]]:
return overwrites
def get_channels(
subscribed_only: bool, source: list[str] | None = None
) -> list[dict]:
"""get a list of all channels"""
data = {
"sort": [{"channel_name.keyword": {"order": "asc"}}],
}
if subscribed_only:
query = {"term": {"channel_subscribed": {"value": True}}}
else:
query = {"match_all": {}}
data["query"] = query # type: ignore
if source:
data["_source"] = source # type: ignore
all_channels = IndexPaginate("ta_channel", data).get_results()
return all_channels
def get_playlists(
subscribed_only: bool, source: list[str] | None = None
) -> list[dict]:
"""get list of playlists"""
data = {
"sort": [{"playlist_channel.keyword": {"order": "desc"}}],
}
must_list = [{"term": {"playlist_active": {"value": True}}}]
if subscribed_only:
must_list.append({"term": {"playlist_subscribed": {"value": True}}})
data = {"query": {"bool": {"must": must_list}}} # type: ignore
if source:
data["_source"] = source # type: ignore
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def calc_is_watched(duration: float, position: float) -> bool:
"""considered watched based on duration position"""

View File

@ -26,6 +26,7 @@ class YouTubeItem:
self.youtube_id = youtube_id
self.es_path = f"{self.index_name}/_doc/{youtube_id}"
self.config = AppConfig().config
self.error = None
self.youtube_meta = False
self.json_data = False
@ -33,17 +34,24 @@ class YouTubeItem:
"""build youtube url"""
return self.yt_base + self.youtube_id
def get_from_youtube(self):
def get_from_youtube(self, obs_overwrite: dict | None = None):
"""use yt-dlp to get meta data from youtube"""
print(f"{self.youtube_id}: get metadata from youtube")
obs_request = self.yt_obs.copy()
if self.config["downloads"]["extractor_lang"]:
langs = self.config["downloads"]["extractor_lang"]
langs_list = [i.strip() for i in langs.split(",")]
obs_request["extractor_args"] = {"youtube": {"lang": langs_list}}
obs_request["extractor_args"] = {
"youtube": {"lang": langs_list}
} # type: ignore
if obs_overwrite:
obs_request.update(obs_overwrite)
url = self.build_yt_url()
self.youtube_meta = YtWrap(obs_request, self.config).extract(url)
self.youtube_meta, self.error = YtWrap(
obs_request, self.config
).extract(url)
def get_from_es(self):
"""get indexed data from elastic search"""

View File

@ -165,15 +165,17 @@ class SearchProcess:
def _process_download(self, download_dict):
"""run on single download item"""
video_id = download_dict["youtube_id"]
cache_root = EnvironmentSettings().get_cache_root()
vid_thumb_url = ThumbManager(video_id).vid_thumb_path()
published = date_parser(download_dict["published"])
vid_thumb_url = None
if download_dict.get("vid_thumb_url"):
video_id = download_dict["youtube_id"]
cache_root = EnvironmentSettings().get_cache_root()
relative_path = ThumbManager(video_id).vid_thumb_path()
vid_thumb_url = f"{cache_root}/{relative_path}"
download_dict.update(
{
"vid_thumb_url": f"{cache_root}/{vid_thumb_url}",
"published": published,
"vid_thumb_url": vid_thumb_url,
"published": date_parser(download_dict["published"]),
}
)
return dict(sorted(download_dict.items()))

View File

@ -4,6 +4,7 @@ Functionality:
- identify vid_type if possible
"""
from typing import Literal, NotRequired, TypedDict
from urllib.parse import parse_qs, urlparse
from common.src.ta_redis import RedisArchivist
@ -11,19 +12,28 @@ from download.src.yt_dlp_base import YtWrap
from video.src.constants import VideoTypeEnum
class ParsedURLType(TypedDict):
"""represents single parsed url"""
type: Literal["video", "channel", "playlist"]
url: str
vid_type: VideoTypeEnum
limit: NotRequired[int | None]
class Parser:
"""
take a multi line string and detect valid youtube ids
channel handle lookup is cached, can be disabled for unittests
"""
def __init__(self, url_str, use_cache=True):
def __init__(self, url_str: str, use_cache: bool = True):
self.url_list = [i.strip() for i in url_str.split()]
self.use_cache = use_cache
def parse(self):
def parse(self) -> list[ParsedURLType]:
"""parse the list"""
ids = []
ids: list[ParsedURLType] = []
for url in self.url_list:
parsed = urlparse(url)
if parsed.netloc:
@ -124,9 +134,9 @@ class Parser:
"extract_flat": True,
"playlistend": 0,
}
url_info = YtWrap(obs_request).extract(url)
url_info, error = YtWrap(obs_request).extract(url)
if not url_info:
raise ValueError(f"failed to retrieve content from URL: {url}")
raise ValueError(f"failed to retrieve URL: {error}")
channel_id = url_info.get("channel_id", False)
if channel_id:

View File

@ -28,6 +28,11 @@ class WatchState:
self.change_vid_state()
return
if url_type == "channel":
self.reset_channel_progress()
if url_type == "playlist":
self.reset_playlist_progress()
self._add_pipeline()
path = f"ta_video/_update_by_query?pipeline=watch_{self.youtube_id}"
data = self._build_update_data(url_type)
@ -53,6 +58,31 @@ class WatchState:
print(response)
raise ValueError("failed to mark video as watched")
def reset_channel_progress(self):
"""reset channel progress positions"""
from channel.src.index import YoutubeChannel
videos = YoutubeChannel(self.youtube_id).get_channel_videos()
video_ids = [i["youtube_id"] for i in videos]
self._reset_list(video_ids)
def reset_playlist_progress(self):
"""reset playlist progress positions"""
from playlist.src.index import YoutubePlaylist
videos = YoutubePlaylist(self.youtube_id).get_playlist_videos()
video_ids = [i["youtube_id"] for i in videos]
self._reset_list(video_ids)
def _reset_list(self, video_ids: list[str]):
"""reset list of video ids"""
redis_con = RedisArchivist()
all_ids = redis_con.list_keys(f"{self.user_id}:progress")
for progress_id in all_ids:
video_id = progress_id.split(":")[-1]
if video_id in video_ids:
redis_con.del_message(progress_id)
def _build_update_data(self, url_type):
"""build update by query data based on url_type"""
term_key_map = {

View File

@ -56,6 +56,7 @@ class Command(BaseCommand):
self._mig_channel_tags()
self._mig_video_channel_tags()
self._mig_fix_download_channel_indexed()
self._mig_add_default_playlist_sort()
def _make_folders(self):
"""make expected cache folders"""
@ -389,3 +390,37 @@ class Command(BaseCommand):
self.stdout.write(response)
sleep(60)
raise CommandError(message)
def _mig_add_default_playlist_sort(self) -> None:
"""migrate from 0.5.4 to 0.5.5 set default playlist sortorder"""
self.stdout.write("[MIGRATION] set default playlist sort order")
path = "ta_playlist/_update_by_query"
data = {
"query": {
"bool": {
"must_not": [{"exists": {"field": "playlist_sort_order"}}]
}
},
"script": {
"source": "ctx._source.playlist_sort_order = 'top'",
"lang": "painless",
},
}
response, status_code = ElasticWrap(path).post(data)
if status_code in [200, 201]:
updated = response.get("updated")
if updated:
self.stdout.write(
self.style.SUCCESS(f" ✓ updated {updated} playlists")
)
else:
self.stdout.write(
self.style.SUCCESS(" no playlists need updating")
)
return
message = " 🗙 failed to set default playlist sort order"
self.stdout.write(self.style.ERROR(message))
self.stdout.write(response)
sleep(60)
raise CommandError(message)

View File

@ -316,7 +316,7 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
# TA application settings
TA_UPSTREAM = "https://github.com/tubearchivist/tubearchivist"
TA_VERSION = "v0.5.3"
TA_VERSION = "v0.5.5-unstable"
# API
REST_FRAMEWORK = {

View File

@ -15,11 +15,11 @@ class DownloadItemSerializer(serializers.Serializer):
channel_indexed = serializers.BooleanField()
channel_name = serializers.CharField()
duration = serializers.CharField()
published = serializers.CharField()
published = serializers.CharField(allow_null=True)
status = serializers.ChoiceField(choices=["pending", "ignore"])
timestamp = serializers.IntegerField()
timestamp = serializers.IntegerField(allow_null=True)
title = serializers.CharField()
vid_thumb_url = serializers.CharField()
vid_thumb_url = serializers.CharField(allow_null=True)
vid_type = serializers.ChoiceField(choices=VideoTypeEnum.values())
youtube_id = serializers.CharField()
message = serializers.CharField(required=False)
@ -42,14 +42,23 @@ class DownloadListQuerySerializer(
filter = serializers.ChoiceField(
choices=["pending", "ignore"], required=False
)
vid_type = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), required=False
)
channel = serializers.CharField(required=False, help_text="channel ID")
page = serializers.IntegerField(required=False)
q = serializers.CharField(required=False, help_text="Search Query")
error = serializers.BooleanField(required=False, allow_null=True)
class DownloadListQueueDeleteQuerySerializer(serializers.Serializer):
"""serialize bulk delete download queue query string"""
filter = serializers.ChoiceField(choices=["pending", "ignore"])
channel = serializers.CharField(required=False, help_text="channel ID")
vid_type = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), required=False
)
class AddDownloadItemSerializer(serializers.Serializer):
@ -69,6 +78,23 @@ class AddToDownloadQuerySerializer(serializers.Serializer):
"""add to queue query serializer"""
autostart = serializers.BooleanField(required=False)
flat = serializers.BooleanField(required=False)
class BulkUpdateDowloadQuerySerializer(serializers.Serializer):
"""serialize bulk update query"""
filter = serializers.ChoiceField(choices=["pending", "ignore", "priority"])
channel = serializers.CharField(required=False)
vid_type = serializers.ChoiceField(
choices=VideoTypeEnum.values_known(), required=False
)
class BulkUpdateDowloadDataSerializer(serializers.Serializer):
"""serialize data"""
status = serializers.ChoiceField(choices=["pending", "ignore", "priority"])
class DownloadQueueItemUpdateSerializer(serializers.Serializer):

View File

@ -4,16 +4,25 @@ Functionality:
- linked with ta_dowload index
"""
import json
from datetime import datetime
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from channel.src.remote_query import get_last_channel_videos
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import get_duration_str, is_shorts, rand_sleep
from download.src.subscriptions import ChannelSubscription
from common.src.helper import (
get_channels,
get_duration_str,
is_shorts,
rand_sleep,
)
from common.src.urlparser import ParsedURLType
from download.src.queue_interact import PendingInteract
from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo
class PendingIndex:
@ -61,11 +70,7 @@ class PendingIndex:
"""get a list of all channels indexed"""
self.all_channels = []
self.channel_overwrites = {}
data = {
"query": {"match_all": {}},
"sort": [{"channel_id": {"order": "asc"}}],
}
channels = IndexPaginate("ta_channel", data).get_results()
channels = get_channels(subscribed_only=False)
for channel in channels:
channel_id = channel["channel_id"]
@ -88,67 +93,6 @@ class PendingIndex:
self.video_overwrites.update({video_id: overwrites})
class PendingInteract:
"""interact with items in download queue"""
def __init__(self, youtube_id=False, status=False):
self.youtube_id = youtube_id
self.status = status
def delete_item(self):
"""delete single item from pending"""
path = f"ta_download/_doc/{self.youtube_id}"
_, _ = ElasticWrap(path).delete(refresh=True)
def delete_by_status(self):
"""delete all matching item by status"""
data = {"query": {"term": {"status": {"value": self.status}}}}
path = "ta_download/_delete_by_query"
_, _ = ElasticWrap(path).post(data=data)
def update_status(self):
"""update status of pending item"""
if self.status == "priority":
data = {
"doc": {
"status": "pending",
"auto_start": True,
"message": None,
}
}
else:
data = {"doc": {"status": self.status}}
path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
_, _ = ElasticWrap(path).post(data=data)
def get_item(self):
"""return pending item dict"""
path = f"ta_download/_doc/{self.youtube_id}"
response, status_code = ElasticWrap(path).get()
return response["_source"], status_code
def get_channel(self):
"""
get channel metadata from queue to not depend on channel to be indexed
"""
data = {
"size": 1,
"query": {"term": {"channel_id": {"value": self.youtube_id}}},
}
response, _ = ElasticWrap("ta_download/_search").get(data=data)
hits = response["hits"]["hits"]
if not hits:
channel_name = "NA"
else:
channel_name = hits[0]["_source"].get("channel_name", "NA")
return {
"channel_id": self.youtube_id,
"channel_name": channel_name,
}
class PendingList(PendingIndex):
"""manage the pending videos list"""
@ -159,126 +103,365 @@ class PendingList(PendingIndex):
"check_formats": None,
}
def __init__(self, youtube_ids=False, task=False):
def __init__(
self,
youtube_ids: list[ParsedURLType],
task=None,
auto_start=False,
flat=False,
):
super().__init__()
self.config = AppConfig().config
self.youtube_ids = youtube_ids
self.task = task
self.auto_start = auto_start
self.flat = flat
self.to_skip = False
self.missing_videos = False
self.missing_videos: list[dict] = []
def parse_url_list(self, auto_start=False):
def parse_url_list(self):
"""extract youtube ids from list"""
self.missing_videos = []
self.get_download()
self.get_indexed()
self.get_channels()
total = len(self.youtube_ids)
for idx, entry in enumerate(self.youtube_ids):
self._process_entry(entry, auto_start=auto_start)
if not self.task:
continue
if self.task:
self.task.send_progress(
message_lines=[f"Extracting items {idx + 1}/{total}"],
progress=(idx + 1) / total,
)
self.task.send_progress(
message_lines=[f"Extracting items {idx + 1}/{total}"],
progress=(idx + 1) / total,
)
self._process_entry(entry, idx, total)
if self.task and self.task.is_stopped():
break
def _process_entry(self, entry, auto_start=False):
rand_sleep(self.config)
def _process_entry(self, entry: dict, idx_url: int, total_url: int):
"""process single entry from url list"""
vid_type = self._get_vid_type(entry)
if entry["type"] == "video":
self._add_video(entry["url"], vid_type, auto_start=auto_start)
self._add_video(
entry["url"], entry["vid_type"], idx_url, total_url
)
elif entry["type"] == "channel":
self._parse_channel(entry["url"], vid_type)
self._parse_channel(entry)
elif entry["type"] == "playlist":
self._parse_playlist(entry["url"])
self._parse_playlist(entry["url"], entry.get("limit"))
else:
raise ValueError(f"invalid url_type: {entry}")
@staticmethod
def _get_vid_type(entry):
"""add vid type enum if available"""
vid_type_str = entry.get("vid_type")
if not vid_type_str:
return VideoTypeEnum.UNKNOWN
return VideoTypeEnum(vid_type_str)
def _add_video(self, url, vid_type, auto_start=False):
def _add_video(self, url, vid_type, idx, total):
"""add video to list"""
if auto_start and url in set(
if self.auto_start and url in set(
i["youtube_id"] for i in self.all_pending
):
PendingInteract(youtube_id=url, status="priority").update_status()
return
if url not in self.missing_videos and url not in self.to_skip:
self.missing_videos.append((url, vid_type))
else:
if url in self.missing_videos or url in self.to_skip:
print(f"{url}: skipped adding already indexed video to download.")
else:
to_add = self._parse_video(
url,
vid_type,
url_type="video",
idx=idx,
total=total,
)
if to_add:
self.missing_videos.append(to_add)
def _parse_channel(self, url, vid_type):
"""add all videos of channel to list"""
video_results = ChannelSubscription().get_last_youtube_videos(
url, limit=False, query_filter=vid_type
def _parse_channel(self, entry):
"""parse channel"""
url = entry["url"]
vid_type = entry["vid_type"]
if isinstance(vid_type, str):
# lookup enum
vid_type = getattr(VideoTypeEnum, vid_type.upper())
limit = entry.get("limit")
video_results = get_last_channel_videos(
channel_id=url,
config=self.config,
limit=limit,
query_filter=vid_type,
)
for video_id, _, vid_type in video_results:
self._add_video(video_id, vid_type)
def _parse_playlist(self, url):
"""add all videos of playlist to list"""
playlist = YoutubePlaylist(url)
is_active = playlist.update_playlist()
if not is_active:
message = f"{playlist.youtube_id}: failed to extract metadata"
print(message)
raise ValueError(message)
entries = playlist.json_data["playlist_entries"]
to_add = [i["youtube_id"] for i in entries if not i["downloaded"]]
if not to_add:
if not video_results:
print(f"{url}: no videos to add from channel, skipping")
return
for video_id in to_add:
# match vid_type later
self._add_video(video_id, VideoTypeEnum.UNKNOWN)
channel_handler = YoutubeChannel(url)
channel_handler.build_json(upload=False)
if not channel_handler.json_data:
print(f"{url}: channel metadata extraction failed, skipping")
return
def add_to_pending(self, status="pending", auto_start=False):
"""add missing videos to pending list"""
self.get_channels()
total = len(video_results)
for idx, video_data in enumerate(video_results):
to_add = self.__parse_channel_video(
video_data, vid_type, idx, total, channel_handler
)
if to_add:
self.missing_videos.append(to_add)
total = len(self.missing_videos)
videos_added = []
for idx, (youtube_id, vid_type) in enumerate(self.missing_videos):
if self.task and self.task.is_stopped():
break
print(f"{youtube_id}: [{idx + 1}/{total}]: add to queue")
self._notify_add(idx, total)
video_details = self.get_youtube_details(youtube_id, vid_type)
if not video_details:
rand_sleep(self.config)
continue
def __parse_channel_video(
self, video_data, vid_type, idx, total, channel_handler
) -> dict | None:
"""parse video of channel"""
video_id = video_data["id"]
if video_id in self.to_skip:
return None
video_details.update(
{
"status": status,
"auto_start": auto_start,
}
if self.flat:
if not video_data.get("channel"):
channel_name = channel_handler.json_data["channel_name"]
video_data["channel"] = channel_name
if not video_data.get("channel_id"):
channel_id = channel_handler.json_data["channel_id"]
video_data["channel_id"] = channel_id
to_add = self._parse_entry(
youtube_id=video_id,
video_data=video_data,
url_type="channel",
idx=idx,
total=total,
)
else:
to_add = self._parse_video(
video_id,
vid_type,
url_type="channel",
idx=idx,
total=total,
)
url = video_details["vid_thumb_url"]
ThumbManager(youtube_id).download_video_thumb(url)
es_url = f"ta_download/_doc/{youtube_id}"
_, _ = ElasticWrap(es_url).put(video_details)
videos_added.append(youtube_id)
return to_add
if idx != total:
rand_sleep(self.config)
def _parse_playlist(self, url: str, limit: int | None):
"""fast parse playlist"""
playlist = YoutubePlaylist(url)
playlist.update_playlist(limit=limit)
if not playlist.youtube_meta:
print(f"{url}: playlist metadata extraction failed, skipping")
return
return videos_added
video_results = playlist.youtube_meta["entries"]
def _notify_add(self, idx, total):
total = len(video_results)
for idx, video_data in enumerate(video_results):
video_id = video_data["id"]
if video_id in self.to_skip:
continue
if self.task and self.task.is_stopped():
break
if self.flat:
if not video_data.get("channel"):
video_data["channel"] = playlist.youtube_meta["channel"]
if not video_data.get("channel_id"):
channel_id = playlist.youtube_meta["channel_id"]
video_data["channel_id"] = channel_id
to_add = self._parse_entry(
video_id,
video_data,
url_type="playlist",
idx=idx,
total=total,
)
else:
to_add = self._parse_video(
video_id,
vid_type=None,
url_type="playlist",
idx=idx,
total=total,
)
if to_add:
self.missing_videos.append(to_add)
def _parse_video(self, url, vid_type, url_type, idx, total):
"""parse video"""
video = YoutubeVideo(youtube_id=url)
video.get_from_youtube()
if not video.youtube_meta:
print(f"{url}: video metadata extraction failed, skipping")
if self.task:
self.task.send_progress(
message_lines=[
"Video extraction failed.",
f"{video.error}",
],
level="error",
)
return None
video.youtube_meta["vid_type"] = vid_type
to_add = self._parse_entry(
youtube_id=url,
video_data=video.youtube_meta,
url_type=url_type,
idx=idx,
total=total,
)
ThumbManager(item_id=url).download_video_thumb(to_add["vid_thumb_url"])
rand_sleep(self.config)
return to_add
def _parse_entry(
self,
youtube_id: str,
video_data: dict,
url_type: str,
idx: int,
total: int,
) -> dict | None:
"""parse entry"""
if video_data.get("id") != youtube_id:
# skip premium videos with different id or redirects
print(f"{youtube_id}: skipping redirect, id not matching")
return None
if video_data.get("live_status") in ["is_upcoming", "is_live"]:
print(f"{youtube_id}: skip is_upcoming or is_live")
return None
to_add = {
"youtube_id": video_data["id"],
"title": video_data["title"],
"vid_thumb_url": self.__extract_thumb(video_data),
"duration": get_duration_str(video_data.get("duration", 0)),
"published": self.__extract_published(video_data),
"timestamp": int(datetime.now().timestamp()),
"vid_type": self.__extract_vid_type(video_data),
"channel_name": video_data["channel"],
"channel_id": video_data["channel_id"],
"channel_indexed": video_data["channel_id"] in self.all_channels,
}
self._notify_progress(url_type, video_data["title"], idx, total)
return to_add
def __extract_thumb(self, video_data) -> str | None:
"""extract thumb"""
if "thumbnail" in video_data:
return video_data["thumbnail"]
return None
def __extract_published(self, video_data) -> str | int | None:
"""build published date or timestamp"""
timestamp = video_data.get("timestamp")
if timestamp:
return timestamp
upload_date = video_data.get("upload_date")
if not upload_date:
return None
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
return published
def __extract_vid_type(self, video_data) -> str:
"""build vid type"""
if "vid_type" in video_data:
return video_data["vid_type"]
if video_data.get("live_status") == "was_live":
return VideoTypeEnum.STREAMS.value
if video_data.get("width", 0) > video_data.get("height", 0):
return VideoTypeEnum.VIDEOS.value
duration = video_data.get("duration")
if duration and isinstance(duration, int):
if duration > 3 * 60:
return VideoTypeEnum.VIDEOS.value
if is_shorts(video_data["id"]):
return VideoTypeEnum.SHORTS.value
return VideoTypeEnum.VIDEOS.value
def add_to_pending(self, status="pending") -> int:
"""add missing videos to pending list"""
total = len(self.missing_videos)
if not self.missing_videos:
self._notify_empty()
return 0
self._notify_start(total)
bulk_list = []
for video_entry in self.missing_videos:
video_entry.update(
{
"status": status,
"auto_start": self.auto_start,
}
)
video_id = video_entry["youtube_id"]
action = {"index": {"_index": "ta_download", "_id": video_id}}
bulk_list.append(json.dumps(action))
bulk_list.append(json.dumps(video_entry))
# add last newline
bulk_list.append("\n")
query_str = "\n".join(bulk_list)
_, status_code = ElasticWrap("_bulk").post(query_str, ndjson=True)
if status_code != 200:
self._notify_fail(status_code)
else:
self._notify_done(total)
return len(self.missing_videos)
def _notify_progress(self, url_type, name, idx, total):
"""notify extraction progress"""
if not self.task:
return
if self.flat:
second_line = f"Bulk processing {total} items."
else:
second_line = f"Processing video {idx + 1}/{total}."
self.task.send_progress(
message_lines=[
f"Extracting '{name}' from {url_type.title()}.",
second_line,
],
progress=(idx + 1) / total,
)
def _notify_empty(self):
"""notify nothing to add"""
if not self.task:
return
self.task.send_progress(
message_lines=[
"Extracting videos completed.",
"No new videos found to add.",
]
)
def _notify_start(self, total):
"""send notification for adding videos to download queue"""
if not self.task:
return
@ -286,82 +469,31 @@ class PendingList(PendingIndex):
self.task.send_progress(
message_lines=[
"Adding new videos to download queue.",
f"Extracting items {idx + 1}/{total}",
],
progress=(idx + 1) / total,
f"Bulk adding {total} videos",
]
)
def get_youtube_details(self, youtube_id, vid_type=VideoTypeEnum.VIDEOS):
"""get details from youtubedl for single pending video"""
vid = YtWrap(self.yt_obs, self.config).extract(youtube_id)
if not vid:
return False
def _notify_done(self, total):
"""send done notification"""
if not self.task:
return
if vid.get("id") != youtube_id:
# skip premium videos with different id
print(f"{youtube_id}: skipping premium video, id not matching")
return False
# stop if video is streaming live now
if vid["live_status"] in ["is_upcoming", "is_live"]:
print(f"{youtube_id}: skip is_upcoming or is_live")
return False
self.task.send_progress(
message_lines=[
"Adding new videos to the queue completed.",
f"Added {total} videos.",
]
)
if vid["live_status"] == "was_live":
vid_type = VideoTypeEnum.STREAMS
else:
if self._check_shorts(vid):
vid_type = VideoTypeEnum.SHORTS
else:
vid_type = VideoTypeEnum.VIDEOS
def _notify_fail(self, status_code):
"""failed to add"""
if not self.task:
return
if not vid.get("channel"):
print(f"{youtube_id}: skip video not part of channel")
return False
return self._parse_youtube_details(vid, vid_type)
@staticmethod
def _check_shorts(vid):
"""check if vid is shorts video"""
if vid["width"] > vid["height"]:
return False
duration = vid.get("duration")
if duration and isinstance(duration, int):
if duration > 3 * 60:
return False
return is_shorts(vid["id"])
def _parse_youtube_details(self, vid, vid_type=VideoTypeEnum.VIDEOS):
"""parse response"""
vid_id = vid.get("id")
# build dict
youtube_details = {
"youtube_id": vid_id,
"channel_name": vid["channel"],
"vid_thumb_url": vid["thumbnail"],
"title": vid["title"],
"channel_id": vid["channel_id"],
"duration": get_duration_str(vid["duration"]),
"published": self._build_published(vid),
"timestamp": int(datetime.now().timestamp()),
"vid_type": vid_type.value,
"channel_indexed": vid["channel_id"] in self.all_channels,
}
return youtube_details
@staticmethod
def _build_published(vid):
"""build published date or timestamp"""
timestamp = vid["timestamp"]
if timestamp:
return timestamp
upload_date = vid["upload_date"]
upload_date_time = datetime.strptime(upload_date, "%Y%m%d")
published = upload_date_time.strftime("%Y-%m-%d")
return published
self.task.send_progress(
message_lines=[
"Adding extracted videos failed.",
f"Status code: {status_code}",
],
level="error",
)

View File

@ -0,0 +1,100 @@
"""interact with queue items"""
from common.src.es_connect import ElasticWrap
class PendingInteract:
"""interact with items in download queue"""
def __init__(self, youtube_id=False, status=False):
self.youtube_id = youtube_id
self.status = status
def delete_item(self):
"""delete single item from pending"""
path = f"ta_download/_doc/{self.youtube_id}"
_, _ = ElasticWrap(path).delete(refresh=True)
def delete_bulk(self, channel_id: str | None, vid_type: str | None):
"""delete all matching item by status"""
must_list = [{"term": {"status": {"value": self.status}}}]
if channel_id:
must_list.append({"term": {"channel_id": {"value": channel_id}}})
if vid_type:
must_list.append({"term": {"vid_type": {"value": vid_type}}})
data = {"query": {"bool": {"must": must_list}}}
path = "ta_download/_delete_by_query?refresh=true"
_, _ = ElasticWrap(path).post(data=data)
def update_bulk(
self, channel_id: str | None, vid_type: str | None, new_status: str
):
"""update status in bulk"""
must_list = [{"term": {"status": {"value": self.status}}}]
if channel_id:
must_list.append({"term": {"channel_id": {"value": channel_id}}})
if vid_type:
must_list.append({"term": {"vid_type": {"value": vid_type}}})
if new_status == "priority":
source = """
ctx._source.status = 'pending';
ctx._source.auto_start = true;
ctx._source.message = null;
"""
else:
source = f"ctx._source.status = '{new_status}'"
data = {
"query": {"bool": {"must": must_list}},
"script": {"source": source, "lang": "painless"},
}
path = "ta_download/_update_by_query?refresh=true"
_, _ = ElasticWrap(path).post(data)
def update_status(self):
"""update status of pending item"""
if self.status == "priority":
data = {
"doc": {
"status": "pending",
"auto_start": True,
"message": None,
}
}
else:
data = {"doc": {"status": self.status}}
path = f"ta_download/_update/{self.youtube_id}/?refresh=true"
_, _ = ElasticWrap(path).post(data=data)
def get_item(self):
"""return pending item dict"""
path = f"ta_download/_doc/{self.youtube_id}"
response, status_code = ElasticWrap(path).get()
return response["_source"], status_code
def get_channel(self):
"""
get channel metadata from queue to not depend on channel to be indexed
"""
data = {
"size": 1,
"query": {"term": {"channel_id": {"value": self.youtube_id}}},
}
response, _ = ElasticWrap("ta_download/_search").get(data=data)
hits = response["hits"]["hits"]
if not hits:
channel_name = "NA"
else:
channel_name = hits[0]["_source"].get("channel_name", "NA")
return {
"channel_id": self.youtube_id,
"channel_name": channel_name,
}

View File

@ -6,324 +6,97 @@ Functionality:
from appsettings.src.config import AppConfig
from channel.src.index import YoutubeChannel
from common.src.es_connect import IndexPaginate
from common.src.helper import is_missing, rand_sleep
from common.src.urlparser import Parser
from download.src.thumbnails import ThumbManager
from download.src.yt_dlp_base import YtWrap
from channel.src.remote_query import VideoQueryBuilder
from common.src.helper import get_channels, get_playlists
from common.src.urlparser import ParsedURLType, Parser
from download.src.queue import PendingList
from playlist.src.index import YoutubePlaylist
from video.src.constants import VideoTypeEnum
from video.src.index import YoutubeVideo
class ChannelSubscription:
"""manage the list of channels subscribed"""
"""scan subscribed channels to find missing videos to add to pending"""
def __init__(self, task=False):
def __init__(self, task=None):
self.config = AppConfig().config
self.task = task
@staticmethod
def get_channels(subscribed_only=True):
"""get a list of all channels subscribed to"""
data = {
"sort": [{"channel_name.keyword": {"order": "asc"}}],
}
if subscribed_only:
data["query"] = {"term": {"channel_subscribed": {"value": True}}}
else:
data["query"] = {"match_all": {}}
all_channels = IndexPaginate("ta_channel", data).get_results()
return all_channels
def get_last_youtube_videos(
self,
channel_id,
limit=True,
query_filter=None,
channel_overwrites=None,
):
"""get a list of last videos from channel"""
query_handler = VideoQueryBuilder(self.config, channel_overwrites)
queries = query_handler.build_queries(query_filter)
last_videos = []
for vid_type_enum, limit_amount in queries:
obs = {
"skip_download": True,
"extract_flat": True,
}
vid_type = vid_type_enum.value
if limit:
obs["playlistend"] = limit_amount
url = f"https://www.youtube.com/channel/{channel_id}/{vid_type}"
channel_query = YtWrap(obs, self.config).extract(url)
if not channel_query:
continue
last_videos.extend(
[
(i["id"], i["title"], vid_type)
for i in channel_query["entries"]
]
)
return last_videos
def find_missing(self):
"""add missing videos from subscribed channels to pending"""
all_channels = self.get_channels()
def find_missing(self) -> int:
"""find missing videos from channel subscriptions"""
all_channels = get_channels(
subscribed_only=True, source=["channel_id", "channel_overwrites"]
)
if not all_channels:
return False
return 0
missing_videos = []
all_channel_urls: list[ParsedURLType] = []
total = len(all_channels)
for idx, channel in enumerate(all_channels):
channel_id = channel["channel_id"]
print(f"{channel_id}: find missing videos.")
last_videos = self.get_last_youtube_videos(
channel_id,
channel_overwrites=channel.get("channel_overwrites"),
)
for channel in all_channels:
queries = VideoQueryBuilder(
config=self.config,
channel_overwrites=channel.get("channel_overwrites", {}),
).build_queries(video_type=None)
if last_videos:
ids_to_add = is_missing([i[0] for i in last_videos])
for video_id, _, vid_type in last_videos:
if video_id in ids_to_add:
missing_videos.append((video_id, vid_type))
for query in queries:
all_channel_urls.append(
ParsedURLType(
type="channel",
url=channel["channel_id"],
vid_type=query[0],
limit=query[1],
)
)
if not self.task:
continue
if self.task.is_stopped():
self.task.send_progress(["Received Stop signal."])
break
self.task.send_progress(
message_lines=[f"Scanning Channel {idx + 1}/{total}"],
progress=(idx + 1) / total,
)
rand_sleep(self.config)
return missing_videos
@staticmethod
def change_subscribe(channel_id, channel_subscribed):
"""subscribe or unsubscribe from channel and update"""
channel = YoutubeChannel(channel_id)
channel.build_json()
channel.json_data["channel_subscribed"] = channel_subscribed
channel.upload_to_es()
channel.sync_to_videos()
return channel.json_data
class VideoQueryBuilder:
"""Build queries for yt-dlp."""
def __init__(self, config: dict, channel_overwrites: dict | None = None):
self.config = config
self.channel_overwrites = channel_overwrites or {}
def build_queries(
self, video_type: VideoTypeEnum | None, limit: bool = True
) -> list[tuple[VideoTypeEnum, int | None]]:
"""Build queries for all or specific video type."""
query_methods = {
VideoTypeEnum.VIDEOS: self.videos_query,
VideoTypeEnum.STREAMS: self.streams_query,
VideoTypeEnum.SHORTS: self.shorts_query,
}
if video_type:
# build query for specific type
query_method = query_methods.get(video_type)
if query_method:
query = query_method(limit)
if query[1] != 0:
return [query]
return []
# Build and return queries for all video types
queries = []
for build_query in query_methods.values():
query = build_query(limit)
if query[1] != 0:
queries.append(query)
return queries
def videos_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for videos."""
return self._build_generic_query(
video_type=VideoTypeEnum.VIDEOS,
overwrite_key="subscriptions_channel_size",
config_key="channel_size",
limit=limit,
pending_handler = PendingList(
youtube_ids=all_channel_urls,
task=self.task,
auto_start=self.config["subscriptions"].get("auto_start", False),
flat=self.config["subscriptions"].get("extract_flat", False),
)
pending_handler.parse_url_list()
added = pending_handler.add_to_pending()
def streams_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for streams."""
return self._build_generic_query(
video_type=VideoTypeEnum.STREAMS,
overwrite_key="subscriptions_live_channel_size",
config_key="live_channel_size",
limit=limit,
)
def shorts_query(self, limit: bool) -> tuple[VideoTypeEnum, int | None]:
"""Build query for shorts."""
return self._build_generic_query(
video_type=VideoTypeEnum.SHORTS,
overwrite_key="subscriptions_shorts_channel_size",
config_key="shorts_channel_size",
limit=limit,
)
def _build_generic_query(
self,
video_type: VideoTypeEnum,
overwrite_key: str,
config_key: str,
limit: bool,
) -> tuple[VideoTypeEnum, int | None]:
"""Generic query for video page scraping."""
if not limit:
return (video_type, None)
if (
overwrite_key in self.channel_overwrites
and self.channel_overwrites[overwrite_key] is not None
):
overwrite = self.channel_overwrites[overwrite_key]
return (video_type, overwrite)
if overwrite := self.config["subscriptions"].get(config_key):
return (video_type, overwrite)
return (video_type, 0)
return added
class PlaylistSubscription:
"""manage the playlist download functionality"""
"""scan subscribed playlists for videos to add to pending"""
def __init__(self, task=False):
def __init__(self, task=None):
self.config = AppConfig().config
self.task = task
@staticmethod
def get_playlists(subscribed_only=True):
"""get a list of all active playlists"""
data = {
"sort": [{"playlist_channel.keyword": {"order": "desc"}}],
}
data["query"] = {
"bool": {"must": [{"term": {"playlist_active": {"value": True}}}]}
}
if subscribed_only:
data["query"]["bool"]["must"].append(
{"term": {"playlist_subscribed": {"value": True}}}
)
all_playlists = IndexPaginate("ta_playlist", data).get_results()
return all_playlists
def process_url_str(self, new_playlists, subscribed=True):
"""process playlist subscribe form url_str"""
for idx, playlist in enumerate(new_playlists):
playlist_id = playlist["url"]
if not playlist["type"] == "playlist":
print(f"{playlist_id} not a playlist, skipping...")
continue
playlist_h = YoutubePlaylist(playlist_id)
playlist_h.build_json()
if not playlist_h.json_data:
message = f"{playlist_h.youtube_id}: failed to extract data"
print(message)
raise ValueError(message)
playlist_h.json_data["playlist_subscribed"] = subscribed
playlist_h.upload_to_es()
playlist_h.add_vids_to_playlist()
self.channel_validate(playlist_h.json_data["playlist_channel_id"])
url = playlist_h.json_data["playlist_thumbnail"]
thumb = ThumbManager(playlist_id, item_type="playlist")
thumb.download_playlist_thumb(url)
if self.task:
self.task.send_progress(
message_lines=[
f"Processing {idx + 1} of {len(new_playlists)}"
],
progress=(idx + 1) / len(new_playlists),
)
@staticmethod
def channel_validate(channel_id):
"""make sure channel of playlist is there"""
channel = YoutubeChannel(channel_id)
channel.build_json(upload=True)
@staticmethod
def change_subscribe(playlist_id, subscribe_status):
"""change the subscribe status of a playlist"""
playlist = YoutubePlaylist(playlist_id)
playlist.build_json()
playlist.json_data["playlist_subscribed"] = subscribe_status
playlist.upload_to_es()
return playlist.json_data
def find_missing(self):
"""find videos in subscribed playlists not downloaded yet"""
all_playlists = [i["playlist_id"] for i in self.get_playlists()]
def find_missing(self) -> int:
"""find missing"""
all_playlists = get_playlists(
subscribed_only=True, source=["playlist_id"]
)
if not all_playlists:
return False
return 0
missing_videos = []
total = len(all_playlists)
for idx, playlist_id in enumerate(all_playlists):
playlist = YoutubePlaylist(playlist_id)
is_active = playlist.update_playlist()
if not is_active:
playlist.deactivate()
continue
playlist_entries = playlist.json_data["playlist_entries"]
size_limit = self.config["subscriptions"]["channel_size"]
if size_limit:
del playlist_entries[size_limit:]
to_check = [
i["youtube_id"]
for i in playlist_entries
if i["downloaded"] is False
]
needs_downloading = is_missing(to_check)
missing_videos.extend(needs_downloading)
if not self.task:
continue
if self.task.is_stopped():
self.task.send_progress(["Received Stop signal."])
break
self.task.send_progress(
message_lines=[f"Scanning Playlists {idx + 1}/{total}"],
progress=(idx + 1) / total,
size_limit = self.config["subscriptions"]["channel_size"]
all_playlist_urls: list[ParsedURLType] = []
for playlist in all_playlists:
all_playlist_urls.append(
ParsedURLType(
type="playlist",
url=playlist["playlist_id"],
vid_type=VideoTypeEnum.UNKNOWN,
limit=size_limit,
)
)
rand_sleep(self.config)
return missing_videos
pending_handler = PendingList(
youtube_ids=all_playlist_urls,
task=self.task,
auto_start=self.config["subscriptions"].get("auto_start", False),
flat=self.config["subscriptions"].get("extract_flat", False),
)
pending_handler.parse_url_list()
added = pending_handler.add_to_pending()
return added
class SubscriptionScanner:
@ -339,40 +112,12 @@ class SubscriptionScanner:
if self.task:
self.task.send_progress(["Rescanning channels and playlists."])
self.missing_videos = []
self.scan_channels()
added = 0
added += ChannelSubscription(task=self.task).find_missing()
if self.task and not self.task.is_stopped():
self.scan_playlists()
added += PlaylistSubscription(task=self.task).find_missing()
return self.missing_videos
def scan_channels(self):
"""get missing from channels"""
channel_handler = ChannelSubscription(task=self.task)
missing = channel_handler.find_missing()
if not missing:
return
for vid_id, vid_type in missing:
self.missing_videos.append(
{"type": "video", "vid_type": vid_type, "url": vid_id}
)
def scan_playlists(self):
"""get missing from playlists"""
playlist_handler = PlaylistSubscription(task=self.task)
missing = playlist_handler.find_missing()
if not missing:
return
for i in missing:
self.missing_videos.append(
{
"type": "video",
"vid_type": VideoTypeEnum.VIDEOS.value,
"url": i,
}
)
return added
class SubscriptionHandler:
@ -404,7 +149,8 @@ class SubscriptionHandler:
f"expected {expected_type} url but got {item.get('type')}"
)
PlaylistSubscription().process_url_str([item])
playlist = YoutubePlaylist(item["url"])
playlist.change_subscribe(new_subscribe_state=True)
return
if item["type"] == "video":
@ -427,9 +173,7 @@ class SubscriptionHandler:
def _subscribe(self, channel_id):
"""subscribe to channel"""
_ = ChannelSubscription().change_subscribe(
channel_id, channel_subscribed=True
)
YoutubeChannel(channel_id).change_subscribe(new_subscribe_state=True)
def _notify(self, idx, item, total):
"""send notification message to redis"""

View File

@ -80,30 +80,33 @@ class YtWrap:
return True, True
def extract(self, url):
"""make extract request"""
def extract(self, url) -> tuple[dict | None, str | None]:
"""
make extract request
returns response, error
"""
with yt_dlp.YoutubeDL(self.obs) as ydl:
try:
response = ydl.extract_info(url)
except cookiejar.LoadError as err:
print(f"cookie file is invalid: {err}")
return False
return None, str(err)
except yt_dlp.utils.ExtractorError as err:
print(f"{url}: failed to extract: {err}, continue...")
return False
return None, str(err)
except yt_dlp.utils.DownloadError as err:
if "This channel does not have a" in str(err):
return False
return None, None
print(f"{url}: failed to get info from youtube: {err}")
if "Temporary failure in name resolution" in str(err):
raise ConnectionError("lost the internet, abort!") from err
return False
return None, str(err)
self._validate_cookie()
return response
return response, None
def _validate_cookie(self):
"""check cookie and write it back for next use"""
@ -146,7 +149,7 @@ class CookieHandler:
AppConfig().update_config({"downloads": {"cookie_import": False}})
print("[cookie]: revoked")
def validate(self):
def validate(self) -> bool:
"""validate cookie using the liked videos playlist"""
validation = RedisArchivist().get_message_dict("cookie:valid")
if validation:
@ -159,8 +162,8 @@ class CookieHandler:
"extract_flat": True,
}
validator = YtWrap(obs_request, self.config)
response = bool(validator.extract("LL"))
self.store_validation(response)
response, error = validator.extract("LL")
self.store_validation(bool(response))
# update in redis to avoid expiring
modified = validator.obs["cookiefile"].getvalue().strip("\x00")
@ -173,15 +176,15 @@ class CookieHandler:
"status": "message:download",
"level": "error",
"title": "Cookie validation failed, exiting...",
"message": "",
"message": error,
}
RedisArchivist().set_message(
"message:download", mess_dict, expire=4
)
print("[cookie]: validation failed, exiting...")
print(f"[cookie]: validation success: {response}")
return response
print(f"[cookie]: validation success: {bool(response)}")
return bool(response)
@staticmethod
def store_validation(response):

View File

@ -16,12 +16,12 @@ from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap, IndexPaginate
from common.src.helper import (
get_channel_overwrites,
get_playlists,
ignore_filelist,
rand_sleep,
)
from common.src.ta_redis import RedisQueue
from download.src.queue import PendingList
from download.src.subscriptions import PlaylistSubscription
from download.src.yt_dlp_base import YtWrap
from playlist.src.index import YoutubePlaylist
from video.src.comments import CommentList
@ -403,8 +403,8 @@ class DownloadPostProcess(DownloaderBase):
def _add_playlist_sub(self):
"""add subscribed playlists to refresh"""
subs = PlaylistSubscription().get_playlists()
to_add = [i["playlist_id"] for i in subs]
playlists = get_playlists(subscribed_only=True, source=["playlist_id"])
to_add = [i["playlist_id"] for i in playlists]
RedisQueue(self.PLAYLIST_QUEUE).add_list(to_add)
def _add_channel_playlists(self):

View File

@ -8,6 +8,8 @@ from common.views_base import AdminOnly, ApiBaseView
from download.serializers import (
AddToDownloadListSerializer,
AddToDownloadQuerySerializer,
BulkUpdateDowloadDataSerializer,
BulkUpdateDowloadQuerySerializer,
DownloadAggsSerializer,
DownloadItemSerializer,
DownloadListQuerySerializer,
@ -15,7 +17,7 @@ from download.serializers import (
DownloadListSerializer,
DownloadQueueItemUpdateSerializer,
)
from download.src.queue import PendingInteract
from download.src.queue_interact import PendingInteract
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.response import Response
from task.tasks import download_pending, extrac_dl
@ -65,6 +67,22 @@ class DownloadApiListView(ApiBaseView):
{"term": {"channel_id": {"value": filter_channel}}}
)
vid_type_filter = validated_data.get("vid_type")
if vid_type_filter:
must_list.append(
{"term": {"vid_type": {"value": vid_type_filter}}}
)
search_query = validated_data.get("q")
if search_query:
must_list.append({"match_phrase_prefix": {"title": search_query}})
if validated_data.get("error") is not None:
operator = "must" if validated_data["error"] else "must_not"
must_list.append(
{"bool": {operator: [{"exists": {"field": "message"}}]}}
)
self.data["query"] = {"bool": {"must": must_list}}
self.get_document_list(request)
@ -99,12 +117,13 @@ class DownloadApiListView(ApiBaseView):
validated_query = query_serializer.validated_data
auto_start = validated_query.get("autostart")
print(f"auto_start: {auto_start}")
flat = validated_query.get("flat", False)
print(f"auto_start: {auto_start}, flat: {flat}")
to_add = validated_data["data"]
pending = [i["youtube_id"] for i in to_add if i["status"] == "pending"]
url_str = " ".join(pending)
task = extrac_dl.delay(url_str, auto_start=auto_start)
task = extrac_dl.delay(url_str, auto_start=auto_start, flat=flat)
message = {
"message": "add to queue task started",
@ -114,6 +133,38 @@ class DownloadApiListView(ApiBaseView):
return Response(response_serializer.data)
@staticmethod
@extend_schema(
request=BulkUpdateDowloadDataSerializer(),
parameters=[BulkUpdateDowloadQuerySerializer()],
responses={204: OpenApiResponse(description="Status updated")},
)
def patch(request):
"""bulk update status"""
data_serializer = BulkUpdateDowloadDataSerializer(data=request.data)
data_serializer.is_valid(raise_exception=True)
validated_data = data_serializer.validated_data
new_status = validated_data["status"]
query_serializer = BulkUpdateDowloadQuerySerializer(
data=request.query_params
)
query_serializer.is_valid(raise_exception=True)
validated_query = query_serializer.validated_data
status_filter = validated_query.get("filter")
channel = validated_query.get("channel")
vid_type = validated_query.get("vid_type")
PendingInteract(status=status_filter).update_bulk(
channel_id=channel, vid_type=vid_type, new_status=new_status
)
if new_status == "priority":
download_pending.delay(auto_only=True)
return Response(status=204)
@extend_schema(
parameters=[DownloadListQueueDeleteQuerySerializer()],
responses={
@ -132,9 +183,18 @@ class DownloadApiListView(ApiBaseView):
validated_query = serializer.validated_data
query_filter = validated_query["filter"]
channel = validated_query.get("channel")
vid_type = validated_query.get("vid_type")
message = f"delete queue by status: {query_filter}"
if channel:
message += f" - filter by channel: {channel}"
if vid_type:
message += f" - filter by vid_type: {vid_type}"
print(message)
PendingInteract(status=query_filter).delete_by_status()
PendingInteract(status=query_filter).delete_bulk(
channel_id=channel, vid_type=vid_type
)
return Response(status=204)

View File

@ -28,6 +28,7 @@ class PlaylistSerializer(serializers.Serializer):
playlist_last_refresh = serializers.CharField()
playlist_name = serializers.CharField()
playlist_subscribed = serializers.BooleanField()
playlist_sort_order = serializers.ChoiceField(choices=["top", "bottom"])
playlist_thumbnail = serializers.CharField()
playlist_type = serializers.ChoiceField(choices=["regular", "custom"])
_index = serializers.CharField(required=False)
@ -68,7 +69,10 @@ class PlaylistBulkAddSerializer(serializers.Serializer):
class PlaylistSingleUpdate(serializers.Serializer):
"""update state of single playlist"""
playlist_subscribed = serializers.BooleanField()
playlist_subscribed = serializers.BooleanField(required=False)
playlist_sort_order = serializers.ChoiceField(
choices=["top", "bottom"], required=False
)
class PlaylistListCustomPostSerializer(serializers.Serializer):

View File

@ -30,16 +30,24 @@ class YoutubePlaylist(YouTubeItem):
self.all_members = False
self.nav = False
def build_json(self, scrape=False):
def build_json(self, scrape=False, limit: int | None = None):
"""collection to create json_data"""
self.get_from_es()
if self.json_data:
subscribed = self.json_data.get("playlist_subscribed")
playlist_sort_order = self.json_data.get("playlist_sort_order")
else:
subscribed = False
playlist_sort_order = "top"
limit_str = limit if limit is not None else ""
sort_order = 1 if playlist_sort_order == "top" else -1
playlist_items = f":{limit_str}:{sort_order}"
if scrape or not self.json_data:
self.get_from_youtube()
self.get_from_youtube(
obs_overwrite={"playlist_items": playlist_items}
)
if not self.youtube_meta:
self.json_data = False
return
@ -48,8 +56,13 @@ class YoutubePlaylist(YouTubeItem):
self._ensure_channel()
ids_found = self.get_local_vids()
self.get_entries(ids_found)
self.json_data["playlist_entries"] = self.all_members
self.json_data["playlist_subscribed"] = subscribed
self.json_data.update(
{
"playlist_entries": self.all_members,
"playlist_subscribed": subscribed,
"playlist_sort_order": playlist_sort_order,
}
)
def process_youtube_meta(self):
"""extract relevant fields from youtube"""
@ -59,6 +72,9 @@ class YoutubePlaylist(YouTubeItem):
print(f"{self.youtube_id}: thumbnail extraction failed")
playlist_thumbnail = False
if not self.youtube_meta.get("channel_id"):
raise ValueError("Failed to extract Channel ID for Playlist")
self.json_data = {
"playlist_id": self.youtube_id,
"playlist_active": True,
@ -79,6 +95,18 @@ class YoutubePlaylist(YouTubeItem):
channel_handler = YoutubeChannel(channel_id)
channel_handler.build_json(upload=True)
def get_playlist_videos(self):
"""get all playlist videos"""
data = {
"query": {
"term": {"playlist.keyword": {"value": self.youtube_id}}
},
"_source": ["youtube_id"],
}
result = IndexPaginate("ta_video", data).get_results()
return result
def get_local_vids(self) -> list[str]:
"""get local video ids from youtube entries"""
entries = self.youtube_meta["entries"]
@ -111,6 +139,13 @@ class YoutubePlaylist(YouTubeItem):
url = self.json_data["playlist_thumbnail"]
ThumbManager(self.youtube_id, item_type="playlist").download(url)
def change_subscribe(self, new_subscribe_state: bool):
"""change subscribe status"""
self.build_json()
self.json_data["playlist_subscribed"] = new_subscribe_state
self.upload_to_es()
return self.json_data
def add_vids_to_playlist(self):
"""sync the playlist id to videos"""
script = (
@ -144,14 +179,7 @@ class YoutubePlaylist(YouTubeItem):
def remove_vids_from_playlist(self):
"""remove playlist ids from videos if needed"""
needed = [i["youtube_id"] for i in self.json_data["playlist_entries"]]
data = {
"query": {"match": {"playlist": self.youtube_id}},
"_source": ["youtube_id"],
}
data = {
"query": {"term": {"playlist.keyword": {"value": self.youtube_id}}}
}
result = IndexPaginate("ta_video", data).get_results()
result = self.get_playlist_videos()
to_remove = [
i["youtube_id"] for i in result if i["youtube_id"] not in needed
]
@ -170,9 +198,9 @@ class YoutubePlaylist(YouTubeItem):
if status_code == 200:
print(f"{self.youtube_id}: removed {video_id} from playlist")
def update_playlist(self, skip_on_empty=False):
def update_playlist(self, skip_on_empty=False, limit: int | None = None):
"""update metadata for playlist with data from YouTube"""
self.build_json(scrape=True)
self.build_json(scrape=True, limit=limit)
if not self.json_data:
# return false to deactivate
return False
@ -190,6 +218,15 @@ class YoutubePlaylist(YouTubeItem):
self.get_playlist_art()
return True
def change_sort_order(self, new_sort_order):
"""update sort order of playlist"""
playlist = YoutubePlaylist(self.youtube_id)
playlist.build_json()
playlist.json_data["playlist_sort_order"] = new_sort_order
playlist.upload_to_es()
return playlist.json_data
def build_nav(self, youtube_id):
"""find next and previous in playlist of a given youtube_id"""
cache_root = EnvironmentSettings().get_cache_root()

View File

@ -7,7 +7,6 @@ from common.serializers import (
ErrorResponseSerializer,
)
from common.views_base import AdminWriteOnly, ApiBaseView
from download.src.subscriptions import PlaylistSubscription
from drf_spectacular.utils import OpenApiResponse, extend_schema
from playlist.serializers import (
PlaylistBulkAddSerializer,
@ -240,9 +239,25 @@ class PlaylistApiView(ApiBaseView):
error = ErrorResponseSerializer({"error": "playlist not found"})
return Response(error.data, status=404)
subscribed = validated_data["playlist_subscribed"]
playlist_sub = PlaylistSubscription()
json_data = playlist_sub.change_subscribe(playlist_id, subscribed)
subscribed = validated_data.get("playlist_subscribed")
sort_order = validated_data.get("playlist_sort_order")
json_data = None
if subscribed is not None:
json_data = YoutubePlaylist(playlist_id).change_subscribe(
new_subscribe_state=subscribed
)
if sort_order:
json_data = YoutubePlaylist(playlist_id).change_sort_order(
new_sort_order=sort_order
)
if not json_data:
error = ErrorResponseSerializer(
{"error": "expect playlist_subscribed or playlist_sort_order"}
)
return Response(error.data, status=400)
response_serializer = PlaylistSerializer(json_data)
return Response(response_serializer.data)

View File

@ -4,7 +4,7 @@ pre-commit==4.2.0
pylint-django==2.6.1
pylint==3.3.7
pytest-django==4.11.1
pytest==8.4.0
python-dotenv==1.1.0
pytest==8.4.1
python-dotenv==1.1.1
requirementscheck==0.0.6
types-requests==2.32.0.20250602
types-requests==2.32.4.20250611

View File

@ -3,13 +3,13 @@ celery==5.5.3
django-auth-ldap==5.2.0
django-celery-beat==2.8.1
django-cors-headers==4.7.0
Django==5.2.2
Django==5.2.3
djangorestframework==3.16.0
drf-spectacular==0.28.0
Pillow==11.2.1
redis==6.2.0
requests==2.32.3
requests==2.32.4
ryd-client==0.0.6
uvicorn==0.34.3
uvicorn==0.35.0
whitenoise==6.9.0
yt-dlp[default]==2025.5.22
yt-dlp[default]==2025.6.30

View File

@ -39,10 +39,10 @@ class BaseTask(Task):
RedisArchivist().set_message(key, message, expire=20)
def on_success(self, retval, task_id, args, kwargs):
"""callback task completed successfully"""
"""callback task completed"""
print(f"{task_id} success callback")
message, key = self._build_message()
message.update({"messages": ["Task completed successfully"]})
message.update({"messages": ["Task completed"]})
RedisArchivist().set_message(key, message, expire=5)
def before_start(self, task_id, args, kwargs):
@ -58,9 +58,11 @@ class BaseTask(Task):
task_title = TASK_CONFIG.get(self.name).get("title")
Notifications(self.name).send(task_id, task_title)
def send_progress(self, message_lines, progress=False, title=False):
def send_progress(
self, message_lines, progress=False, title=False, level="info"
):
"""send progress message"""
message, key = self._build_message()
message, key = self._build_message(level=level)
message.update(
{
"messages": message_lines,
@ -101,13 +103,13 @@ def update_subscribed(self):
manager.init(self)
handler = SubscriptionScanner(task=self)
missing_videos = handler.scan()
added = handler.scan()
auto_start = handler.auto_start
if missing_videos:
print(missing_videos)
extrac_dl.delay(missing_videos, auto_start=auto_start)
message = f"Found {len(missing_videos)} videos to add to the queue."
return message
if added:
if auto_start:
download_pending.delay(auto_only=True)
return f"Found {added} videos to add to the queue."
return None
@ -147,7 +149,9 @@ def download_pending(self, auto_only=False):
@shared_task(name="extract_download", bind=True, base=BaseTask)
def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
def extrac_dl(
self, youtube_ids, auto_start=False, flat=False, status="pending"
):
"""parse list passed and add to pending"""
TaskManager().init(self)
if isinstance(youtube_ids, str):
@ -155,17 +159,17 @@ def extrac_dl(self, youtube_ids, auto_start=False, status="pending"):
else:
to_add = youtube_ids
pending_handler = PendingList(youtube_ids=to_add, task=self)
pending_handler.parse_url_list(auto_start=auto_start)
videos_added = pending_handler.add_to_pending(
status=status, auto_start=auto_start
pending_handler = PendingList(
youtube_ids=to_add, task=self, auto_start=auto_start, flat=flat
)
pending_handler.parse_url_list()
videos_added = pending_handler.add_to_pending(status=status)
if auto_start:
download_pending.delay(auto_only=True)
if videos_added:
return f"added {len(videos_added)} Videos to Queue"
return f"added {videos_added} Videos to Queue"
return None
@ -259,7 +263,7 @@ def rescan_filesystem(self):
handler = Scanner(task=self)
handler.scan()
handler.apply()
ThumbValidator(task=self).validate()
thumbnail_check.delay()
@shared_task(bind=True, name="thumbnail_check", base=BaseTask)

View File

@ -79,7 +79,9 @@ class Comments:
def get_yt_comments(self):
"""get comments from youtube"""
yt_obs = self.build_yt_obs()
info_json = YtWrap(yt_obs, config=self.config).extract(self.youtube_id)
info_json, _ = YtWrap(yt_obs, config=self.config).extract(
self.youtube_id
)
if not info_json:
return False, False

View File

@ -10,10 +10,10 @@ from datetime import datetime
import requests
from channel.src import index as ta_channel
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import ElasticWrap
from common.src.helper import get_duration_sec, get_duration_str, randomizor
from common.src.index_generic import YouTubeItem
from django.conf import settings
from download.src.thumbnails import ThumbManager
from playlist.src import index as ta_playlist
from ryd_client import ryd_client
from user.src.user_config import UserConfig
@ -394,12 +394,6 @@ class YoutubeVideo(YouTubeItem, YoutubeSubtitle):
return subtitles
def update_media_url(self):
"""update only media_url in es for reindex channel rename"""
data = {"doc": {"media_url": self.json_data["media_url"]}}
path = f"{self.index_name}/_update/{self.youtube_id}"
_, _ = ElasticWrap(path).post(data=data)
def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
"""combined classes to create new video in index"""
@ -409,5 +403,8 @@ def index_new_video(youtube_id, video_type=VideoTypeEnum.VIDEOS):
raise ValueError("failed to get metadata for " + youtube_id)
video.check_subtitles()
url = video.json_data["vid_thumb_url"]
ThumbManager(item_id=video.youtube_id).download_video_thumb(url=url)
video.upload_to_es()
return video.json_data

View File

@ -227,7 +227,8 @@ class VideoProgressView(ApiBaseView):
expire = False
current_progress.update({"watched": watched})
redis_con.set_message(key, current_progress, expire=expire)
if position > 5:
redis_con.set_message(key, current_progress, expire=expire)
response_serializer = PlayerSerializer(current_progress)

View File

@ -9,6 +9,15 @@ else
LOGLEVEL="INFO"
fi
# update yt-dlp if needed
if [[ "${TA_AUTO_UPDATE_YTDLP,,}" =~ ^(release|nightly)$ ]]; then
echo "Updating yt-dlp..."
preflag=$([[ "${TA_AUTO_UPDATE_YTDLP,,}" == "nightly" ]] && echo "--pre" || echo "")
python -m pip install --target=/root/.local/bin --upgrade $preflag "yt-dlp[default]" || {
echo "yt-dlp update failed"
}
fi
# stop on pending manual migration
python manage.py ta_stop_on_error

View File

@ -11,24 +11,24 @@
"dompurify": "^3.2.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"zustand": "^5.0.5"
"react-router-dom": "^7.6.3",
"zustand": "^5.0.6"
},
"devDependencies": {
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react-swc": "^3.10.0",
"eslint": "^9.27.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"prettier": "3.5.3",
"globals": "^16.3.0",
"prettier": "3.6.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"vite": ">=6.3.5",
"vite": ">=7.0.0",
"vite-plugin-checker": "^0.9.3"
}
},
@ -512,9 +512,9 @@
}
},
"node_modules/@eslint/config-array": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -527,9 +527,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -551,9 +551,9 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz",
"integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==",
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@ -598,9 +598,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -635,9 +635,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz",
"integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==",
"version": "9.30.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz",
"integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==",
"dev": true,
"license": "MIT",
"engines": {
@ -776,16 +776,16 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
"integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==",
"version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz",
"integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
"integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz",
"integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==",
"cpu": [
"arm"
],
@ -797,9 +797,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz",
"integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz",
"integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==",
"cpu": [
"arm64"
],
@ -811,9 +811,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz",
"integrity": "sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz",
"integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==",
"cpu": [
"arm64"
],
@ -825,9 +825,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz",
"integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz",
"integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==",
"cpu": [
"x64"
],
@ -839,9 +839,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz",
"integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz",
"integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==",
"cpu": [
"arm64"
],
@ -853,9 +853,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz",
"integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz",
"integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==",
"cpu": [
"x64"
],
@ -867,9 +867,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz",
"integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz",
"integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==",
"cpu": [
"arm"
],
@ -881,9 +881,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz",
"integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz",
"integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==",
"cpu": [
"arm"
],
@ -895,9 +895,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz",
"integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz",
"integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==",
"cpu": [
"arm64"
],
@ -909,9 +909,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz",
"integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz",
"integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==",
"cpu": [
"arm64"
],
@ -923,9 +923,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz",
"integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz",
"integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==",
"cpu": [
"loong64"
],
@ -937,9 +937,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz",
"integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz",
"integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==",
"cpu": [
"ppc64"
],
@ -951,9 +951,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz",
"integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz",
"integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==",
"cpu": [
"riscv64"
],
@ -965,9 +965,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz",
"integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz",
"integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==",
"cpu": [
"riscv64"
],
@ -979,9 +979,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz",
"integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz",
"integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==",
"cpu": [
"s390x"
],
@ -993,9 +993,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz",
"integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz",
"integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==",
"cpu": [
"x64"
],
@ -1007,9 +1007,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz",
"integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz",
"integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==",
"cpu": [
"x64"
],
@ -1021,9 +1021,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz",
"integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz",
"integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==",
"cpu": [
"arm64"
],
@ -1035,9 +1035,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz",
"integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz",
"integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==",
"cpu": [
"ia32"
],
@ -1049,9 +1049,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz",
"integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz",
"integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==",
"cpu": [
"x64"
],
@ -1063,15 +1063,15 @@
]
},
"node_modules/@swc/core": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.29.tgz",
"integrity": "sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.9.tgz",
"integrity": "sha512-O+LfT2JlVMsIMWG9x+rdxg8GzpzeGtCZQfXV7cKc1PjIKUkLFf1QJ7okuseA4f/9vncu37dQ2ZcRrPKy0Ndd5g==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.21"
"@swc/types": "^0.1.23"
},
"engines": {
"node": ">=10"
@ -1081,16 +1081,16 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.11.29",
"@swc/core-darwin-x64": "1.11.29",
"@swc/core-linux-arm-gnueabihf": "1.11.29",
"@swc/core-linux-arm64-gnu": "1.11.29",
"@swc/core-linux-arm64-musl": "1.11.29",
"@swc/core-linux-x64-gnu": "1.11.29",
"@swc/core-linux-x64-musl": "1.11.29",
"@swc/core-win32-arm64-msvc": "1.11.29",
"@swc/core-win32-ia32-msvc": "1.11.29",
"@swc/core-win32-x64-msvc": "1.11.29"
"@swc/core-darwin-arm64": "1.12.9",
"@swc/core-darwin-x64": "1.12.9",
"@swc/core-linux-arm-gnueabihf": "1.12.9",
"@swc/core-linux-arm64-gnu": "1.12.9",
"@swc/core-linux-arm64-musl": "1.12.9",
"@swc/core-linux-x64-gnu": "1.12.9",
"@swc/core-linux-x64-musl": "1.12.9",
"@swc/core-win32-arm64-msvc": "1.12.9",
"@swc/core-win32-ia32-msvc": "1.12.9",
"@swc/core-win32-x64-msvc": "1.12.9"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@ -1102,9 +1102,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz",
"integrity": "sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.9.tgz",
"integrity": "sha512-GACFEp4nD6V+TZNR2JwbMZRHB+Yyvp14FrcmB6UCUYmhuNWjkxi+CLnEvdbuiKyQYv0zA+TRpCHZ+whEs6gwfA==",
"cpu": [
"arm64"
],
@ -1119,9 +1119,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz",
"integrity": "sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.12.9.tgz",
"integrity": "sha512-hv2kls7Ilkm2EpeJz+I9MCil7pGS3z55ZAgZfxklEuYsxpICycxeH+RNRv4EraggN44ms+FWCjtZFu0LGg2V3g==",
"cpu": [
"x64"
],
@ -1136,9 +1136,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz",
"integrity": "sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.9.tgz",
"integrity": "sha512-od9tDPiG+wMU9wKtd6y3nYJdNqgDOyLdgRRcrj1/hrbHoUPOM8wZQZdwQYGarw63iLXGgsw7t5HAF9Yc51ilFA==",
"cpu": [
"arm"
],
@ -1153,9 +1153,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz",
"integrity": "sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.9.tgz",
"integrity": "sha512-6qx1ka9LHcLzxIgn2Mros+CZLkHK2TawlXzi/h7DJeNnzi8F1Hw0Yzjp8WimxNCg6s2n+o3jnmin1oXB7gg8rw==",
"cpu": [
"arm64"
],
@ -1170,9 +1170,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz",
"integrity": "sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.9.tgz",
"integrity": "sha512-yghFZWKPVVGbUdqiD7ft23G0JX6YFGDJPz9YbLLAwGuKZ9th3/jlWoQDAw1Naci31LQhVC+oIji6ozihSuwB2A==",
"cpu": [
"arm64"
],
@ -1187,9 +1187,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz",
"integrity": "sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.9.tgz",
"integrity": "sha512-SFUxyhWLZRNL8QmgGNqdi2Q43PNyFVkRZ2zIif30SOGFSxnxcf2JNeSeBgKIGVgaLSuk6xFVVCtJ3KIeaStgRg==",
"cpu": [
"x64"
],
@ -1204,9 +1204,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz",
"integrity": "sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.9.tgz",
"integrity": "sha512-9FB0wM+6idCGTI20YsBNBg9xSWtkDBymnpaTCsZM3qDc0l4uOpJMqbfWhQvp17x7r/ulZfb2QY8RDvQmCL6AcQ==",
"cpu": [
"x64"
],
@ -1221,9 +1221,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz",
"integrity": "sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.9.tgz",
"integrity": "sha512-zHOusMVbOH9ik5RtRrMiGzLpKwxrPXgXkBm3SbUCa65HAdjV33NZ0/R9Rv1uPESALtEl2tzMYLUxYA5ECFDFhA==",
"cpu": [
"arm64"
],
@ -1238,9 +1238,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz",
"integrity": "sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.9.tgz",
"integrity": "sha512-aWZf0PqE0ot7tCuhAjRkDFf41AzzSQO0x2xRfTbnhpROp57BRJ/N5eee1VULO/UA2PIJRG7GKQky5bSGBYlFug==",
"cpu": [
"ia32"
],
@ -1255,9 +1255,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.11.29",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz",
"integrity": "sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g==",
"version": "1.12.9",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.9.tgz",
"integrity": "sha512-C25fYftXOras3P3anSUeXXIpxmEkdAcsIL9yrr0j1xepTZ/yKwpnQ6g3coj8UXdeJy4GTVlR6+Ow/QiBgZQNOg==",
"cpu": [
"x64"
],
@ -1279,9 +1279,9 @@
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz",
"integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==",
"version": "0.1.23",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz",
"integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -1289,9 +1289,9 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
@ -1303,9 +1303,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.5",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
"integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==",
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@ -1313,9 +1313,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "19.1.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz",
"integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==",
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -1533,23 +1533,23 @@
}
},
"node_modules/@vitejs/plugin-react-swc": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.0.tgz",
"integrity": "sha512-ZmkdHw3wo/o/Rk05YsXZs/DJAfY2CdQ5DUAjoWji+PEr+hYADdGMCGgEAILbiKj+CjspBTuTACBcWDrmC8AUfw==",
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.10.2.tgz",
"integrity": "sha512-xD3Rdvrt5LgANug7WekBn1KhcvLn1H3jNBfJRL3reeOIua/WnZOEV5qi5qIBq5T8R0jUDmRtxuvk4bPhzGHDWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.9",
"@swc/core": "^1.11.22"
"@rolldown/pluginutils": "1.0.0-beta.11",
"@swc/core": "^1.11.31"
},
"peerDependencies": {
"vite": "^4 || ^5 || ^6"
"vite": "^4 || ^5 || ^6 || ^7.0.0-beta.0"
}
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@ -1630,9 +1630,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1722,6 +1722,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1833,19 +1842,19 @@
}
},
"node_modules/eslint": {
"version": "9.27.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz",
"integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==",
"version": "9.30.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz",
"integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.1",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.14.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.27.0",
"@eslint/js": "9.30.1",
"@eslint/plugin-kit": "^0.3.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@ -1857,9 +1866,9 @@
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.3.0",
"eslint-visitor-keys": "^4.2.0",
"espree": "^10.3.0",
"eslint-scope": "^8.4.0",
"eslint-visitor-keys": "^4.2.1",
"espree": "^10.4.0",
"esquery": "^1.5.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
@ -1933,9 +1942,9 @@
}
},
"node_modules/eslint-scope": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
@ -1963,9 +1972,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1974,9 +1983,9 @@
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@ -2000,15 +2009,15 @@
}
},
"node_modules/espree": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.14.0",
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^4.2.0"
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2018,9 +2027,9 @@
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
@ -2230,9 +2239,9 @@
}
},
"node_modules/globals": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz",
"integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==",
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz",
"integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -2631,9 +2640,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@ -2651,7 +2660,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.8",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -2670,9 +2679,9 @@
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
@ -2738,9 +2747,9 @@
}
},
"node_modules/react-router": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz",
"integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==",
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz",
"integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@ -2760,12 +2769,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.0.tgz",
"integrity": "sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==",
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz",
"integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.6.0"
"react-router": "7.6.3"
},
"engines": {
"node": ">=20.0.0"
@ -2775,15 +2784,6 @@
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -2820,13 +2820,13 @@
}
},
"node_modules/rollup": {
"version": "4.39.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.39.0.tgz",
"integrity": "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==",
"version": "4.44.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz",
"integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.7"
"@types/estree": "1.0.8"
},
"bin": {
"rollup": "dist/bin/rollup"
@ -2836,26 +2836,26 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.39.0",
"@rollup/rollup-android-arm64": "4.39.0",
"@rollup/rollup-darwin-arm64": "4.39.0",
"@rollup/rollup-darwin-x64": "4.39.0",
"@rollup/rollup-freebsd-arm64": "4.39.0",
"@rollup/rollup-freebsd-x64": "4.39.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.39.0",
"@rollup/rollup-linux-arm-musleabihf": "4.39.0",
"@rollup/rollup-linux-arm64-gnu": "4.39.0",
"@rollup/rollup-linux-arm64-musl": "4.39.0",
"@rollup/rollup-linux-loongarch64-gnu": "4.39.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.39.0",
"@rollup/rollup-linux-riscv64-gnu": "4.39.0",
"@rollup/rollup-linux-riscv64-musl": "4.39.0",
"@rollup/rollup-linux-s390x-gnu": "4.39.0",
"@rollup/rollup-linux-x64-gnu": "4.39.0",
"@rollup/rollup-linux-x64-musl": "4.39.0",
"@rollup/rollup-win32-arm64-msvc": "4.39.0",
"@rollup/rollup-win32-ia32-msvc": "4.39.0",
"@rollup/rollup-win32-x64-msvc": "4.39.0",
"@rollup/rollup-android-arm-eabi": "4.44.1",
"@rollup/rollup-android-arm64": "4.44.1",
"@rollup/rollup-darwin-arm64": "4.44.1",
"@rollup/rollup-darwin-x64": "4.44.1",
"@rollup/rollup-freebsd-arm64": "4.44.1",
"@rollup/rollup-freebsd-x64": "4.44.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.44.1",
"@rollup/rollup-linux-arm-musleabihf": "4.44.1",
"@rollup/rollup-linux-arm64-gnu": "4.44.1",
"@rollup/rollup-linux-arm64-musl": "4.44.1",
"@rollup/rollup-linux-loongarch64-gnu": "4.44.1",
"@rollup/rollup-linux-powerpc64le-gnu": "4.44.1",
"@rollup/rollup-linux-riscv64-gnu": "4.44.1",
"@rollup/rollup-linux-riscv64-musl": "4.44.1",
"@rollup/rollup-linux-s390x-gnu": "4.44.1",
"@rollup/rollup-linux-x64-gnu": "4.44.1",
"@rollup/rollup-linux-x64-musl": "4.44.1",
"@rollup/rollup-win32-arm64-msvc": "4.44.1",
"@rollup/rollup-win32-ia32-msvc": "4.44.1",
"@rollup/rollup-win32-x64-msvc": "4.44.1",
"fsevents": "~2.3.2"
}
},
@ -2991,9 +2991,9 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
"integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3135,24 +3135,24 @@
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz",
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"fdir": "^6.4.6",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@ -3161,14 +3161,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "*",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@ -3285,9 +3285,9 @@
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
"integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -3359,9 +3359,9 @@
}
},
"node_modules/zustand": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
"integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"

View File

@ -14,24 +14,24 @@
"dompurify": "^3.2.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0",
"zustand": "^5.0.5"
"react-router-dom": "^7.6.3",
"zustand": "^5.0.6"
},
"devDependencies": {
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react-swc": "^3.10.0",
"eslint": "^9.27.0",
"@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"prettier": "3.5.3",
"globals": "^16.3.0",
"prettier": "3.6.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"vite": ">=6.3.5",
"vite": ">=7.0.0",
"vite-plugin-checker": "^0.9.3"
}
}

View File

@ -6,10 +6,10 @@ type AppriseTaskNameType =
| 'download_pending'
| 'check_reindex';
const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType) => {
const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
return APIClient('/api/task/notification/', {
method: 'DELETE',
body: { task_name: taskName },
body: { task_name: taskName, url: url },
});
};

View File

@ -2,9 +2,15 @@ import APIClient from '../../functions/APIClient';
type FilterType = 'ignore' | 'pending';
const deleteDownloadQueueByFilter = async (filter: FilterType) => {
const deleteDownloadQueueByFilter = async (
filter: FilterType,
channel: string | null,
vid_type: string | null,
) => {
const searchParams = new URLSearchParams();
if (filter) searchParams.append('filter', filter);
if (channel) searchParams.append('channel', channel);
if (vid_type) searchParams.append('vid_type', vid_type);
return APIClient(`/api/download/?${searchParams.toString()}`, {
method: 'DELETE',

View File

@ -1,6 +1,6 @@
import APIClient from '../../functions/APIClient';
const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean) => {
const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean, flat: boolean) => {
const urls = [];
const containsMultiple = youtubeIdStrings.includes('\n');
@ -16,12 +16,12 @@ const updateDownloadQueue = async (youtubeIdStrings: string, autostart: boolean)
urls.push({ youtube_id: youtubeIdStrings, status: 'pending' });
}
let params = '';
if (autostart) {
params = '?autostart=true';
}
const searchParams = new URLSearchParams();
if (autostart) searchParams.append('autostart', 'true');
if (flat) searchParams.append('flat', 'true');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;
return APIClient(`/api/download/${params}`, {
return APIClient(endpoint, {
method: 'POST',
body: { data: [...urls] },
});

View File

@ -0,0 +1,23 @@
import APIClient from '../../functions/APIClient';
type FilterType = 'ignore' | 'pending';
export type DownloadQueueStatus = 'ignore' | 'pending' | 'priority';
const updateDownloadQueueByFilter = async (
filter: FilterType,
channel: string | null,
vid_type: string | null,
status: DownloadQueueStatus,
) => {
const searchParams = new URLSearchParams();
if (filter) searchParams.append('filter', filter);
if (channel) searchParams.append('channel', channel);
if (vid_type) searchParams.append('vid_type', vid_type);
return APIClient(`/api/download/?${searchParams.toString()}`, {
method: 'PATCH',
body: { status: status },
});
};
export default updateDownloadQueueByFilter;

View File

@ -0,0 +1,10 @@
import APIClient from '../../functions/APIClient';
const updatePlaylistSortOrder = async (playlistId: string, newSortOrder: 'top' | 'bottom') => {
return APIClient(`/api/playlist/${playlistId}/`, {
method: 'POST',
body: { playlist_sort_order: newSortOrder },
});
};
export default updatePlaylistSortOrder;

View File

@ -6,6 +6,7 @@ export type AppSettingsConfigType = {
live_channel_size: number | null;
shorts_channel_size: number | null;
auto_start: boolean;
extract_flat: boolean;
};
downloads: {
limit_speed: number | null;

View File

@ -1,11 +1,21 @@
import APIClient from '../../functions/APIClient';
import { DownloadResponseType } from '../../pages/Download';
const loadDownloadQueue = async (page: number, channelId: string | null, showIgnored: boolean) => {
const loadDownloadQueue = async (
page: number,
channelId: string | null,
vid_type: string | null,
errorFilterFromUrl: string | null,
showIgnored: boolean,
search: string,
) => {
const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString());
if (channelId) searchParams.append('channel', channelId);
if (vid_type) searchParams.append('vid_type', vid_type);
if (search) searchParams.append('q', encodeURIComponent(search));
if (errorFilterFromUrl !== null) searchParams.append('error', errorFilterFromUrl);
searchParams.append('filter', showIgnored ? 'ignore' : 'pending');
const endpoint = `/api/download/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`;

View File

@ -14,6 +14,7 @@ export type PlaylistType = {
playlist_channel_id: string;
playlist_description: string;
playlist_entries: PlaylistEntryType[];
playlist_sort_order: 'top' | 'bottom';
playlist_id: string;
playlist_last_refresh: string;
playlist_name: string;

View File

@ -37,9 +37,10 @@ export type CommentsType = {
type CommentBoxProps = {
comment: CommentsType;
onTimestampClick?: (seconds: number) => void;
};
const CommentBox = ({ comment }: CommentBoxProps) => {
const CommentBox = ({ comment, onTimestampClick }: CommentBoxProps) => {
const [showSubComments, setShowSubComments] = useState(false);
const hasSubComments =
@ -51,7 +52,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => {
{comment.comment_author}
</h3>
<p>
<Linkify>{comment.comment_text}</Linkify>
<Linkify onTimestampClick={onTimestampClick}>{comment.comment_text}</Linkify>
</p>
<div className="comment-meta">
@ -95,7 +96,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => {
comment.comment_replies?.map(comment => {
return (
<Fragment key={comment.comment_id}>
<CommentBox comment={comment} />
<CommentBox comment={comment} onTimestampClick={onTimestampClick} />
</Fragment>
);
})}

View File

@ -47,14 +47,19 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {
{!download.channel_indexed && <span>{download.channel_name}</span>}
<a href={`https://www.youtube.com/watch?v=${download.youtube_id}`} target="_blank">
<a
href={`https://www.youtube.com/watch?v=${download.youtube_id}`}
target="_blank"
rel="noopener noreferrer"
>
<h3>{download.title}</h3>
</a>
</div>
<p>
Published: {formatDate(download.published)} | Duration: {download.duration} |{' '}
{download.youtube_id}
{download.published && <span>Published: {formatDate(download.published)} | </span>}
<span>Duration: {download.duration} | </span>
<span>{download.youtube_id}</span>
</p>
{download.message && <p className="danger-zone">{download.message}</p>}

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import LoadingIndicator from './LoadingIndicator';
type InputTextProps = {
type: 'text' | 'number';
@ -51,13 +52,7 @@ const InputConfig = ({ type, name, value, setValue, oldValue, updateCallback }:
</>
)}
{oldValue !== null && <button onClick={() => handleUpdate(name, null)}>reset</button>}
{loading && (
<>
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
</>
)}
{loading && <LoadingIndicator />}
{success && <span></span>}
</div>
</div>

View File

@ -3,17 +3,61 @@ import DOMPurify from 'dompurify';
type LinkifyProps = {
children: string;
ignoreLineBreak?: boolean;
onTimestampClick?: (seconds: number) => void;
};
// source: https://www.js-craft.io/blog/react-detect-url-text-convert-link/
const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => {
const Linkify = ({ children, ignoreLineBreak = false, onTimestampClick }: LinkifyProps) => {
const isUrl = (word: string) => {
const urlPattern = /(https?:\/\/[^\s]+)/g;
return word.match(urlPattern);
};
const isTimestamp = (word: string) => {
const timestampPattern = /^(\d{1,}:)?(\d{1,2}):(\d{1,2})$/;
return word.match(timestampPattern);
};
const parseTimestamp = (timestamp: string): number => {
const parts = timestamp.split(':').map(part => parseInt(part, 10));
if (parts.length === 2) {
// MM:SS format
const [minutes, seconds] = parts;
return minutes * 60 + seconds;
}
if (parts.length === 3) {
// HH:MM:SS format
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
return 0;
};
const addMarkup = (word: string) => {
return isUrl(word) ? `<a href="${word}">${word}</a>` : word;
if (isUrl(word)) {
return `<a href="${word}" rel="noopener noreferrer" target="_blank">${word}</a>`;
}
if (isTimestamp(word) && onTimestampClick) {
const seconds = parseTimestamp(word);
return `<span class="timestamp-link" data-timestamp="${seconds}">${word}</span>`;
}
return word;
};
const handleClick = (event: React.MouseEvent<HTMLSpanElement>) => {
const target = event.target as HTMLElement;
if (target.classList.contains('timestamp-link') && onTimestampClick) {
const timestamp = target.getAttribute('data-timestamp');
if (timestamp) {
const seconds = parseInt(timestamp, 10);
onTimestampClick(seconds);
}
}
};
let workingText = children;
@ -26,9 +70,11 @@ const Linkify = ({ children, ignoreLineBreak = false }: LinkifyProps) => {
const formatedWords = words.map(w => addMarkup(w));
const html = DOMPurify.sanitize(formatedWords.join(' '));
const html = DOMPurify.sanitize(formatedWords.join(' '), {
ADD_ATTR: ['target'],
});
return <span dangerouslySetInnerHTML={{ __html: html }} />;
return <span dangerouslySetInnerHTML={{ __html: html }} onClick={handleClick} />;
};
export default Linkify;

View File

@ -0,0 +1,9 @@
const LoadingIndicator = () => {
return (
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
);
};
export default LoadingIndicator;

View File

@ -95,8 +95,8 @@ const SearchExampleQueries = () => {
<span>full:</span> search in video subtitles
<ul>
<li>
<span>lang:</span> subtitles language (use two-letter ISO country code, same as
the one from settings page)
<span>lang:</span> subtitles language (use two-letter ISO 639 language code,
same as the one from settings page)
</li>
<li>
<span>source:</span>

View File

@ -6,6 +6,11 @@ import humanFileSize from '../functions/humanFileSize';
import { FileSizeUnits } from '../api/actions/updateUserConfig';
import { useUserConfigStore } from '../stores/UserConfigStore';
const StreamsTypeEmun = {
Video: 'video',
Audio: 'audio',
};
type VideoListItemProps = {
videoList: VideoType[] | undefined;
viewStyle: ViewStylesType;
@ -35,7 +40,8 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
<tbody>
{videoList?.map(({ youtube_id, title, channel, vid_type, media_size, streams }) => {
const [videoStream, audioStream] = streams;
const videoStream = streams?.find(s => s.type === StreamsTypeEmun.Video);
const audioStream = streams?.find(s => s.type === StreamsTypeEmun.Audio);
return (
<tr key={youtube_id}>
@ -46,12 +52,12 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => {
<Link to={Routes.Video(youtube_id)}>{title}</Link>
</td>
<td>{vid_type}</td>
<td>{`${videoStream.width}x${videoStream.height}`}</td>
<td>{`${videoStream?.width || '-'}x${videoStream?.height || '-'}`}</td>
<td>{humanFileSize(media_size, useSiUnits)}</td>
<td>{videoStream.codec}</td>
<td>{humanFileSize(videoStream.bitrate, useSiUnits)}</td>
<td>{audioStream.codec}</td>
<td>{humanFileSize(audioStream.bitrate, useSiUnits)}</td>
<td>{videoStream?.codec || '-'}</td>
<td>{humanFileSize(videoStream?.bitrate || 0, useSiUnits)}</td>
<td>{audioStream?.codec || '-'}</td>
<td>{humanFileSize(audioStream?.bitrate || 0, useSiUnits)}</td>
</tr>
);
})}

View File

@ -111,6 +111,8 @@ type VideoPlayerProps = {
autoplay?: boolean;
onWatchStateChanged?: (status: boolean) => void;
onVideoEnd?: () => void;
seekToTimestamp?: number;
setSeekToTimestamp?: (timestamp: number | undefined) => void;
};
const VideoPlayer = ({
@ -120,9 +122,30 @@ const VideoPlayer = ({
autoplay = false,
onWatchStateChanged,
onVideoEnd,
seekToTimestamp,
setSeekToTimestamp,
}: VideoPlayerProps) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
if (seekToTimestamp === undefined || !videoRef.current) {
return;
}
const videoDuration = videoRef.current.duration;
if (isNaN(videoDuration) || seekToTimestamp > videoDuration) {
return;
}
videoRef.current.currentTime = seekToTimestamp;
if (videoRef.current.paused) {
videoRef.current.play();
}
if (setSeekToTimestamp) setSeekToTimestamp(undefined);
window.scroll(0, 0);
}, [seekToTimestamp]);
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
@ -140,6 +163,8 @@ const VideoPlayer = ({
const [showHelpDialog, setShowHelpDialog] = useState(false);
const [showInfoDialog, setShowInfoDialog] = useState(false);
const [infoDialogContent, setInfoDialogContent] = useState('');
const [isTheaterMode, setIsTheaterMode] = useState(false);
const [theaterModeKeyPressed, setTheaterModeKeyPressed] = useState(false);
const questionmarkPressed = useKeyPress('?');
const mutePressed = useKeyPress('m');
@ -151,6 +176,8 @@ const VideoPlayer = ({
const arrowRightPressed = useKeyPress('ArrowRight');
const arrowLeftPressed = useKeyPress('ArrowLeft');
const pPausedPressed = useKeyPress('p');
const theaterModePressed = useKeyPress('t');
const escapePressed = useKeyPress('Escape');
const videoId = video.youtube_id;
const videoUrl = video.media_url;
@ -345,10 +372,42 @@ const VideoPlayer = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [questionmarkPressed]);
useEffect(() => {
if (embed) {
return;
}
if (theaterModePressed && !theaterModeKeyPressed) {
setTheaterModeKeyPressed(true);
const newTheaterMode = !isTheaterMode;
setIsTheaterMode(newTheaterMode);
infoDialog(newTheaterMode ? 'Theater mode' : 'Normal mode');
} else if (!theaterModePressed) {
setTheaterModeKeyPressed(false);
}
}, [theaterModePressed, isTheaterMode, theaterModeKeyPressed]);
useEffect(() => {
if (embed) {
return;
}
if (escapePressed && isTheaterMode) {
setIsTheaterMode(false);
infoDialog('Normal mode');
}
}, [escapePressed, isTheaterMode]);
return (
<>
<div id="player" className={embed ? '' : 'player-wrapper'}>
<div className={embed ? '' : 'video-main'}>
<div
id="player"
className={embed ? '' : `player-wrapper ${isTheaterMode ? 'theater-mode' : ''}`}
>
<div className={embed ? '' : `video-main ${isTheaterMode ? 'theater-mode' : ''}`}>
<video
ref={videoRef}
key={`${getApiUrl()}${videoUrl}`}
@ -423,6 +482,18 @@ const VideoPlayer = ({
<td>Toggle fullscreen</td>
<td>f</td>
</tr>
{!embed && (
<>
<tr>
<td>Toggle theater mode</td>
<td>t</td>
</tr>
<tr>
<td>Exit theater mode</td>
<td>Esc</td>
</tr>
</>
)}
<tr>
<td>Toggle subtitles (if available)</td>
<td>c</td>
@ -467,11 +538,19 @@ const VideoPlayer = ({
<h4>
This video doesn't have any sponsor segments added. To add a segment go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
<a
href={`https://www.youtube.com/watch?v=${videoId}`}
target="_blank"
rel="noopener noreferrer"
>
this video on YouTube
</a>
</u>{' '}
and add a segment using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
<a href="https://sponsor.ajay.app/" target="_blank" rel="noopener noreferrer">
SponsorBlock
</a>
</u>{' '}
extension.
</h4>
@ -480,11 +559,19 @@ const VideoPlayer = ({
<h4>
This video has unlocked sponsor segments. Go to{' '}
<u>
<a href={`https://www.youtube.com/watch?v=${videoId}`}>this video on YouTube</a>
<a
href={`https://www.youtube.com/watch?v=${videoId}`}
target="_blank"
rel="noopener noreferrer"
>
this video on YouTube
</a>
</u>{' '}
and vote on the segments using the{' '}
<u>
<a href="https://sponsor.ajay.app/">SponsorBlock</a>
<a href="https://sponsor.ajay.app/" target="_blank" rel="noopener noreferrer">
SponsorBlock
</a>
</u>{' '}
extension.
</h4>

View File

@ -1,6 +1,7 @@
import iconUnseen from '/img/icon-unseen.svg';
import iconSeen from '/img/icon-seen.svg';
import { useEffect, useState } from 'react';
import LoadingIndicator from './LoadingIndicator';
type WatchedCheckBoxProps = {
watched: boolean;
@ -32,9 +33,7 @@ const WatchedCheckBox = ({ watched, onClick, onDone }: WatchedCheckBoxProps) =>
<>
{loading && (
<>
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
<LoadingIndicator />
</>
)}
{!loading && watched && (

View File

@ -6,7 +6,7 @@ import getCookie from './getCookie';
import Routes from '../configuration/routes/RouteList';
export interface ApiClientOptions extends Omit<RequestInit, 'body'> {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: Record<string, unknown> | string;
}

View File

@ -130,7 +130,11 @@ const ChannelAbout = () => {
{channel.channel_active && (
<p>
Youtube:{' '}
<a href={`https://www.youtube.com/channel/${channel.channel_id}`} target="_blank">
<a
href={`https://www.youtube.com/channel/${channel.channel_id}`}
target="_blank"
rel="noopener noreferrer"
>
Active
</a>
</p>
@ -316,7 +320,7 @@ const ChannelAbout = () => {
<div>
<p>
Overwrite{' '}
<a href="https://sponsor.ajay.app/" target="_blank">
<a href="https://sponsor.ajay.app/" target="_blank" rel="noopener noreferrer">
SponsorBlock
</a>
</p>

View File

@ -97,104 +97,97 @@ const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
videoId,
]);
if (!channel) {
return (
<div className="boxed-content">
<br />
<h2>Channel {channelId} not found!</h2>
</div>
);
}
return (
<>
<title>{`TA | Channel: ${channel.channel_name}`}</title>
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="info-box info-box-2">
<ChannelOverview
channelId={channel.channel_id}
channelname={channel.channel_name}
channelSubs={channel.channel_subs}
channelSubscribed={channel.channel_subscribed}
channelThumbUrl={channel.channel_thumb_url}
setRefresh={setRefresh}
/>
<div className="info-box-item">
{videoAggs && (
<>
<p>
{videoAggs.total_items.value} videos <span className="space-carrot">|</span>{' '}
{videoAggs.total_duration.value_str} playback{' '}
<span className="space-carrot">|</span> Total size{' '}
{humanFileSize(videoAggs.total_size.value, useSiUnits)}
</p>
<div className="button-box">
<Button
label="Mark as watched"
id="watched-button"
type="button"
title={`Mark all videos from ${channel.channel_name} as watched`}
onClick={async () => {
await updateWatchedState({
id: channel.channel_id,
is_watched: true,
});
channel && (
<>
<title>{`TA | Channel: ${channel.channel_name}`}</title>
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="info-box info-box-2">
<ChannelOverview
channelId={channel.channel_id}
channelname={channel.channel_name}
channelSubs={channel.channel_subs}
channelSubscribed={channel.channel_subscribed}
channelThumbUrl={channel.channel_thumb_url}
setRefresh={setRefresh}
/>
<div className="info-box-item">
{videoAggs && (
<>
<p>
{videoAggs.total_items.value} videos <span className="space-carrot">|</span>{' '}
{videoAggs.total_duration.value_str} playback{' '}
<span className="space-carrot">|</span> Total size{' '}
{humanFileSize(videoAggs.total_size.value, useSiUnits)}
</p>
<div className="button-box">
<Button
label="Mark as watched"
id="watched-button"
type="button"
title={`Mark all videos from ${channel.channel_name} as watched`}
onClick={async () => {
await updateWatchedState({
id: channel.channel_id,
is_watched: true,
});
setRefresh(true);
}}
/>{' '}
<Button
label="Mark as unwatched"
id="unwatched-button"
type="button"
title={`Mark all videos from ${channel.channel_name} as unwatched`}
onClick={async () => {
await updateWatchedState({
id: channel.channel_id,
is_watched: false,
});
setRefresh(true);
}}
/>{' '}
<Button
label="Mark as unwatched"
id="unwatched-button"
type="button"
title={`Mark all videos from ${channel.channel_name} as unwatched`}
onClick={async () => {
await updateWatchedState({
id: channel.channel_id,
is_watched: false,
});
setRefresh(true);
}}
/>
</div>
</>
)}
setRefresh(true);
}}
/>
</div>
</>
)}
</div>
</div>
</div>
</div>
<div className={`boxed-content ${gridView}`}>
<Filterbar
hideToggleText={'Hide watched videos:'}
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
/>
</div>
<EmbeddableVideoPlayer videoId={videoId} />
<div className={`boxed-content ${gridView}`}>
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
{!hasVideos && (
<>
<h2>No videos found...</h2>
<p>
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the scan
and download tasks.
</p>
</>
)}
<VideoList videoList={videoList} viewStyle={viewStyle} refreshVideoList={setRefresh} />
<div className={`boxed-content ${gridView}`}>
<Filterbar
hideToggleText={'Hide watched videos:'}
viewStyle={ViewStyleNames.Home as ViewStyleNamesType}
/>
</div>
</div>
{pagination && (
<div className="boxed-content">
<Pagination pagination={pagination} setPage={setCurrentPage} />
<EmbeddableVideoPlayer videoId={videoId} />
<div className={`boxed-content ${gridView}`}>
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
{!hasVideos && (
<>
<h2>No videos found...</h2>
<p>
Try going to the <Link to={Routes.Downloads}>downloads page</Link> to start the
scan and download tasks.
</p>
</>
)}
<VideoList videoList={videoList} viewStyle={viewStyle} refreshVideoList={setRefresh} />
</div>
</div>
)}
</>
{pagination && (
<div className="boxed-content">
<Pagination pagination={pagination} setPage={setCurrentPage} />
</div>
)}
</>
)
);
};

View File

@ -21,6 +21,10 @@ import loadDownloadAggs, { DownloadAggsType } from '../api/loader/loadDownloadAg
import { useUserConfigStore } from '../stores/UserConfigStore';
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
import { ApiResponseType } from '../functions/APIClient';
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
import updateDownloadQueueByFilter, {
DownloadQueueStatus,
} from '../api/actions/updateDownloadQueueByFilter';
type Download = {
auto_start: boolean;
@ -29,9 +33,9 @@ type Download = {
channel_name: string;
duration: string;
message?: string;
published: string;
published: string | null;
status: string;
timestamp: number;
timestamp: number | null;
title: string;
vid_thumb_url: string;
vid_type: string;
@ -53,9 +57,16 @@ const Download = () => {
const channelFilterFromUrl = searchParams.get('channel');
const ignoredOnlyParam = searchParams.get('ignored');
const vidTypeFilterFromUrl = searchParams.get('vid-type');
const errorFilterFromUrl = searchParams.get('error');
const [refresh, setRefresh] = useState(false);
const [showHiddenForm, setShowHiddenForm] = useState(false);
const [addAsAutoStart, setAddAsAutoStart] = useState(false);
const [addAsFlat, setAddAsFlat] = useState(false);
const [showQueueActions, setShowQueueActions] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [downloadPending, setDownloadPending] = useState(false);
const [rescanPending, setRescanPending] = useState(false);
@ -85,6 +96,9 @@ const Download = () => {
const gridItems = userConfig.grid_items;
const showIgnored =
ignoredOnlyParam !== null ? ignoredOnlyParam === 'true' : userConfig.show_ignored_only;
const showIgnoredFilter = showIgnored ? 'ignore' : 'pending';
const isGridView = viewStyle === ViewStylesEnum.Grid;
const gridView = isGridView ? `boxed-${gridItems}` : '';
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
@ -104,7 +118,10 @@ const Download = () => {
const videosResponse = await loadDownloadQueue(
currentPage,
channelFilterFromUrl,
vidTypeFilterFromUrl,
errorFilterFromUrl,
showIgnored,
searchInput,
);
const { data: channelResponseData } = videosResponse ?? {};
const videoCount = channelResponseData?.paginate?.total_hits;
@ -123,7 +140,14 @@ const Download = () => {
useEffect(() => {
setRefresh(true);
}, [channelFilterFromUrl, currentPage, showIgnored]);
}, [
channelFilterFromUrl,
vidTypeFilterFromUrl,
errorFilterFromUrl,
currentPage,
showIgnored,
searchInput,
]);
useEffect(() => {
(async () => {
@ -133,6 +157,16 @@ const Download = () => {
})();
}, [lastVideoCount, showIgnored]);
const handleBulkStatusUpdate = async (status: DownloadQueueStatus) => {
await updateDownloadQueueByFilter(
showIgnoredFilter,
channelFilterFromUrl,
vidTypeFilterFromUrl,
status,
);
setRefresh(true);
};
return (
<>
<title>TA | Downloads</title>
@ -194,6 +228,46 @@ const Download = () => {
{showHiddenForm && (
<div className="show-form">
<div>
<div className="toggle">
<div className="toggleBox">
<input
id="hide_watched"
type="checkbox"
checked={addAsFlat}
onChange={() => setAddAsFlat(!addAsFlat)}
/>
{addAsFlat ? (
<label htmlFor="" className="onbtn">
On
</label>
) : (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
</div>
<span>Fast add</span>
</div>
<div className="toggle">
<div className="toggleBox">
<input
id="hide_watched"
type="checkbox"
checked={addAsAutoStart}
onChange={() => setAddAsAutoStart(!addAsAutoStart)}
/>
{addAsAutoStart ? (
<label htmlFor="" className="onbtn">
On
</label>
) : (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
</div>
<span>Auto Download</span>
</div>
<textarea
value={downloadQueueText}
onChange={e => setDownloadQueueText(e.target.value)}
@ -205,24 +279,13 @@ const Download = () => {
label="Add to queue"
onClick={async () => {
if (downloadQueueText.trim()) {
await updateDownloadQueue(downloadQueueText, false);
await updateDownloadQueue(downloadQueueText, addAsAutoStart, addAsFlat);
setDownloadQueueText('');
setRefresh(true);
setShowHiddenForm(false);
}
}}
/>{' '}
<Button
label="Download now"
onClick={async () => {
if (downloadQueueText.trim()) {
await updateDownloadQueue(downloadQueueText, true);
setDownloadQueueText('');
setRefresh(true);
setShowHiddenForm(false);
}
}}
/>
</div>
</div>
)}
@ -236,7 +299,7 @@ const Download = () => {
id="showIgnored"
onChange={() => {
handleUserConfigUpdate({ show_ignored_only: !showIgnored });
const newParams = new URLSearchParams(searchParams.toString());
const newParams = new URLSearchParams();
newParams.set('ignored', String(!showIgnored));
setSearchParams(newParams);
setRefresh(true);
@ -257,37 +320,9 @@ const Download = () => {
</div>
</div>
<div className="view-icons">
{channelAggsList && channelAggsList.length > 1 && (
<select
name="channel_filter"
id="channel_filter"
value={channelFilterFromUrl || 'all'}
onChange={async event => {
const value = event.currentTarget.value;
const params = searchParams;
if (value !== 'all') {
params.set('channel', value);
} else {
params.delete('channel');
}
setSearchParams(params);
}}
>
<option value="all">all</option>
{channelAggsList.map(channel => {
const [name, id] = channel.key;
const count = channel.doc_count;
return (
<option key={id} value={id}>
{name} ({count})
</option>
);
})}
</select>
)}
<Button onClick={() => setShowQueueActions(!showQueueActions)}>
{showQueueActions ? 'Hide Advanced' : 'Show Advanced'}
</Button>
{isGridView && (
<div className="grid-count">
@ -341,7 +376,147 @@ const Download = () => {
<i>{channel_filter_name}</i>
</>
)}
{vidTypeFilterFromUrl && (
<>
{' - by type '}
<i>{vidTypeFilterFromUrl}</i>
</>
)}
</h3>
{showQueueActions && (
<div className="settings-group">
<h3>Search & Filter</h3>
<select
name="vid_type_filter"
id="vid_type_filter"
value={vidTypeFilterFromUrl || 'all'}
onChange={async event => {
const value = event.currentTarget.value;
const params = searchParams;
if (value !== 'all') {
params.set('vid-type', value);
} else {
params.delete('vid-type');
}
setSearchParams(params);
}}
>
<option value="all">all types</option>
<option value="videos">Videos</option>
<option value="streams">Streams</option>
<option value="shorts">Shorts</option>
</select>
{channelAggsList && channelAggsList.length > 1 && (
<select
name="channel_filter"
id="channel_filter"
value={channelFilterFromUrl || 'all'}
onChange={async event => {
const value = event.currentTarget.value;
const params = searchParams;
if (value !== 'all') {
params.set('channel', value);
} else {
params.delete('channel');
}
setSearchParams(params);
}}
>
<option value="all">all channels</option>
{channelAggsList.map(channel => {
const [name, id] = channel.key;
const count = channel.doc_count;
return (
<option key={id} value={id}>
{name} ({count})
</option>
);
})}
</select>
)}
<select
name="error_filter"
id="error_filter"
value={errorFilterFromUrl || 'all'}
onChange={async event => {
const value = event.currentTarget.value;
const params = searchParams;
if (value !== 'all') {
params.set('error', value);
} else {
params.delete('error');
}
setSearchParams(params);
}}
>
<option value="all">all error state</option>
<option value="true">has error</option>
<option value="false">has no error</option>
</select>
<input
type="text"
placeholder="Search..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
{searchInput && <Button onClick={() => setSearchInput('')}>Clear</Button>}
<h3>Bulk actions</h3>
<p>
Applied filtered by status <i>'{showIgnoredFilter}'</i>
{channelFilterFromUrl && (
<span>
{' '}
and by channel: <i>'{channel_filter_name}'</i>
</span>
)}
{vidTypeFilterFromUrl && (
<span>
{' '}
and by type: <i>'{vidTypeFilterFromUrl}'</i>
</span>
)}
</p>
<div>
{showIgnored ? (
<div className="button-box">
<Button onClick={() => handleBulkStatusUpdate('pending')}>Add to Queue</Button>
</div>
) : (
<div className="button-box">
<Button onClick={() => handleBulkStatusUpdate('ignore')}>Ignore</Button>
<Button onClick={() => handleBulkStatusUpdate('priority')}>Download Now</Button>
</div>
)}
</div>
<div className="button-box">
{showDeleteConfirm ? (
<>
<Button
className="danger-button"
onClick={async () => {
await deleteDownloadQueueByFilter(
showIgnoredFilter,
channelFilterFromUrl,
vidTypeFilterFromUrl,
);
setRefresh(true);
setShowDeleteConfirm(false);
}}
>
Confirm
</Button>
<Button onClick={() => setShowDeleteConfirm(false)}>Cancel</Button>
</>
) : (
<Button onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}>Forget</Button>
)}
</div>
</div>
)}
</div>
<div className={`boxed-content ${gridView}`}>

View File

@ -69,7 +69,7 @@ export type VideoType = {
sponsorblock?: SponsorBlockType;
playlist: string[];
stats: StatsType;
streams: StreamType[];
streams: StreamType[] | undefined;
subtitles: Subtitles[];
tags: string[];
title: string;

View File

@ -5,6 +5,7 @@ import Colours from '../configuration/colours/Colours';
import Button from '../components/Button';
import signIn from '../api/actions/signIn';
import loadAuth from '../api/loader/loadAuth';
import LoadingIndicator from '../components/LoadingIndicator';
const Login = () => {
const navigate = useNavigate();
@ -134,10 +135,7 @@ const Login = () => {
{waitingForBackend && (
<>
<p>
Waiting for backend{' '}
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
Waiting for backend <LoadingIndicator />
</p>
</>
)}

View File

@ -31,6 +31,7 @@ import useIsAdmin from '../functions/useIsAdmin';
import { useUserConfigStore } from '../stores/UserConfigStore';
import { ApiResponseType } from '../functions/APIClient';
import NotFound from './NotFound';
import updatePlaylistSortOrder from '../api/actions/updatePlaylistSortOrder';
export type VideoResponseType = {
data?: VideoType[];
@ -180,6 +181,7 @@ const Playlist = () => {
<a
href={`https://www.youtube.com/playlist?list=${playlist.playlist_id}`}
target="_blank"
rel="noopener noreferrer"
>
Active
</a>
@ -286,6 +288,31 @@ const Playlist = () => {
/>
</div>
)}
<div className="toggle">
<span>Switch sort order:</span>
<div className="toggleBox">
<input
id="playlist_sort_order"
type="checkbox"
checked={playlist.playlist_sort_order === 'bottom'}
onChange={async () => {
const newSortOrder =
playlist.playlist_sort_order === 'top' ? 'bottom' : 'top';
await updatePlaylistSortOrder(playlist.playlist_id, newSortOrder);
setRefresh(true);
}}
/>
{playlist.playlist_sort_order === 'bottom' ? (
<label htmlFor="" className="onbtn">
On
</label>
) : (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
</div>
</div>
</div>
</div>
</div>

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import loadBackupList, { BackupListType } from '../api/loader/loadBackupList';
import SettingsNavigation from '../components/SettingsNavigation';
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
import updateTaskByName from '../api/actions/updateTaskByName';
import queueBackup from '../api/actions/queueBackup';
import restoreBackup from '../api/actions/restoreBackup';
@ -63,32 +62,6 @@ const SettingsActions = () => {
<div className="title-bar">
<h1>Actions</h1>
</div>
<div className="settings-group">
<h2>Delete download queue</h2>
<p>Delete your pending or previously ignored videos from your download queue.</p>
{deleteIgnored && <p>Deleting download queue: ignored</p>}
{!deleteIgnored && (
<Button
label="Delete all ignored"
title="Delete all previously ignored videos from the queue"
onClick={async () => {
await deleteDownloadQueueByFilter('ignore');
setDeleteIgnored(true);
}}
/>
)}{' '}
{deletePending && <p>Deleting download queue: pending</p>}
{!deletePending && (
<Button
label="Delete all queued"
title="Delete all pending videos from the queue"
onClick={async () => {
await deleteDownloadQueueByFilter('pending');
setDeletePending(true);
}}
/>
)}
</div>
<div className="settings-group">
<h2>Manual media files import.</h2>
<p>

View File

@ -42,6 +42,7 @@ const SettingsApplication = () => {
const [livePageSize, setLivePageSize] = useState<number | null>(null);
const [shortPageSize, setShortPageSize] = useState<number | null>(null);
const [isAutostart, setIsAutostart] = useState<boolean>(false);
const [isExtractFlat, setIsExtractFlat] = useState<boolean>(false);
// Downloads
const [currentDownloadSpeed, setCurrentDownloadSpeed] = useState<number | null>(null);
@ -98,6 +99,7 @@ const SettingsApplication = () => {
setLivePageSize(appSettingsConfigData?.subscriptions.live_channel_size || null);
setShortPageSize(appSettingsConfigData?.subscriptions.shorts_channel_size || null);
setIsAutostart(appSettingsConfigData?.subscriptions.auto_start || false);
setIsExtractFlat(appSettingsConfigData?.subscriptions.extract_flat || false);
// Downloads
setCurrentDownloadSpeed(appSettingsConfigData?.downloads.limit_speed || null);
@ -212,6 +214,10 @@ const SettingsApplication = () => {
Autostart automatically starts downloading videos from subscriptions with
priority.
</li>
<li>
Fast add extracts and adds videos in bulk. That is much faster but is not able
to extract as much metadata during adding to the queue.
</li>
</ul>
</div>
)}
@ -264,6 +270,16 @@ const SettingsApplication = () => {
updateCallback={handleUpdateConfig}
/>
</div>
<div className="settings-box-wrapper">
<div>
<p>Fast add</p>
</div>
<ToggleConfig
name="subscriptions.extract_flat"
value={isExtractFlat}
updateCallback={handleUpdateConfig}
/>
</div>
</div>
<div className="info-box-item">
<h2 id="downloads">Downloads</h2>
@ -779,7 +795,11 @@ const SettingsApplication = () => {
<li>Make sure to contribute to this excellent project.</li>
<li>
More details{' '}
<a target="_blank" href="https://returnyoutubedislike.com/">
<a
href="https://returnyoutubedislike.com/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
@ -794,7 +814,11 @@ const SettingsApplication = () => {
<li>Make sure to contribute to this excellent project.</li>
<li>
More details{' '}
<a target="_blank" href="https://sponsor.ajay.app/">
<a
href="https://sponsor.ajay.app/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
@ -831,7 +855,11 @@ const SettingsApplication = () => {
<div>
<p>
Enable{' '}
<a target="_blank" href="https://returnyoutubedislike.com/">
<a
href="https://returnyoutubedislike.com/"
target="_blank"
rel="noopener noreferrer"
>
returnyoutubedislike
</a>
</p>
@ -846,7 +874,7 @@ const SettingsApplication = () => {
<div>
<p>
Enable{' '}
<a href="https://sponsor.ajay.app/" target="_blank">
<a href="https://sponsor.ajay.app/" target="_blank" rel="noopener noreferrer">
Sponsorblock
</a>
</p>

View File

@ -526,7 +526,10 @@ const SettingsScheduling = () => {
className="danger-button"
label="Delete"
onClick={async () => {
await deleteAppriseNotificationUrl(key as AppriseTaskNameType);
await deleteAppriseNotificationUrl(
key as AppriseTaskNameType,
url,
);
setRefresh(true);
}}

View File

@ -109,6 +109,7 @@ const Video = () => {
const { userConfig } = useUserConfigStore();
const [videoEnded, setVideoEnded] = useState(false);
const [seekToTimestamp, setSeekToTimestamp] = useState<number>();
const [playlistAutoplay, setPlaylistAutoplay] = useState(
localStorage.getItem('playlistAutoplay') === 'true',
);
@ -135,6 +136,10 @@ const Video = () => {
const { data: customPlaylistsResponseData } = customPlaylistsResponse ?? {};
const { data: commentsResponseData } = commentsResponse ?? {};
const handleTimestampClick = (seconds: number) => {
setSeekToTimestamp(seconds);
};
useEffect(() => {
(async () => {
if (refreshVideoList || videoId !== videoResponseData?.youtube_id) {
@ -224,6 +229,8 @@ const Video = () => {
video={video}
sponsorBlock={sponsorBlock}
autoplay={playlistAutoplay}
seekToTimestamp={seekToTimestamp}
setSeekToTimestamp={setSeekToTimestamp}
onWatchStateChanged={() => {
setRefreshVideoList(true);
}}
@ -279,7 +286,11 @@ const Video = () => {
{video.active && (
<p>
Youtube:{' '}
<a href={`https://www.youtube.com/watch?v=${video.youtube_id}`} target="_blank">
<a
href={`https://www.youtube.com/watch?v=${video.youtube_id}`}
target="_blank"
rel="noopener noreferrer"
>
Active
</a>
</p>
@ -488,7 +499,7 @@ const Video = () => {
id={descriptionExpanded ? 'text-expand-expanded' : 'text-expand'}
className="description-text"
>
<Linkify>{video.description}</Linkify>
<Linkify onTimestampClick={handleTimestampClick}>{video.description}</Linkify>
</p>
<Button
@ -601,7 +612,7 @@ const Video = () => {
{comments?.map(comment => {
return (
<Fragment key={comment.comment_id}>
<CommentBox comment={comment} />
<CommentBox comment={comment} onTimestampClick={handleTimestampClick} />
</Fragment>
);
})}

View File

@ -13,6 +13,7 @@ export const useAppSettingsStore = create<AppSettingsState>(set => ({
live_channel_size: null,
shorts_channel_size: null,
auto_start: false,
extract_flat: false,
},
downloads: {
limit_speed: null,

View File

@ -469,6 +469,36 @@ button:disabled:hover {
margin: 20px 0;
}
.player-wrapper.theater-mode {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.9);
margin: 0;
display: flex;
align-items: center;
justify-content: center;
}
.video-main.theater-mode {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
.video-main.theater-mode video {
max-height: 95vh;
max-width: 95vw;
width: auto;
height: auto;
}
.video-player {
display: grid;
align-content: space-evenly;
@ -970,6 +1000,12 @@ video:-webkit-full-screen {
filter: var(--img-filter-error);
}
.timestamp-link {
color: var(--accent-font-light);
cursor: pointer;
font-weight: bold;
}
/* multi search page */
.multi-search-box {
padding-right: 20px;