diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index febd9c30..55dc2b3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: files: ^backend/ args: ["--profile", "black", "-l 79"] - repo: https://github.com/pycqa/flake8 - rev: 7.1.2 + rev: 7.3.0 hooks: - id: flake8 alias: python @@ -31,7 +31,7 @@ repos: - id: codespell exclude: ^frontend/package-lock.json - repo: https://github.com/pre-commit/mirrors-eslint - rev: v9.22.0 + rev: v9.30.1 hooks: - id: eslint name: eslint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a4425a9..b6d389cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ Please read this carefully before opening any [issue](https://github.com/tubearc - Do respond to questions within a day or two so issues can progress. If the issue doesn't move forward due to a lack of response, we'll assume it's solved and we'll close it after some time to keep the list fresh. **Don't**: -- Don't open *duplicates*, that includes open and closed issues. +- Don't open *duplicates*, that includes open and closed issues. Also don't post the same issue on multiple platforms, that makes it unnecessarily hard for maintainers to keep up. - Don't open an issue for something that's already on the [roadmap](https://github.com/tubearchivist/tubearchivist#roadmap), this needs your help to implement it, not another issue. - Don't open an issue for something that's a [known limitation](https://github.com/tubearchivist/tubearchivist#known-limitations). These are *known* by definition and don't need another reminder. Some limitations may be solved in the future, maybe by you? - Don't overwrite the *issue template*, they are there for a reason. Overwriting that shows that you don't really care about this project. It shows that you have a misunderstanding how open source collaboration works and just want to push your ideas through. Overwriting the template may result in a ban. @@ -65,7 +65,7 @@ IMPORTANT: When receiving help, contribute back to the community by improving th ## How to make a Pull Request -Thank you for contributing and helping improve this project. Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application. +Focus for the foreseeable future is on improving and building on existing functionality, *not* on adding and expanding the application. This is a quick checklist to help streamline the process: @@ -107,7 +107,7 @@ Beyond that, general rules to consider: - Maintainability is key: It's not just about implementing something and being done with it, it's about maintaining it, fixing bugs as they occur, improving on it and supporting it in the long run. - Others can do it better: Some problems have been solved by very talented developers. These things don't need to be reinvented again here in this project. -- Develop for the 80%: New features and additions *should* be beneficial for 80% of the users. If you are trying to solve your own problem that only applies to you, maybe that would be better to do in your own fork or if possible by a standalone implementation using the API. +- Develop for the 80%: New features and additions *should* be beneficial for 80% of the users. If you are trying to solve your own problem that only apply to you, maybe that would be better to do in your own fork or if possible by a standalone implementation using the API. - If all of that sounds too strict for you, as stated above, start becoming a regular contributor to this project. --- @@ -133,7 +133,7 @@ The documentation available at [docs.tubearchivist.com](https://docs.tubearchivi This codebase is set up to be developed natively outside of docker as well as in a docker container. Developing outside of a docker container can be convenient, as IDE and hot reload usually works out of the box. But testing inside of a container is still essential, as there are subtle differences, especially when working with the filesystem and networking between containers. Note: -- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers. +- Subtitles currently fail to load with `DJANGO_DEBUG=True`, that is due to incorrect `Content-Type` error set by Django's static file implementation. That's only if you run the Django dev server, Nginx sets the correct headers in the container. ### Native Instruction @@ -183,12 +183,6 @@ And the frontend should be available at [localhost:3000](localhost:3000). ### Docker Instructions -Set up docker on your development machine. - -Clone this repository. - -Functional changes should be made against the unstable `testing` branch, so check that branch out, then make a new branch for your work. - Edit the `docker-compose.yml` file and replace the [`image: bbilly1/tubearchivist` line](https://github.com/tubearchivist/tubearchivist/blob/4af12aee15620e330adf3624c984c3acf6d0ac8b/docker-compose.yml#L7) with `build: .`. Also make any other changes to the environment variables and so on necessary to run the application, just like you're launching the application as normal. Run `docker compose up --build`. This will bring up the application. Kill it with `ctrl-c` or by running `docker compose down` from a new terminal window in the same directory. @@ -212,7 +206,7 @@ You may find it nice to run everything inside of a VM for complete environment s ### Working with Elasticsearch Additionally to the required services as listed in the example docker-compose file, the **Dev Tools** of [Kibana](https://www.elastic.co/guide/en/kibana/current/docker.html) are invaluable for running and testing Elasticsearch queries. -**Quick start** +**Quick start** Generate your access token in Elasitcsearch: ```bash bin/elasticsearch-service-tokens create elastic/kibana kibana @@ -233,7 +227,7 @@ services: If you want to run queries on the Elasticsearch container directly from your host with for example `curl` or something like *postman*, you might want to **publish** the port 9200 instead of just **exposing** it. -**Persist Token** +**Persist Token** The token will get stored in ES in the `config` folder, and not in the `data` folder. To persist the token between ES container rebuilds, you'll need to persist the config folder as an additional volume: 1. Create the token as described above diff --git a/README.md b/README.md index 9c50cd8c..f11bf4d0 100644 --- a/README.md +++ b/README.md @@ -91,13 +91,13 @@ All environment variables are explained in detail in the docs [here](https://doc ## Update -Always use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tags are only for your testing environment, there might not be an update path for these testing builds. +Always use the *latest* (the default) or a named semantic version tag for the docker images. The *unstable* tags see [CONTRIBUTING.md#beta-testing](https://github.com/tubearchivist/tubearchivist/blob/master/CONTRIBUTING.md#beta-testing). -You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. To update, you need to update the docker images, the method for which will depend on your platform. For example, if you're using `docker-compose`, run `docker-compose pull` and then restart with `docker-compose up -d`. After updating, check the footer to verify you are running the expected version. +You will see the current version number of **Tube Archivist** in the footer of the interface. There is a daily version check task querying tubearchivist.com, notifying you of any new releases in the footer. After updating, check the footer to verify you are running the expected version. - - This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported and you might have to reset your index and configurations to update. Ideally apply new updates at least once per month. + - This project is tested for updates between one or two releases maximum. Further updates back may or may not be supported. Ideally apply new updates at least once per month. - There can be breaking changes between updates, particularly as the application grows, new environment variables or settings might be required for you to set in the your docker-compose file. *Always* check the **release notes**: Any breaking changes will be marked there. - - All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated when a new release of Elasticsearch is available. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version. + - All testing and development is done with the Elasticsearch version number as mentioned in the provided *docker-compose.yml* file. This will be updated from time to time. Running an older version of Elasticsearch is most likely not going to result in any issues, but it's still recommended to run the same version as mentioned. Use `bbilly1/tubearchivist-es` to automatically get the recommended version. ## Getting Started 1. Go through the **settings** page and look at the available options. Particularly set *Download Format* to your desired video quality before downloading. **Tube Archivist** downloads the best available quality by default. To support iOS or MacOS and some other browsers a compatible format must be specified. For example: diff --git a/backend/appsettings/index_mapping.json b/backend/appsettings/index_mapping.json index d1bbd500..72845492 100644 --- a/backend/appsettings/index_mapping.json +++ b/backend/appsettings/index_mapping.json @@ -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": { diff --git a/backend/appsettings/serializers.py b/backend/appsettings/serializers.py index 5e6e434b..22f3fb44 100644 --- a/backend/appsettings/serializers.py +++ b/backend/appsettings/serializers.py @@ -24,7 +24,9 @@ class AppConfigSubSerializer( channel_size = serializers.IntegerField(required=False) live_channel_size = serializers.IntegerField(required=False) shorts_channel_size = serializers.IntegerField(required=False) + playlist_size = serializers.IntegerField(required=False) auto_start = serializers.BooleanField(required=False) + extract_flat = serializers.BooleanField(required=False) class AppConfigDownloadsSerializer( diff --git a/backend/appsettings/src/config.py b/backend/appsettings/src/config.py index 3c14a2c0..2fb9247a 100644 --- a/backend/appsettings/src/config.py +++ b/backend/appsettings/src/config.py @@ -21,7 +21,9 @@ class SubscriptionsConfigType(TypedDict): channel_size: int live_channel_size: int shorts_channel_size: int + playlist_size: int auto_start: bool + extract_flat: bool class DownloadsConfigType(TypedDict): @@ -72,7 +74,9 @@ class AppConfig: "channel_size": 50, "live_channel_size": 50, "shorts_channel_size": 50, + "playlist_size": 50, "auto_start": False, + "extract_flat": False, }, "downloads": { "limit_speed": None, diff --git a/backend/appsettings/src/reindex.py b/backend/appsettings/src/reindex.py index 56fd339e..20f82425 100644 --- a/backend/appsettings/src/reindex.py +++ b/backend/appsettings/src/reindex.py @@ -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 diff --git a/backend/channel/src/index.py b/backend/channel/src/index.py index 65503ef7..17bf9526 100644 --- a/backend/channel/src/index.py +++ b/backend/channel/src/index.py @@ -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) diff --git a/backend/channel/src/remote_query.py b/backend/channel/src/remote_query.py new file mode 100644 index 00000000..a38d1cce --- /dev/null +++ b/backend/channel/src/remote_query.py @@ -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 diff --git a/backend/channel/views.py b/backend/channel/views.py index f915ecec..1f0daf57 100644 --- a/backend/channel/views.py +++ b/backend/channel/views.py @@ -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: diff --git a/backend/common/src/es_connect.py b/backend/common/src/es_connect.py index f7a59e3a..b2d11635 100644 --- a/backend/common/src/es_connect.py +++ b/backend/common/src/es_connect.py @@ -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""" diff --git a/backend/common/src/helper.py b/backend/common/src/helper.py index fcf75dfe..cf647a4e 100644 --- a/backend/common/src/helper.py +++ b/backend/common/src/helper.py @@ -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""" diff --git a/backend/common/src/index_generic.py b/backend/common/src/index_generic.py index 450c1f88..354f83ea 100644 --- a/backend/common/src/index_generic.py +++ b/backend/common/src/index_generic.py @@ -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""" diff --git a/backend/common/src/search_processor.py b/backend/common/src/search_processor.py index 714d33b0..e5c0cac4 100644 --- a/backend/common/src/search_processor.py +++ b/backend/common/src/search_processor.py @@ -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())) diff --git a/backend/common/src/urlparser.py b/backend/common/src/urlparser.py index 3db07ad6..c3b80690 100644 --- a/backend/common/src/urlparser.py +++ b/backend/common/src/urlparser.py @@ -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: diff --git a/backend/common/src/watched.py b/backend/common/src/watched.py index 5cdef464..27e135c2 100644 --- a/backend/common/src/watched.py +++ b/backend/common/src/watched.py @@ -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 = { diff --git a/backend/config/fwd_auth_settings.py b/backend/config/fwd_auth_settings.py new file mode 100644 index 00000000..516b9c47 --- /dev/null +++ b/backend/config/fwd_auth_settings.py @@ -0,0 +1,6 @@ +from os import environ + +TA_AUTH_PROXY_USERNAME_HEADER = ( + environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER" +) +TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL") diff --git a/backend/config/ldap_settings.py b/backend/config/ldap_settings.py new file mode 100644 index 00000000..ab644a0a --- /dev/null +++ b/backend/config/ldap_settings.py @@ -0,0 +1,111 @@ +from os import environ + +import ldap +from django_auth_ldap.config import LDAPSearch + +AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI") + +AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN") + +AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD") + +""" +Given Names are *_technically_* different from Personal names, as people +who change their names have different given names and personal names, +and they go by personal names. Additionally, "LastName" is actually +incorrect for many cultures, such as Korea, where the +family name comes first, and the personal name comes last. + +But we all know people are going to try to guess at these, so still want +to include names that people will guess, hence using first/last as well. +""" + +AUTH_LDAP_USER_ATTR_MAP_USERNAME = ( + environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME") + or environ.get("TA_LDAP_USER_ATTR_MAP_UID") + or "uid" +) + +AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = ( + environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME") + or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME") + or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME") + or "givenName" +) + +AUTH_LDAP_USER_ATTR_MAP_SURNAME = ( + environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME") + or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME") + or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME") + or "sn" +) + +AUTH_LDAP_USER_ATTR_MAP_EMAIL = ( + environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL") + or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL") + or "mail" +) + +AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE") + +AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER") + +# pylint: disable=no-member +AUTH_LDAP_USER_SEARCH = LDAPSearch( + AUTH_LDAP_USER_BASE, + ldap.SCOPE_SUBTREE, + "(&(" + + AUTH_LDAP_USER_ATTR_MAP_USERNAME + + "=%(user)s)" + + AUTH_LDAP_USER_FILTER + + ")", +) + +AUTH_LDAP_USER_ATTR_MAP = { + "username": AUTH_LDAP_USER_ATTR_MAP_USERNAME, + "first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME, + "last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME, + "email": AUTH_LDAP_USER_ATTR_MAP_EMAIL, +} + +if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")): + # pylint: disable=global-at-module-level + global AUTH_LDAP_GLOBAL_OPTIONS + AUTH_LDAP_GLOBAL_OPTIONS = { + ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER, + } + +# Promote specific usernames to staff or superuser permission levels +_ldap_superuser_username_config = ( + environ.get("TA_LDAP_PROMOTE_USERNAMES_TO_SUPERUSER") or "" +) +_ldap_superuser_usernames = [] +if _ldap_superuser_username_config: + _ldap_superuser_usernames = [ + u.strip() for u in _ldap_superuser_username_config.split(",") + ] + +_ldap_staff_username_config = ( + environ.get("TA_LDAP_PROMOTE_USERNAMES_TO_STAFF") or "" +) +_ldap_staff_usernames = [] +if _ldap_staff_username_config: + _ldap_staff_usernames = [ + u.strip() for u in _ldap_staff_username_config.split(",") + ] + +if _ldap_staff_usernames or _ldap_superuser_usernames: + import django_auth_ldap.backend + + def create_user(sender, user=None, ldap_user=None, **kwargs): + if user.ldap_username in _ldap_superuser_usernames and not ( + user.is_superuser and user.is_staff + ): + user.is_staff = True + user.is_superuser = True + user.save() + elif user.ldap_username in _ldap_staff_usernames and not user.is_staff: + user.is_staff = True + user.save() + + django_auth_ldap.backend.populate_user.connect(create_user) diff --git a/backend/config/management/commands/ta_connection.py b/backend/config/management/commands/ta_connection.py index 4ad511d1..4f89b0f4 100644 --- a/backend/config/management/commands/ta_connection.py +++ b/backend/config/management/commands/ta_connection.py @@ -24,7 +24,7 @@ class Command(BaseCommand): """command framework""" TIMEOUT = 120 - MIN_MAJOR, MAX_MAJOR = 8, 8 + MIN_MAJOR, MAX_MAJOR = 8, 9 MIN_MINOR = 0 # pylint: disable=no-member diff --git a/backend/config/management/commands/ta_envcheck.py b/backend/config/management/commands/ta_envcheck.py index 69ebd98e..2b284d7c 100644 --- a/backend/config/management/commands/ta_envcheck.py +++ b/backend/config/management/commands/ta_envcheck.py @@ -81,6 +81,7 @@ class Command(BaseCommand): """run all commands""" self.stdout.write(LOGO) self.stdout.write(TOPIC) + self._additional_auth_vars_expectations() self._expected_vars() self._unexpected_vars() self._elastic_user_overwrite() @@ -89,6 +90,50 @@ class Command(BaseCommand): self._disable_static_auth() self._create_superuser() + def _additional_auth_vars_expectations(self): + """conditionally add additional expectations for auth modes""" + ldap_required_env = [ + "TA_LDAP_SERVER_URI", + "TA_LDAP_BIND_DN", + "TA_LDAP_BIND_PASSWORD", + "TA_LDAP_USER_BASE", + "TA_LDAP_USER_FILTER", + ] + _login_auth_mode = ( + os.environ.get("TA_LOGIN_AUTH_MODE") or "single" + ).casefold() + if _login_auth_mode == "local": + UNEXPECTED_ENV_VARS["TA_LDAP"] = ( + "TA_LDAP is not valid with current auth mode" + ) + UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = ( + "TA_ENABLE_AUTH_PROXY is not valid with current auth mode" + ) + elif _login_auth_mode == "ldap": + EXPECTED_ENV_VARS.extend(ldap_required_env) + UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = ( + "TA_ENABLE_AUTH_PROXY is not valid with current auth mode" + ) + elif _login_auth_mode == "forwardauth": + UNEXPECTED_ENV_VARS["TA_LDAP"] = ( + "TA_LDAP is not valid with current auth mode" + ) + elif _login_auth_mode == "ldap_local": + EXPECTED_ENV_VARS.extend(ldap_required_env) + UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = ( + "TA_ENABLE_AUTH_PROXY is not valid with current auth mode" + ) + else: + if bool(os.environ.get("TA_LDAP")): + EXPECTED_ENV_VARS.extend(ldap_required_env) + UNEXPECTED_ENV_VARS["TA_ENABLE_AUTH_PROXY"] = ( + "TA_ENABLE_AUTH_PROXY is not valid with current auth mode" + ) + if bool(os.environ.get("TA_ENABLE_AUTH_PROXY")): + UNEXPECTED_ENV_VARS["TA_LDAP"] = ( + "TA_LDAP is not valid with current auth mode" + ) + def _expected_vars(self): """check if expected env vars are set""" self.stdout.write("[1] checking expected env vars") diff --git a/backend/config/management/commands/ta_startup.py b/backend/config/management/commands/ta_startup.py index 980354b1..4b75f05b 100644 --- a/backend/config/management/commands/ta_startup.py +++ b/backend/config/management/commands/ta_startup.py @@ -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) diff --git a/backend/config/settings.py b/backend/config/settings.py index abcc4aa8..f3fe5466 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -104,98 +104,6 @@ TEMPLATES = [ WSGI_APPLICATION = "config.wsgi.application" -if bool(environ.get("TA_LDAP")): - # pylint: disable=global-at-module-level - import ldap - from django_auth_ldap.config import LDAPSearch - - global AUTH_LDAP_SERVER_URI - AUTH_LDAP_SERVER_URI = environ.get("TA_LDAP_SERVER_URI") - - global AUTH_LDAP_BIND_DN - AUTH_LDAP_BIND_DN = environ.get("TA_LDAP_BIND_DN") - - global AUTH_LDAP_BIND_PASSWORD - AUTH_LDAP_BIND_PASSWORD = environ.get("TA_LDAP_BIND_PASSWORD") - - """ - Since these are new environment variables, taking the opporunity to use - more accurate env names. - Given Names are *_technically_* different from Personal names, as people - who change their names have different given names and personal names, - and they go by personal names. Additionally, "LastName" is actually - incorrect for many cultures, such as Korea, where the - family name comes first, and the personal name comes last. - - But we all know people are going to try to guess at these, so still want - to include names that people will guess, hence using first/last as well. - """ - - # Attribute mapping options - - global AUTH_LDAP_USER_ATTR_MAP_USERNAME - AUTH_LDAP_USER_ATTR_MAP_USERNAME = ( - environ.get("TA_LDAP_USER_ATTR_MAP_USERNAME") - or environ.get("TA_LDAP_USER_ATTR_MAP_UID") - or "uid" - ) - - global AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME - AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME = ( - environ.get("TA_LDAP_USER_ATTR_MAP_PERSONALNAME") - or environ.get("TA_LDAP_USER_ATTR_MAP_FIRSTNAME") - or environ.get("TA_LDAP_USER_ATTR_MAP_GIVENNAME") - or "givenName" - ) - - global AUTH_LDAP_USER_ATTR_MAP_SURNAME - AUTH_LDAP_USER_ATTR_MAP_SURNAME = ( - environ.get("TA_LDAP_USER_ATTR_MAP_SURNAME") - or environ.get("TA_LDAP_USER_ATTR_MAP_LASTNAME") - or environ.get("TA_LDAP_USER_ATTR_MAP_FAMILYNAME") - or "sn" - ) - - global AUTH_LDAP_USER_ATTR_MAP_EMAIL - AUTH_LDAP_USER_ATTR_MAP_EMAIL = ( - environ.get("TA_LDAP_USER_ATTR_MAP_EMAIL") - or environ.get("TA_LDAP_USER_ATTR_MAP_MAIL") - or "mail" - ) - - global AUTH_LDAP_USER_BASE - AUTH_LDAP_USER_BASE = environ.get("TA_LDAP_USER_BASE") - - global AUTH_LDAP_USER_FILTER - AUTH_LDAP_USER_FILTER = environ.get("TA_LDAP_USER_FILTER") - - global AUTH_LDAP_USER_SEARCH - # pylint: disable=no-member - AUTH_LDAP_USER_SEARCH = LDAPSearch( - AUTH_LDAP_USER_BASE, - ldap.SCOPE_SUBTREE, - "(&(" - + AUTH_LDAP_USER_ATTR_MAP_USERNAME - + "=%(user)s)" - + AUTH_LDAP_USER_FILTER - + ")", - ) - - global AUTH_LDAP_USER_ATTR_MAP - AUTH_LDAP_USER_ATTR_MAP = { - "username": AUTH_LDAP_USER_ATTR_MAP_USERNAME, - "first_name": AUTH_LDAP_USER_ATTR_MAP_PERSONALNAME, - "last_name": AUTH_LDAP_USER_ATTR_MAP_SURNAME, - "email": AUTH_LDAP_USER_ATTR_MAP_EMAIL, - } - - if bool(environ.get("TA_LDAP_DISABLE_CERT_CHECK")): - global AUTH_LDAP_GLOBAL_OPTIONS - AUTH_LDAP_GLOBAL_OPTIONS = { - ldap.OPT_X_TLS_REQUIRE_CERT: ldap.OPT_X_TLS_NEVER, - } - - # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases @@ -229,16 +137,6 @@ AUTH_PASSWORD_VALIDATORS = [ AUTH_USER_MODEL = "user.Account" -# Forward-auth authentication -if bool(environ.get("TA_ENABLE_AUTH_PROXY")): - TA_AUTH_PROXY_USERNAME_HEADER = ( - environ.get("TA_AUTH_PROXY_USERNAME_HEADER") or "HTTP_REMOTE_USER" - ) - TA_AUTH_PROXY_LOGOUT_URL = environ.get("TA_AUTH_PROXY_LOGOUT_URL") - - MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware") - - # Configure Authentication Backend Combinations _login_auth_mode = (environ.get("TA_LOGIN_AUTH_MODE") or "single").casefold() if _login_auth_mode == "local": @@ -247,24 +145,33 @@ if _login_auth_mode == "local": ) elif _login_auth_mode == "ldap": AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",) + from .ldap_settings import * # noqa: F403 F401 elif _login_auth_mode == "forwardauth": + from .fwd_auth_settings import * # noqa: F403 F401 + AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.RemoteUserBackend", ) + MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware") elif _login_auth_mode == "ldap_local": AUTHENTICATION_BACKENDS = ( "django_auth_ldap.backend.LDAPBackend", "django.contrib.auth.backends.ModelBackend", ) + from .ldap_settings import * # noqa: F403 F401 else: # If none of these cases match, AUTHENTICATION_BACKENDS is unset, which # means the ModelBackend should be used by default if bool(environ.get("TA_LDAP")): AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",) + from .ldap_settings import * # noqa: F403 F401 if bool(environ.get("TA_ENABLE_AUTH_PROXY")): + from .fwd_auth_settings import * # noqa: F403 F401 + AUTHENTICATION_BACKENDS = ( "django.contrib.auth.backends.RemoteUserBackend", ) + MIDDLEWARE.append("user.src.remote_user_auth.HttpRemoteUserMiddleware") # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ @@ -329,3 +236,21 @@ SPECTACULAR_SETTINGS = { "VERSION": TA_VERSION, "SERVE_INCLUDE_SCHEMA": False, } + +# Logging configuration +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "loggers": { + "apprise": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + }, +} diff --git a/backend/download/serializers.py b/backend/download/serializers.py index 4ae1b180..b838b69c 100644 --- a/backend/download/serializers.py +++ b/backend/download/serializers.py @@ -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): diff --git a/backend/download/src/queue.py b/backend/download/src/queue.py index 7110ceaf..8bf59e83 100644 --- a/backend/download/src/queue.py +++ b/backend/download/src/queue.py @@ -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,372 @@ 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] = [] + self.added = 0 - def parse_url_list(self, auto_start=False): + def parse_url_list(self, status="pending") -> int: """extract youtube ids from list""" - self.missing_videos = [] self.get_download() self.get_indexed() - 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 - - self.task.send_progress( - message_lines=[f"Extracting items {idx + 1}/{total}"], - progress=(idx + 1) / total, - ) - - def _process_entry(self, entry, auto_start=False): - """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) - elif entry["type"] == "channel": - self._parse_channel(entry["url"], vid_type) - elif entry["type"] == "playlist": - self._parse_playlist(entry["url"]) - 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): - """add video to list""" - if 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: - print(f"{url}: skipped adding already indexed video to download.") - - 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 - ) - 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: - return - - for video_id in to_add: - # match vid_type later - self._add_video(video_id, VideoTypeEnum.UNKNOWN) - - def add_to_pending(self, status="pending", auto_start=False): - """add missing videos to pending list""" self.get_channels() + total = len(self.youtube_ids) + for idx, entry in enumerate(self.youtube_ids, start=1): + if self.task: + self.task.send_progress( + message_lines=[f"Extracting URL {idx}/{total}"], + progress=idx / total, + ) + + self._process_entry(entry, idx, total) + + if self.missing_videos: + self.added += self.add_to_pending(status) + self.missing_videos = [] - 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) + rand_sleep(self.config) + + return self.added + + def _process_entry(self, entry: ParsedURLType, idx: int, total: int): + """process single entry from url list""" + if entry["type"] == "video": + to_add = self._add_video(entry["url"], entry["vid_type"]) + if to_add: + self.__notify_add( + item_type="video", + name=to_add["title"], + idx=idx, + total=total, + ) + + elif entry["type"] == "channel": + self._parse_channel(entry) + elif entry["type"] == "playlist": + self._parse_playlist(entry["url"], entry.get("limit")) + else: + raise ValueError(f"invalid url_type: {entry}") + + def _add_video(self, url, vid_type): + """add video to list""" + 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 None + + 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) + if to_add: + self.missing_videos.append(to_add) + + return to_add + + 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, + ) + if not video_results: + print(f"{url}: no videos to add from channel, skipping") + return + + 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 + + total = len(video_results) + for idx, video_data in enumerate(video_results, start=1): + to_add = self.__parse_channel_video( + video_data, vid_type, channel_handler.json_data + ) + if self.task and self.task.is_stopped(): + break + + if not to_add: continue - video_details.update( - { - "status": status, - "auto_start": auto_start, - } + self.missing_videos.append(to_add) + self.__notify_add( + item_type="channel", + name=channel_handler.json_data["channel_name"], + 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) + def __parse_channel_video( + self, video_data, vid_type, channel_json + ) -> dict | None: + """parse video of channel""" + video_id = video_data["id"] + if video_id in self.to_skip: + return None - if idx != total: - rand_sleep(self.config) + # fallback + channel_name = channel_json["channel_name"] + channel_id = channel_json["channel_id"] - return videos_added + if self.flat: + if not video_data.get("channel"): + video_data["channel"] = channel_name - def _notify_add(self, idx, total): + if not video_data.get("channel_id"): + video_data["channel_id"] = channel_id + + to_add = self._parse_entry( + youtube_id=video_id, + video_data=video_data, + ) + else: + to_add = self._parse_video(video_id, vid_type) + + return to_add + + 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 + + video_results = playlist.youtube_meta["entries"] + + total = len(video_results) + for idx, video_data in enumerate(video_results, start=1): + 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) + else: + to_add = self._parse_video(video_id, vid_type=None) + + if not to_add: + continue + + self.missing_videos.append(to_add) + self.__notify_add( + item_type="playlist", + name=playlist.json_data["playlist_name"], + idx=idx, + total=total, + ) + + def _parse_video(self, url, vid_type): + """parse video when not flat, fetch from YT""" + 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, + ) + 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, + ) -> 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, + } + + return to_add + + def __extract_thumb(self, video_data) -> str | None: + """extract thumb""" + if "thumbnail" in video_data: + return video_data["thumbnail"] + + if video_data.get("thumbnails"): + return video_data["thumbnails"][-1]["url"] + + 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 str(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_add( + self, item_type: str, name: str, idx: int, total: int + ) -> None: + """notify""" + if not self.task: + return + + if self.flat: + lines = [ + f"Bulk extracting {item_type.title()}: '{name}'.", + f"Fast adding item {idx}/{total}.", + ] + else: + lines = [ + f"Full extracting {item_type.title()}: '{name}'", + f"Parsing item {idx}/{total}.", + ] + + self.task.send_progress( + message_lines=lines, + progress=idx / 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 +476,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", + ) diff --git a/backend/download/src/queue_interact.py b/backend/download/src/queue_interact.py new file mode 100644 index 00000000..260b5e5a --- /dev/null +++ b/backend/download/src/queue_interact.py @@ -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, + } diff --git a/backend/download/src/subscriptions.py b/backend/download/src/subscriptions.py index abc0a4eb..5d84ba03 100644 --- a/backend/download/src/subscriptions.py +++ b/backend/download/src/subscriptions.py @@ -6,324 +6,101 @@ 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": {}} + def find_missing(self) -> int: + """find missing videos from channel subscriptions""" + if self.task: + self.task.send_progress(["Looking up channels."]) - 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() + 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: + self.task.send_progress([f"Scanning {len(all_channels)} channels"]) - 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), ) + added = pending_handler.parse_url_list() - 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"]["playlist_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), + ) + added = pending_handler.parse_url_list() + + return added class SubscriptionScanner: @@ -339,40 +116,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 +153,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 +177,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""" diff --git a/backend/download/src/thumbnails.py b/backend/download/src/thumbnails.py index 50af3887..2d9d4739 100644 --- a/backend/download/src/thumbnails.py +++ b/backend/download/src/thumbnails.py @@ -265,7 +265,7 @@ class ValidatorCallback: def run(self): """run the task for page""" print(f"{self.index_name}: validate artwork") - if self.index_name == "ta_video": + if self.index_name in ["ta_video", "ta_download"]: self._validate_videos() elif self.index_name == "ta_channel": self._validate_channels() @@ -325,6 +325,13 @@ class ThumbValidator: }, "name": "ta_playlist", }, + { + "data": { + "query": {"term": {"status": {"value": "pending"}}}, + "_source": ["youtube_id", "vid_thumb_url"], + }, + "name": "ta_download", + }, ] def __init__(self, task=False): diff --git a/backend/download/src/yt_dlp_base.py b/backend/download/src/yt_dlp_base.py index c80f9d7d..68332da0 100644 --- a/backend/download/src/yt_dlp_base.py +++ b/backend/download/src/yt_dlp_base.py @@ -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): diff --git a/backend/download/src/yt_dlp_handler.py b/backend/download/src/yt_dlp_handler.py index 6348e3ab..0617bf38 100644 --- a/backend/download/src/yt_dlp_handler.py +++ b/backend/download/src/yt_dlp_handler.py @@ -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 @@ -360,9 +360,7 @@ class DownloadPostProcess(DownloaderBase): print("add deleted to ignore list") vids = [{"type": "video", "url": i["youtube_id"]} for i in to_delete] - pending = PendingList(youtube_ids=vids) - pending.parse_url_list() - _ = pending.add_to_pending(status="ignore") + PendingList(youtube_ids=vids).parse_url_list(status="ignore") def refresh_playlist(self) -> None: """match videos with playlists""" @@ -403,8 +401,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): diff --git a/backend/download/views.py b/backend/download/views.py index 6ef1f7ff..05f3deb5 100644 --- a/backend/download/views.py +++ b/backend/download/views.py @@ -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) diff --git a/backend/playlist/serializers.py b/backend/playlist/serializers.py index 9362f5b6..741c770d 100644 --- a/backend/playlist/serializers.py +++ b/backend/playlist/serializers.py @@ -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): diff --git a/backend/playlist/src/index.py b/backend/playlist/src/index.py index 3533205a..165860dc 100644 --- a/backend/playlist/src/index.py +++ b/backend/playlist/src/index.py @@ -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() diff --git a/backend/playlist/views.py b/backend/playlist/views.py index 6ec102ff..bb756081 100644 --- a/backend/playlist/views.py +++ b/backend/playlist/views.py @@ -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) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 30696ad3..84d10470 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -ipython==9.3.0 +ipython==9.4.0 pre-commit==4.2.0 pylint-django==2.6.1 pylint==3.3.7 diff --git a/backend/requirements.txt b/backend/requirements.txt index b939b37f..a71cb97f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,10 +3,10 @@ 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.3 +Django==5.2.4 djangorestframework==3.16.0 drf-spectacular==0.28.0 -Pillow==11.2.1 +Pillow==11.3.0 redis==6.2.0 requests==2.32.4 ryd-client==0.0.6 diff --git a/backend/task/serializers.py b/backend/task/serializers.py index 26d98a6d..8ab19bd6 100644 --- a/backend/task/serializers.py +++ b/backend/task/serializers.py @@ -91,3 +91,12 @@ class TaskNotificationPostSerializer(serializers.Serializer): task_name = serializers.ChoiceField(choices=list(TASK_CONFIG)) url = serializers.CharField(required=False) + + +class TaskNotificationTestSerializer(serializers.Serializer): + """serialize task notification test POST""" + + url = serializers.CharField() + task_name = serializers.ChoiceField( + choices=list(TASK_CONFIG), required=False + ) diff --git a/backend/task/src/notify.py b/backend/task/src/notify.py index 3a9b1f23..7a493548 100644 --- a/backend/task/src/notify.py +++ b/backend/task/src/notify.py @@ -32,6 +32,38 @@ class Notifications: apobj.notify(body=body, title=title) + def test(self, url) -> tuple[bool, str]: + """send test notification""" + try: + apobj = apprise.Apprise() + + if not apobj.add(url): + success = False + message = f"Invalid notification URL format: {url}" + return success, message + + title = f"[TA] {self.task_name} process ended with SUCCESS" + body = "This is a test notification. Task completed successfully." + + result = apobj.notify(body=body, title=title) + + if result: + success = True + message = "Test notification sent successfully" + return success, message + + success = False + message = ( + "Notification failed. " + "Please check container logs for more information." + ) + return success, message + + except Exception as err: # pylint: disable=broad-exception-caught + success = False + message = f"Notification error: {str(err)}" + return success, message + def _build_message( self, task_id: str, task_title: str ) -> tuple[str, str | None]: diff --git a/backend/task/tasks.py b/backend/task/tasks.py index d879cb9e..2cf49624 100644 --- a/backend/task/tasks.py +++ b/backend/task/tasks.py @@ -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,16 @@ 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 ) + videos_added = pending_handler.parse_url_list(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 +262,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) diff --git a/backend/task/urls.py b/backend/task/urls.py index 66f985c6..d58e2aa7 100644 --- a/backend/task/urls.py +++ b/backend/task/urls.py @@ -34,4 +34,9 @@ urlpatterns = [ views.ScheduleNotification.as_view(), name="api-schedule-notification", ), + path( + "notification/test/", + views.NotificationTestView.as_view(), + name="api-schedule-notification-test", + ), ] diff --git a/backend/task/views.py b/backend/task/views.py index ff2562e3..a0bdee77 100644 --- a/backend/task/views.py +++ b/backend/task/views.py @@ -15,6 +15,7 @@ from task.serializers import ( TaskIDDataSerializer, TaskNotificationPostSerializer, TaskNotificationSerializer, + TaskNotificationTestSerializer, TaskResultSerializer, ) from task.src.config_schedule import CrontabValidator, ScheduleBuilder @@ -343,3 +344,34 @@ class ScheduleNotification(ApiBaseView): Notifications(task_name).remove_task() return Response(status=204) + + +class NotificationTestView(ApiBaseView): + """resolves to /api/task/notification/test/ + POST: test notification url + """ + + @extend_schema( + request=TaskNotificationTestSerializer(), + responses={ + 200: OpenApiResponse(description="test notification sent"), + 400: OpenApiResponse( + ErrorResponseSerializer(), description="bad request" + ), + }, + ) + def post(self, request): + """test notification""" + data_serializer = TaskNotificationTestSerializer(data=request.data) + data_serializer.is_valid(raise_exception=True) + validated_data = data_serializer.validated_data + + url = validated_data["url"] + task_name = validated_data.get("task_name", "manual_test") + + success, message = Notifications(task_name).test(url) + + status = 200 if success else 400 + return Response( + {"success": success, "message": message}, status=status + ) diff --git a/backend/video/src/comments.py b/backend/video/src/comments.py index 2d13448c..4b145a75 100644 --- a/backend/video/src/comments.py +++ b/backend/video/src/comments.py @@ -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 diff --git a/backend/video/src/constants.py b/backend/video/src/constants.py index 11b7de74..382636d4 100644 --- a/backend/video/src/constants.py +++ b/backend/video/src/constants.py @@ -11,6 +11,9 @@ class VideoTypeEnum(enum.Enum): SHORTS = "shorts" UNKNOWN = "unknown" + def __str__(self): + return self.value + @classmethod def values(cls) -> list[str]: """value list""" diff --git a/backend/video/src/index.py b/backend/video/src/index.py index 6f324eb3..34e23a8c 100644 --- a/backend/video/src/index.py +++ b/backend/video/src/index.py @@ -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 diff --git a/backend/video/views.py b/backend/video/views.py index a679552c..ef5c011a 100644 --- a/backend/video/views.py +++ b/backend/video/views.py @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5be2f31..44cefb06 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/package.json b/frontend/package.json index 6d205445..2407f73b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/api/actions/deleteAppriseNotificationUrl.ts b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts index 5018b79f..33153c49 100644 --- a/frontend/src/api/actions/deleteAppriseNotificationUrl.ts +++ b/frontend/src/api/actions/deleteAppriseNotificationUrl.ts @@ -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 }, }); }; diff --git a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts index 65a01a3c..9d4f5902 100644 --- a/frontend/src/api/actions/deleteDownloadQueueByFilter.ts +++ b/frontend/src/api/actions/deleteDownloadQueueByFilter.ts @@ -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', diff --git a/frontend/src/api/actions/testAppriseNotificationUrl.ts b/frontend/src/api/actions/testAppriseNotificationUrl.ts new file mode 100644 index 00000000..ff7f443e --- /dev/null +++ b/frontend/src/api/actions/testAppriseNotificationUrl.ts @@ -0,0 +1,16 @@ +import APIClient from '../../functions/APIClient'; +import { AppriseTaskNameType } from './createAppriseNotificationUrl'; + +export type TestNotificationResponseType = { + success: boolean; + message: string; +}; + +const testAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => { + return APIClient('/api/task/notification/test/', { + method: 'POST', + body: { task_name: taskName, url }, + }); +}; + +export default testAppriseNotificationUrl; diff --git a/frontend/src/api/actions/updateDownloadQueue.ts b/frontend/src/api/actions/updateDownloadQueue.ts index 071707df..beb14120 100644 --- a/frontend/src/api/actions/updateDownloadQueue.ts +++ b/frontend/src/api/actions/updateDownloadQueue.ts @@ -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] }, }); diff --git a/frontend/src/api/actions/updateDownloadQueueByFilter.ts b/frontend/src/api/actions/updateDownloadQueueByFilter.ts new file mode 100644 index 00000000..1410360e --- /dev/null +++ b/frontend/src/api/actions/updateDownloadQueueByFilter.ts @@ -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; diff --git a/frontend/src/api/actions/updatePlaylistSortOrder.ts b/frontend/src/api/actions/updatePlaylistSortOrder.ts new file mode 100644 index 00000000..a7234da2 --- /dev/null +++ b/frontend/src/api/actions/updatePlaylistSortOrder.ts @@ -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; diff --git a/frontend/src/api/loader/loadAppsettingsConfig.ts b/frontend/src/api/loader/loadAppsettingsConfig.ts index 44f22e8a..19091533 100644 --- a/frontend/src/api/loader/loadAppsettingsConfig.ts +++ b/frontend/src/api/loader/loadAppsettingsConfig.ts @@ -5,7 +5,9 @@ export type AppSettingsConfigType = { channel_size: number | null; live_channel_size: number | null; shorts_channel_size: number | null; + playlist_size: number | null; auto_start: boolean; + extract_flat: boolean; }; downloads: { limit_speed: number | null; diff --git a/frontend/src/api/loader/loadDownloadQueue.ts b/frontend/src/api/loader/loadDownloadQueue.ts index d74f4bae..367638ab 100644 --- a/frontend/src/api/loader/loadDownloadQueue.ts +++ b/frontend/src/api/loader/loadDownloadQueue.ts @@ -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()}` : ''}`; diff --git a/frontend/src/api/loader/loadPlaylistById.ts b/frontend/src/api/loader/loadPlaylistById.ts index b800410f..5a339baa 100644 --- a/frontend/src/api/loader/loadPlaylistById.ts +++ b/frontend/src/api/loader/loadPlaylistById.ts @@ -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; diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 0712d07d..6b389e07 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -9,6 +9,7 @@ export interface ButtonProps { children?: string | ReactNode | ReactNode[]; value?: string; title?: string; + disabled?: boolean; onClick?: () => void; } @@ -21,6 +22,7 @@ const Button = ({ children, value, title, + disabled, onClick, }: ButtonProps) => { return ( @@ -31,6 +33,7 @@ const Button = ({ type={type} value={value} title={title} + disabled={disabled} onClick={onClick} > {label} diff --git a/frontend/src/components/CommentBox.tsx b/frontend/src/components/CommentBox.tsx index c80c70bb..f7f71379 100644 --- a/frontend/src/components/CommentBox.tsx +++ b/frontend/src/components/CommentBox.tsx @@ -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}

- {comment.comment_text} + {comment.comment_text}

@@ -95,7 +96,7 @@ const CommentBox = ({ comment }: CommentBoxProps) => { comment.comment_replies?.map(comment => { return ( - + ); })} diff --git a/frontend/src/components/DownloadListItem.tsx b/frontend/src/components/DownloadListItem.tsx index ff0273dd..cd540c87 100644 --- a/frontend/src/components/DownloadListItem.tsx +++ b/frontend/src/components/DownloadListItem.tsx @@ -57,8 +57,9 @@ const DownloadListItem = ({ download, setRefresh }: DownloadListItemProps) => {

- Published: {formatDate(download.published)} | Duration: {download.duration} |{' '} - {download.youtube_id} + {download.published && Published: {formatDate(download.published)} | } + Duration: {download.duration} | + {download.youtube_id}

{download.message &&

{download.message}

} diff --git a/frontend/src/components/Linkify.tsx b/frontend/src/components/Linkify.tsx index a9d1d839..57dd92cb 100644 --- a/frontend/src/components/Linkify.tsx +++ b/frontend/src/components/Linkify.tsx @@ -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) ? `${word}` : word; + if (isUrl(word)) { + return `${word}`; + } + + if (isTimestamp(word) && onTimestampClick) { + const seconds = parseTimestamp(word); + return `${word}`; + } + + return word; + }; + + const handleClick = (event: React.MouseEvent) => { + 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 ; + return ; }; export default Linkify; diff --git a/frontend/src/components/SearchExampleQueries.tsx b/frontend/src/components/SearchExampleQueries.tsx index 2e17792e..1c181c6e 100644 --- a/frontend/src/components/SearchExampleQueries.tsx +++ b/frontend/src/components/SearchExampleQueries.tsx @@ -95,8 +95,8 @@ const SearchExampleQueries = () => { full: — search in video subtitles
  • - lang: — subtitles language (use two-letter ISO country code, same as - the one from settings page) + lang: — subtitles language (use two-letter ISO 639 language code, + same as the one from settings page)
  • source: diff --git a/frontend/src/components/VideoListItemTable.tsx b/frontend/src/components/VideoListItemTable.tsx index afd62216..3542406c 100644 --- a/frontend/src/components/VideoListItemTable.tsx +++ b/frontend/src/components/VideoListItemTable.tsx @@ -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) => { {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 ( @@ -46,12 +52,12 @@ const VideoListItemTable = ({ videoList, viewStyle }: VideoListItemProps) => { {title} {vid_type} - {`${videoStream.width}x${videoStream.height}`} + {`${videoStream?.width || '-'}x${videoStream?.height || '-'}`} {humanFileSize(media_size, useSiUnits)} - {videoStream.codec} - {humanFileSize(videoStream.bitrate, useSiUnits)} - {audioStream.codec} - {humanFileSize(audioStream.bitrate, useSiUnits)} + {videoStream?.codec || '-'} + {humanFileSize(videoStream?.bitrate || 0, useSiUnits)} + {audioStream?.codec || '-'} + {humanFileSize(audioStream?.bitrate || 0, useSiUnits)} ); })} diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx index de8fc4bb..d6c66c15 100644 --- a/frontend/src/components/VideoPlayer.tsx +++ b/frontend/src/components/VideoPlayer.tsx @@ -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(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'); diff --git a/frontend/src/functions/APIClient.ts b/frontend/src/functions/APIClient.ts index c0656a63..5b464f3d 100644 --- a/frontend/src/functions/APIClient.ts +++ b/frontend/src/functions/APIClient.ts @@ -6,7 +6,7 @@ import getCookie from './getCookie'; import Routes from '../configuration/routes/RouteList'; export interface ApiClientOptions extends Omit { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: Record | string; } diff --git a/frontend/src/pages/Download.tsx b/frontend/src/pages/Download.tsx index f08de4fa..10510f99 100644 --- a/frontend/src/pages/Download.tsx +++ b/frontend/src/pages/Download.tsx @@ -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 ( <> TA | Downloads @@ -194,6 +228,46 @@ const Download = () => { {showHiddenForm && (
    +
    +
    + setAddAsFlat(!addAsFlat)} + /> + {addAsFlat ? ( + + ) : ( + + )} +
    + Fast add +
    +
    +
    + setAddAsAutoStart(!addAsAutoStart)} + /> + {addAsAutoStart ? ( + + ) : ( + + )} +
    + Auto Download +