diff --git a/.vscode/settings.json b/.vscode/settings.json index e7fec592..96a1dd3c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -112,6 +112,7 @@ "timespec", "tmpfilename", "ungroup", + "unmark", "unnegated", "unpickleable", "Unraid", diff --git a/API.md b/API.md index 84588a36..ba5d5a29 100644 --- a/API.md +++ b/API.md @@ -25,8 +25,11 @@ This document describes the available endpoints and their usage. All endpoints r - [POST /api/history/{id}](#post-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid) - [GET /api/history](#get-apihistory) + - [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive) + - [POST /api/history/{id}/archive](#post-apihistoryidarchive) - [GET /api/tasks](#get-apitasks) - [PUT /api/tasks](#put-apitasks) + - [POST /api/tasks/{id}/mark](#post-apitasksidmark) - [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8) - [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8) - [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets) @@ -35,16 +38,26 @@ This document describes the available endpoints and their usage. All endpoints r - [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath) - - [GET /api/yt-dlp/archive/recheck](#get-apiyt-dlparchiverecheck) + - [POST /api/file/action/{path:.\*}](#post-apifileactionpath) + - [POST /api/file/download](#post-apifiledownload) + - [GET /api/file/download/{token}](#get-apifiledownloadtoken) - [GET /api/random/background](#get-apirandombackground) - [GET /api/presets](#get-apipresets) + - [GET /api/dl\_fields](#get-apidl_fields) + - [PUT /api/dl\_fields](#put-apidl_fields) - [PUT /api/presets](#put-apipresets) - [GET /api/conditions](#get-apiconditions) - [PUT /api/conditions](#put-apiconditions) + - [POST /api/conditions/test](#post-apiconditionstest) - [GET /api/logs](#get-apilogs) - [GET /api/notifications](#get-apinotifications) - [PUT /api/notifications](#put-apinotifications) + - [POST /api/yt-dlp/archive\_id/](#post-apiyt-dlparchive_id) - [POST /api/notifications/test](#post-apinotificationstest) + - [GET /api/yt-dlp/options](#get-apiyt-dlpoptions) + - [POST /api/system/shutdown](#post-apisystemshutdown) + - [GET /api/dev/loop](#get-apidevloop) + - [GET /api/dev/pip](#get-apidevpip) - [Error Responses](#error-responses) --- @@ -348,6 +361,46 @@ or an error: --- +### DELETE /api/history/{id}/archive +**Purpose**: Remove an item from archive file, allowing it to be re-downloaded. + +**Path Parameter**: +- `id`: Item ID from the history. + +**Response**: +```json +{ "message": "item '' removed from '<archive_file>' archive." } +``` +or an error: +```json +{ "error": "text" } +``` + +- `400 Bad Request` if archive is not configured or parameters are invalid. +- `404 Not Found` if the item or archive entry is not found. + +--- + +### POST /api/history/{id}/archive +**Purpose**: Add item to the archive file preventing it from being downloaded. + +**Path Parameter**: +- `id`: Item ID from the history. + +**Response**: +```json +{ "message": "item '<archive_id>' archived in file '<archive_file>'." } +``` +or an error: +```json +{ "error": "text" } +``` + +- `404 Not Found` if the item or archive file does not exist. +- `409 Conflict` if the item is already archived. + +--- + ### GET /api/tasks **Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager. @@ -420,6 +473,26 @@ or on error --- +### POST /api/tasks/{id}/mark +**Purpose**: Mark all entries associated with a scheduled task as downloaded. + +**Path Parameter**: +- `id`: Task ID. + +**Response**: +```json +{ "message": "..." } +``` +or +```json +{ "error": "..." } +``` + +- `400 Bad Request` if id is missing or invalid. +- `404 Not Found` if the task does not exist. + +--- + ### GET /api/player/playlist/{file:.*}.m3u8 **Purpose**: Generate a playlist for a given local media file. @@ -577,22 +650,60 @@ Binary image data with the appropriate `Content-Type`. --- -### GET /api/yt-dlp/archive/recheck -**Purpose**: Recheck manual archive entries to see if become available or not. +### POST /api/file/action/{path:.*} +**Purpose**: Perform a file browser action on a file or directory. + +**Path Parameter**: +- `path`: Base path (relative to `download_path`) to operate under. Use `/` for root. + +**Body**: +```json +{ "action": "rename|delete|move|directory", ... } +``` +Actions and required fields: +- `rename`: `{ "new_name": "<name>" }` +- `delete`: no extra fields +- `move`: `{ "new_path": "<dir-relative-to-download_path>" }` +- `directory`: `{ "new_dir": "<subdir/to/create>" }` + +**Response**: `200 OK` with empty body. + +or an error: +```json +{ "error": "text" } +``` + +- `403 Forbidden` if browser or actions are disabled. +- `400/404` for invalid paths or parameters. + +--- + +### POST /api/file/download +**Purpose**: Prepare a ZIP download of selected files (and detected sidecars). Returns a short-lived token. + +**Body**: +```json +[ "relative/path/file1.ext", "relative/path/file2.ext" ] +``` **Response**: ```json -[ - { - "id": "youtube_video_id", - "url": "https://youtube.com/watch?v=...", - "status": "available|unavailable|error", - "info": { ... } // video info if available - }, - ... -] +{ "token": "<uuid>", "files": ["relative/path/file1.ext", "..."] } ``` -- Returns `404 Not Found` if manual archive is not enabled or file doesn't exist. + +- `400 Bad Request` if the body is not a JSON array or contains no valid files. + +--- + +### GET /api/file/download/{token} +**Purpose**: Stream a ZIP file for the previously prepared download token. + +**Path Parameter**: +- `token`: Token returned by POST `/api/file/download`. + +**Response**: +- `200 OK` streaming response with `Content-Type: application/zip` and `Content-Disposition: attachment`. +- JSON error with `400 Bad Request` if the token is invalid/expired or no files available. --- @@ -633,6 +744,47 @@ Binary image data with appropriate `Content-Type` header. --- +### GET /api/dl_fields +**Purpose**: Retrieve the list of configured download fields. + +**Query Parameters (optional)**: +- `filter`: Comma-separated list of field names to include in each object. + +**Response**: +```json +[ + { "id": "<uuid>", "name": "...", ... }, + ... +] +``` + +--- + +### PUT /api/dl_fields +**Purpose**: Save the list of download fields. Replaces existing entries. + +**Body**: Array of objects. Required per-item fields: `name`. `id` is auto-generated if missing or invalid. +```json +[ + { "name": "...", "id": "<uuid>", ... }, + { "name": "..." } +] +``` + +**Response**: +```json +[ + { "id": "<uuid>", "name": "...", ... }, + ... +] +``` +or an error: +```json +{ "error": "text" } +``` + +--- + ### PUT /api/presets **Purpose**: Save/update download presets. @@ -716,6 +868,27 @@ Binary image data with appropriate `Content-Type` header. --- +### POST /api/conditions/test +**Purpose**: Evaluate a condition expression against info extracted from a URL. + +**Body**: +```json +{ "url": "https://...", "condition": "yt:duration > 600", "preset": "<optional-preset>" } +``` + +**Response**: +```json +{ + "status": true, + "condition": "...", + "data": { ... } // sanitized, possibly large +} +``` + +- `400 Bad Request` for invalid body, missing fields, or extractor failures. + +--- + ### GET /api/logs **Purpose**: Retrieve recent application logs (if file logging is enabled). @@ -824,6 +997,41 @@ Binary image data with appropriate `Content-Type` header. "allowedTypes": ["added", "completed", "error", "cancelled", "cleared", "log_info", "log_success", ...] } ``` +--- + +### POST /api/yt-dlp/archive_id/ +**Purpose**: Get the archive ID for a given URLs. +**Body**: Array of URLs. +```json +[ + "https://youtube.com/...", + "https://..." +} +``` + +**Response**: +```json +[ + { + "index": "index_of_the_url_in_request_array", + "url": "the_url", + "id": "the_video_id_or_null_if_not_found", + "ie_key": "the_extractor_key_or_null_if_not_found", + "archive_id": "the_archive_id_or_null_if_not_found", + "error": "error_message_if_any_or_null" + }, + ... +] +``` + +or an error: +```json +{ + "error": "text" +} +``` + +- If the body is not a valid JSON array, returns `400 Bad Request`. --- @@ -840,6 +1048,58 @@ Binary image data with appropriate `Content-Type` header. --- +### GET /api/yt-dlp/options +**Purpose**: Get the current yt-dlp CLI options as a JSON object. + +**Response**: +```json +[ + { + "description": "Description of the option", + "flags":[ "--option", "-o" ], + "group": "Option Group", + "ignored": false, // true if this option is ignored by ytptube. + }, + ... +] +``` + +--- + +### POST /api/system/shutdown +**Purpose**: Gracefully shut down the application (native mode only). + +**Response**: +```json +{ "message": "The application shutting down." } +``` + +- `400 Bad Request` if not running in native mode. + +--- + +### GET /api/dev/loop +**Purpose**: Development-only. Show event loop details and running tasks. + +**Response**: +```json +{ "total_tasks": 1, "loop": "...", "tasks": [ { "task": "...", "stack": ["..."] } ] } +``` + +- `403 Forbidden` if not in development mode. + +--- + +### GET /api/dev/pip +**Purpose**: Development-only. Return installed versions for configured pip packages. + +**Response**: +```json +{ "package": "version-or-null", "...": null } +``` + +--- + ## Error Responses Most endpoints return standard error codes (`400`, `403`, `404`, `500`, etc.) and a JSON body on failure. For example: diff --git a/FAQ.md b/FAQ.md index b3cfd6bb..a5bbf97f 100644 --- a/FAQ.md +++ b/FAQ.md @@ -18,7 +18,6 @@ or the `environment:` section in `compose.yaml` file. | YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` | | YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` | | YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` | -| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` | | YTP_HOST | Which IP address to bind to | `0.0.0.0` | | YTP_PORT | Which port to bind to | `8081` | | YTP_LOG_LEVEL | Log level | `info` | @@ -27,10 +26,8 @@ or the `environment:` section in `compose.yaml` file. | YTP_ACCESS_LOG | Whether to log access to the web server | `true` | | YTP_DEBUG | Whether to turn on debug mode | `false` | | YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` | -| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` | | YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` | | YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` | -| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` | | YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` | | YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` | | YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` | @@ -197,27 +194,6 @@ you and also enable the fallback by using the follow extractor args Use this alternative extractor args in case the extractor fails to get the pot tokens from the bgutil provider server. For more information please visit [bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider) project. -# How to set global settings for yt-dlp? - -You can create a file named `ytdlp.cli` in the `/config` folder, and add your desired global settings there. - -> [!IMPORTANT] -> We strongly recommend to use presets instead of global settings, as presets are more convenient. - -The `ytdlp.cli` file usage is deprecated and likely will be removed in `v1.0.0` release. - -# Updating yt-dlp - -The engine which powers the actual video downloads in YTPTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video -sites regularly updated, frequent updates of yt-dlp are required to keep up. - -We have added the `YTP_YTDLP_AUTO_UPDATE` environment variable, which is enabled by default. This feature allows the -container to automatically update `yt-dlp` to the latest version whenever the container starts. If a new version is -available, it will be downloaded and applied automatically. To disable this automatic update, set the -`YTP_YTDLP_AUTO_UPDATE` environment variable to `false`. - -We will no longer release new versions of YTPTube for every new version of yt-dlp. - # Troubleshooting and submitting issues Before asking a question or submitting an issue for YTPTube, please remember that YTPTube is only a thin wrapper for diff --git a/app/library/DataStore.py b/app/library/DataStore.py index 435d2bcd..c08c9722 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -121,8 +121,8 @@ class DataStore: return items - def put(self, value: Download) -> Download: - if "error" == value.info.status: + def put(self, value: Download, no_notify: bool = False) -> Download: + if "error" == value.info.status and not no_notify: from app.library.Events import EventBus, Events asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error") @@ -181,16 +181,18 @@ class DataStore: except AttributeError: pass + encoded: str = stored.json() + self._connection.execute( sqlStatement.strip(), ( stored._id, str(type), stored.url, - stored.json(), + encoded, str(type), stored.url, - stored.json(), + encoded, datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"), ), ) diff --git a/app/library/Download.py b/app/library/Download.py index 25ce071c..b42a4dca 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -19,7 +19,6 @@ from .ffprobe import ffprobe from .ItemDTO import ItemDTO from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies from .ytdlp import YTDLP -from .YTDLPOpts import YTDLPOpts class Terminator: @@ -27,19 +26,19 @@ class Terminator: class NestedLogger: - debug_messages = ["[debug] ", "[download] "] + debug_messages: list[str] = ["[debug] ", "[download] "] - def __init__(self, logger: logging.Logger): - self.logger = logger + def __init__(self, logger: logging.Logger) -> None: + self.logger: logging.Logger = logger - def debug(self, msg: str): - levelno = logging.DEBUG if any(msg.startswith(x) for x in self.debug_messages) else logging.INFO + def debug(self, msg: str) -> None: + levelno: int = logging.DEBUG if any(msg.startswith(x) for x in self.debug_messages) else logging.INFO self.logger.log(level=levelno, msg=re.sub(r"^\[(debug|info)\] ", "", msg, flags=re.IGNORECASE)) - def error(self, msg): + def error(self, msg) -> None: self.logger.error(msg) - def warning(self, msg): + def warning(self, msg) -> None: self.logger.warning(msg) @@ -110,7 +109,6 @@ class Download: self.template = info.template self.template_chapter = info.template_chapter self.download_info_expires = int(config.download_info_expires) - self.preset = info.preset self.info = info self.id = info._id self.debug = bool(config.debug) @@ -125,14 +123,14 @@ class Download: self.temp_disabled = bool(config.temp_disabled) self.is_live = bool(info.is_live) or info.live_in is not None self.info_dict = info_dict - self.logger = logging.getLogger(f"Download.{info.id if info.id else info._id}") + self.logger: logging.Logger = logging.getLogger(f"Download.{info.id if info.id else info._id}") self.started_time = 0 - self.queue_time = datetime.now(tz=UTC) + self.queue_time: datetime = datetime.now(tz=UTC) self.logs = logs if logs else [] def _progress_hook(self, data: dict): if self.debug: - d_copy = deepcopy(data) + d_copy: dict = deepcopy(data) for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]: d_copy["info_dict"].pop(k, None) @@ -148,7 +146,7 @@ class Download: def _postprocessor_hook(self, data: dict): if self.debug: - d_copy = deepcopy(data) + d_copy: dict = deepcopy(data) for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]: d_copy["info_dict"].pop(k, None) @@ -181,10 +179,7 @@ class Download: try: params: dict = ( - YTDLPOpts.get_instance() - .preset(self.preset) - .add({"break_on_existing": True}) - .add_cli(args=self.info.cli, from_user=True) + self.info.get_ytdlp_opts() .add( config={ "color": "no_color", @@ -263,31 +258,30 @@ class Download: self.info_dict = info if self.is_live: - hasDeletedOptions = False deletedOpts: list = [] for opt in self.bad_live_options: if opt in params: params.pop(opt, None) - hasDeletedOptions = True deletedOpts.append(opt) - if hasDeletedOptions: + if len(deletedOpts) > 0: self.logger.warning( f"Live stream detected for '{self.info.title}', The following opts '{deletedOpts=}' have been deleted." ) if isinstance(self.info_dict, dict) and len(self.info_dict.get("formats", [])) < 1: - msg = f"Failed to extract any formats for '{self.info.url}'." + msg: str = f"Failed to extract any formats for '{self.info.url}'." if filtered_logs := extract_ytdlp_logs(self.logs): msg += " " + ", ".join(filtered_logs) raise ValueError(msg) # noqa: TRY301 self.logger.info( - f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" cookies="{bool(params.get("cookiefile"))}" started.' + f'Task {self.info.name()}, preset="{self.info.preset}", cookies="{bool(params.get("cookiefile"))}" started.' ) - self.logger.debug(f"Params before passing to yt-dlp. {params}") + if self.debug: + self.logger.debug(f"Params before passing to yt-dlp. {params}") params["logger"] = NestedLogger(self.logger) @@ -337,7 +331,7 @@ class Download: self.logger.error(f"Failed to delete cookie file: {cookie_file}. {e}") self.logger.info( - f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" cookies="{bool(params.get("cookiefile"))}" completed.' + f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.' ) async def start(self): @@ -361,7 +355,9 @@ class Download: ret = await asyncio.get_running_loop().run_in_executor(None, self.proc.join) - if self.final_update: + if self.final_update or self.cancelled: + if self.cancelled: + self.info.status = "cancelled" return ret self.status_queue.put(Terminator()) @@ -373,9 +369,11 @@ class Download: for i in range(drain_count): try: - self.logger.debug(f"(50/{i}) Draining the status queue...") + if self.debug: + self.logger.debug(f"(50/{i}) Draining the status queue...") if self.final_update: - self.logger.debug("(50/{i}) Draining stopped. Final update received.") + if self.debug: + self.logger.debug("(50/{i}) Draining stopped. Final update received.") break next_status = self.status_queue.get(timeout=0.1) if next_status is None or isinstance(next_status, Terminator): @@ -499,12 +497,10 @@ class Download: return if str(tmp_dir) == str(self.temp_dir): - self.logger.warning( - f"Attempted to delete video temp folder '{self.temp_path}', but it is the same as main temp folder." - ) + self.logger.warning(f"Refusing to delete video temp folder '{self.temp_path}' as it's temp root.") return - status = delete_dir(tmp_dir) + status: bool = delete_dir(tmp_dir) if by_pass: tmp_dir.mkdir(parents=True, exist_ok=True) self.logger.info(f"Temp folder '{self.temp_path}' emptied.") @@ -523,7 +519,7 @@ class Download: await self._notify.emit(Events.ITEM_UPDATED, data=self.info) return - self.tmpfilename = status.get("tmpfilename") + self.tmpfilename: str | None = status.get("tmpfilename") fl = None if "final_name" in status: @@ -560,7 +556,7 @@ class Download: if "downloaded_bytes" in status and status.get("downloaded_bytes", 0) > 0: self.info.downloaded_bytes = status.get("downloaded_bytes") - total = status.get("total_bytes") or status.get("total_bytes_estimate") + total: float | None = status.get("total_bytes") or status.get("total_bytes_estimate") if total: try: self.info.percent = status["downloaded_bytes"] / total * 100 @@ -617,7 +613,7 @@ class Download: return False if self.started_time < 1: - self.logger.debug(f"Download task '{self.info.title}: {self.info.id}' not started yet.") + self.logger.debug(f"Download task '{self.info.name()}' not started yet.") return False if int(time.time()) - self.started_time < 300: @@ -633,7 +629,7 @@ class Download: return True if self.info.status not in ["finished", "error", "cancelled", "downloading", "postprocessing"]: - status = self.info.status if self.info.status else "unknown" + status: str = self.info.status if self.info.status else "unknown" self.logger.warning( f"Download task '{self.info.title}: {self.info.id}' has been stuck in '{status}' state for '{int(time.time()) - self.started_time}' seconds." ) diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 772a6e38..67148ea3 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -29,17 +29,15 @@ from .Utils import ( dt_delta, extract_info, extract_ytdlp_logs, - is_downloaded, load_cookies, str_to_dt, ytdlp_reject, ) -from .YTDLPOpts import YTDLPOpts if TYPE_CHECKING: from app.library.Presets import Preset -LOG = logging.getLogger("DownloadQueue") +LOG: logging.Logger = logging.getLogger("DownloadQueue") class DownloadQueue(metaclass=Singleton): @@ -87,7 +85,7 @@ class DownloadQueue(metaclass=Singleton): self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency) @staticmethod - def get_instance(): + def get_instance() -> "DownloadQueue": """ Get the instance of the DownloadQueue. @@ -100,7 +98,7 @@ class DownloadQueue(metaclass=Singleton): return DownloadQueue._instance - def attach(self, _: web.Application): + def attach(self, _: web.Application) -> None: """ Attach the download queue to the application. @@ -136,13 +134,11 @@ class DownloadQueue(metaclass=Singleton): await self.done.test() return True - async def initialize(self): + async def initialize(self) -> None: """ Initialize the download queue. """ - LOG.info( - f"Using '{self.config.max_workers}' worker/s for downloading. Can be configured via `YTP_MAX_WORKERS` environment variable." - ) + LOG.info(f"Using '{self.config.max_workers}' workers for downloading.") asyncio.create_task(self._download_pool(), name="download_pool") async def start_items(self, ids: list[str]) -> dict[str, str]: @@ -158,11 +154,11 @@ class DownloadQueue(metaclass=Singleton): """ status: dict[str, str] = {"status": "ok"} started = False - tasks = [] + tasks: list = [] for item_id in ids: try: - item = self.queue.get(key=item_id) + item: Download = self.queue.get(key=item_id) except KeyError as e: status[item_id] = f"not found: {e!s}" status["status"] = "error" @@ -175,7 +171,7 @@ class DownloadQueue(metaclass=Singleton): continue item.info.auto_start = True - updated = self.queue.put(item) + updated: Download = self.queue.put(item) tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info)) tasks.append( self._notify.emit( @@ -209,11 +205,11 @@ class DownloadQueue(metaclass=Singleton): """ status: dict[str, str] = {"status": "ok"} - tasks = [] + tasks: list = [] for item_id in ids: try: - item = self.queue.get(key=item_id) + item: Download = self.queue.get(key=item_id) except KeyError as e: status[item_id] = f"not found: {e!s}" status["status"] = "error" @@ -231,7 +227,7 @@ class DownloadQueue(metaclass=Singleton): continue item.info.auto_start = False - updated = self.queue.put(item) + updated: Download = self.queue.put(item) tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info)) tasks.append( self._notify.emit( @@ -416,20 +412,20 @@ class DownloadQueue(metaclass=Singleton): LOG.debug(f"Entry id '{entry.get('id')}' url '{entry.get('webpage_url')} - {entry.get('url')}'.") try: - _item = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url")) - if _item is not None: - err_msg = f"Item '{_item.info.id}' - '{_item.info.title}' already exists. Removing from history." - LOG.warning(err_msg) - await self.clear([_item.info._id], remove_file=False) + _item: Download = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url")) + err_msg: str = f"Removing {_item.info.name()} from history list." + LOG.warning(err_msg) + await self.clear([_item.info._id], remove_file=False) except KeyError: pass try: - _item = self.queue.get(key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url"))) - if _item is not None: - err_msg = f"Item ID '{_item.info.id}' - '{_item.info.title}' already in download queue." - LOG.info(err_msg) - return {"status": "error", "msg": err_msg} + _item: Download = self.queue.get( + key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url")) + ) + err_msg: str = f"Item {_item.info.name()} is already in download queue." + LOG.info(err_msg) + return {"status": "error", "msg": err_msg} except KeyError: pass @@ -494,7 +490,7 @@ class DownloadQueue(metaclass=Singleton): nEvent = Events.ITEM_MOVED nStore = "history" nTitle = "Upcoming Premiere" if is_premiere else "Upcoming Live Stream" - nMessage = f"{'Premiere video' if is_premiere else 'Stream' } '{dlInfo.info.title}' is not available yet. {text_logs}" + nMessage = f"{'Premiere video' if is_premiere else 'Stream'} '{dlInfo.info.title}' is not available yet. {text_logs}" dlInfo.info.status = "not_live" dlInfo.info.msg = nMessage.replace(f" '{dlInfo.info.title}'", "") @@ -535,7 +531,7 @@ class DownloadQueue(metaclass=Singleton): LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}") dlInfo.info.error += f" Failed to parse live_in date '{release_in}'." else: - dlInfo.info.error += f" Delaying download by '{300+dl.extras.get('duration',0)}' seconds." + dlInfo.info.error += f" Delaying download by '{300 + dl.extras.get('duration', 0)}' seconds." nMessage = f"'{dlInfo.info.title}': '{dlInfo.info.error.strip()}'." @@ -602,10 +598,10 @@ class DownloadQueue(metaclass=Singleton): if event_type.startswith("url"): return await self.add(item=item.new_with(url=entry.get("url")), already=already) - if not event_type.startswith("video"): - return {"status": "error", "msg": f'Unsupported event type "{event_type}".'} + if event_type.startswith("video"): + return await self._add_video(entry=entry, item=item, logs=logs) - return await self._add_video(entry=entry, item=item, logs=logs) + return {"status": "error", "msg": f'Unsupported event type "{event_type}".'} async def add(self, item: Item, already: set | None = None): """ @@ -658,16 +654,15 @@ class DownloadQueue(metaclass=Singleton): "level": logging.WARNING, "name": "callback-logger", }, - **YTDLPOpts.get_instance().preset(name=item.preset).add_cli(args=item.cli, from_user=True).get_all(), + **item.get_ytdlp_opts().get_all(), } if yt_conf.get("external_downloader"): LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.") item.extras.update({"external_downloader": True}) - downloaded, id_dict = self._is_downloaded(file=yt_conf.get("download_archive", None), url=item.url) - if downloaded is True and id_dict: - message = f"'{id_dict.get('id')}': The URL '{item.url}' is already downloaded and recorded in archive." + if item.is_archived(): + message: str = f"The URL '{item.url}' is already downloaded and recorded in archive." LOG.error(message) await self._notify.emit(Events.LOG_INFO, title="Already Downloaded", message=message) return {"status": "error", "msg": message} @@ -686,7 +681,7 @@ class DownloadQueue(metaclass=Singleton): LOG.info(f"Checking '{item.url}' with {'cookies' if yt_conf.get('cookiefile') else 'no cookies'}.") - entry = await asyncio.wait_for( + entry: dict | None = await asyncio.wait_for( fut=asyncio.get_running_loop().run_in_executor( None, functools.partial( @@ -980,9 +975,9 @@ class DownloadQueue(metaclass=Singleton): self._active[entry.info._id] = entry await entry.start() - if entry.info.status not in ("finished", "skip"): + if entry.info.status not in ("finished", "skip", "cancelled"): if not entry.info.error: - entry.info.error = f"Download failed with status '{entry.info.status}'." + entry.info.error = f"Download ended with unexpected status '{entry.info.status}'." entry.info.status = "error" except Exception as e: entry.info.status = "error" @@ -1010,9 +1005,12 @@ class DownloadQueue(metaclass=Singleton): if entry.info.status == "finished" and entry.info.filename: nTitle = "Download Completed" nMessage = f"Completed '{entry.info.title}' download." + if entry.info.is_archivable and not entry.info.is_archived: + entry.info.is_archived = True + _tasks.append(self._notify.emit(Events.ITEM_COMPLETED, data=entry.info, title=nTitle, message=nMessage)) - self.done.put(value=entry) + self.done.put(entry) _tasks.append( self._notify.emit( Events.ITEM_MOVED, @@ -1029,23 +1027,6 @@ class DownloadQueue(metaclass=Singleton): if self.event: self.event.set() - def _is_downloaded(self, url: str, file: str | None = None) -> tuple[bool, dict | None]: - """ - Check if the url has been downloaded already. - - Args: - url (str): The url to check. - file (str | None): The archive file to check. - - Returns: - tuple: A tuple with the status of the operation and the id of the downloaded item. - - """ - if not url or not file: - return False, None - - return is_downloaded(file, url) - async def _check_for_stale(self): """ Monitor pool for stale downloads and cancel them if needed. @@ -1074,7 +1055,8 @@ class DownloadQueue(metaclass=Singleton): if self.is_paused() or self.done.empty(): return - LOG.debug("Checking history queue for queued live stream links.") + if self.config.debug: + LOG.debug("Checking history queue for queued live stream links.") time_now = datetime.now(tz=UTC) diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 99479fac..b45d5bcb 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -97,7 +97,7 @@ class HttpAPI: app.on_shutdown.append(self.on_shutdown) - def add_routes(self, app: web.Application): + def add_routes(self, app: web.Application) -> None: """ Add the routes to the application. @@ -133,7 +133,8 @@ class HttpAPI: elif "" == base_path or not routePath.rstrip("/").startswith(base_path.rstrip("/")): route.path = f"{base_path}/{route.path.lstrip('/')}" - LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.") + if self.config.debug: + LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.") app.router.add_route(route.method, route.path, handler=_handle(route.handler), name=route.name) diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index 61c2105e..28c41297 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -110,9 +110,10 @@ class HttpSocket: load_modules(self.rootPath, self.rootPath / "routes" / "socket") for route in get_routes(RouteType.SOCKET).values(): - LOG.debug( - f"Add ({route.name}) {route.method.value if isinstance(route.method,RouteType) else route.method}: {route.path}." - ) + if self.config.debug: + LOG.debug( + f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}." + ) self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path)) @staticmethod diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index 4a264e67..02d9ae17 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -6,9 +6,10 @@ from dataclasses import dataclass, field from email.utils import formatdate from typing import Any -from app.library.Utils import clean_item +from app.library.Utils import archive_add, archive_delete, archive_read, clean_item, get_archive_id +from app.library.YTDLPOpts import YTDLPOpts -LOG = logging.getLogger("ItemDTO") +LOG: logging.Logger = logging.getLogger("ItemDTO") @dataclass(kw_only=True) @@ -92,29 +93,6 @@ class Item: """ return Item.format({**self.serialize(), **kwargs}) - def __repr__(self): - from .config import Config - from .Utils import calc_download_path, strip_newline - - data = {} - for k, v in self.serialize().items(): - if not v and k not in ("auto_start"): - continue - - if k == "cli": - data[k] = strip_newline(v) - elif k == "extras": - data[k] = f"{len(v)} items" - elif k == "cookies": - data[k] = f"{len(v)}/chars" - elif k == "folder": - data[k] = calc_download_path(base_path=Config.get_instance().download_path, folder=v) - else: - data[k] = v - - items = "".join(f'{k}="{v}", ' for k, v in data.items() if v) - return f"Item({items.strip(', ')})" - @staticmethod def format(item: dict) -> "Item": """ @@ -137,16 +115,14 @@ class Item: msg = "url param is required." raise ValueError(msg) - data = { - "url": url, - } + data: dict[str, str] = {"url": url} - preset = item.get("preset") + preset: str | None = item.get("preset") if preset and isinstance(preset, str) and preset != Item._default_preset(): from .Presets import Presets if not Presets.get_instance().has(preset): - msg = f"Preset '{preset}' does not exist." + msg: str = f"Preset '{preset}' does not exist." raise ValueError(msg) data["preset"] = preset @@ -163,19 +139,19 @@ class Item: if "auto_start" in item and isinstance(item.get("auto_start"), bool): data["auto_start"] = bool(item.get("auto_start")) - extras = item.get("extras") + extras: dict | None = item.get("extras") if extras and isinstance(extras, dict) and len(extras) > 0: data["extras"] = extras if item.get("requeued") and isinstance(item.get("requeued"), bool): data["requeued"] = item.get("requeued") - cli = item.get("cli") + cli: str | None = item.get("cli") if cli and len(cli) > 2: from .Utils import arg_converter try: - removed_options = [] + removed_options: list = [] arg_converter(args=cli, level=True, removed_options=removed_options) if len(removed_options) > 0: LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url) @@ -187,6 +163,55 @@ class Item: return Item(**data) + def get_archive_id(self) -> str | None: + if not self.url: + return None + + idDict: dict = get_archive_id(self.url) + return idDict.get("archive_id") + + def get_ytdlp_opts(self) -> YTDLPOpts: + params: YTDLPOpts = YTDLPOpts.get_instance() + + if self.preset: + params = params.preset(name=self.preset) + + if self.cli: + params = params.add_cli(self.cli, from_user=True) + + return params + + def get_archive_file(self) -> str | None: + return self.get_ytdlp_opts().get_all().get("download_archive") + + def is_archived(self) -> bool: + archive_id: str | None = self.get_archive_id() + archive_file: str | None = self.get_archive_file() + return len(archive_read(archive_file, [archive_id])) > 0 if archive_file and archive_id else False + + def __repr__(self) -> str: + from .config import Config + from .Utils import calc_download_path, strip_newline + + data: dict = {} + for k, v in self.serialize().items(): + if not v and k not in ("auto_start"): + continue + + if k == "cli": + data[k] = strip_newline(v) + elif k == "extras": + data[k] = f"{len(v)} items" + elif k == "cookies": + data[k] = f"{len(v)}/chars" + elif k == "folder": + data[k] = calc_download_path(base_path=Config.get_instance().download_path, folder=v) + else: + data[k] = v + + items: str = "".join(f'{k}="{v}", ' for k, v in data.items() if v is not None) + return f"Item({items.strip(', ')})" + @dataclass(kw_only=True) class ItemDTO: @@ -196,30 +221,55 @@ class ItemDTO: """ _id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False) - + """ Unique identifier for the item. """ error: str | None = None + """ Error message if the item failed. """ id: str + """ The ID of the item yt-dlp """ title: str + """ The title of the item. """ url: str - quality: str | None = None - format: str | None = None + """ The URL of the item. """ preset: str = "default" + """ The preset to be used for the item. """ folder: str + """ The folder to save the item to. """ download_dir: str | None = None + """ The full path to the download directory. """ temp_dir: str | None = None + """ The full path to the temporary directory. """ status: str | None = None + """ The status of the item. """ cookies: str | None = None + """ The cookies to be used for the item. """ template: str | None = None + """ The output template to be used for the item. """ template_chapter: str | None = None + """ The output template for chapters to be used for the item. """ timestamp: float = field(default_factory=lambda: time.time_ns()) + """ The timestamp of the item. """ is_live: bool | None = None + """ If the item is a live stream. """ datetime: str = field(default_factory=lambda: str(formatdate(time.time()))) + """ The datetime of the item. """ live_in: str | None = None + """ The time until the live stream starts. """ file_size: int | None = None + """ The file size of the item. """ options: dict = field(default_factory=dict) + """ The options used for the item. """ extras: dict = field(default_factory=dict) + """ Extra data associated with the item. """ cli: str = "" + """ The command options for yt-dlp to be used for this download. """ auto_start: bool = True + """ If the item should be started automatically. """ + is_archivable: bool | None = None + """ If the item can be archived. """ + is_archived: bool | None = None + """ If the item has been archived. """ + archive_id: str | None = None + """ The archive ID of the item. """ # yt-dlp injected fields. tmpfilename: str | None = None @@ -232,7 +282,13 @@ class ItemDTO: speed: str | None = None eta: str | None = None + _recomputed: bool = False + _archive_file: str | None = None + def serialize(self) -> dict: + if "finished" == self.status and not self._recomputed: + self.archive_status() + item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields()) return item @@ -248,6 +304,104 @@ class ItemDTO: def name(self) -> str: return f'id="{self.id}", title="{self.title}"' + def get_archive_id(self) -> str | None: + """ + Get the archive ID for the download URL. + + Returns: + str | None: The archive ID if available, None otherwise. + + """ + if self.archive_id: + return self.archive_id + + if not self.url: + return None + + idDict: dict = get_archive_id(self.url) + self.archive_id = idDict.get("archive_id") + + return self.archive_id + + def get_ytdlp_opts(self) -> YTDLPOpts: + """ + Get the yt-dlp options for the item. + + Returns: + YTDLPOpts: The yt-dlp options for the item. + + """ + params: YTDLPOpts = YTDLPOpts.get_instance() + + if self.preset: + params = params.preset(name=self.preset) + + if self.cli: + params = params.add_cli(self.cli, from_user=True) + + return params + + def archive_status(self, force: bool = False) -> None: + if not force and (self._recomputed or not self.archive_id): + return + + if "finished" == self.status: + self._recomputed = True + + self.is_archivable = bool(self._archive_file) + if not self.is_archivable: + self.is_archived = False + else: + self.is_archived = len(archive_read(self._archive_file, [self.archive_id])) > 0 + + def get_archive_file(self) -> str | None: + """ + Get the archive file path from the yt-dlp options. + + Returns: + str | None: The archive file path if available, None otherwise. + + """ + if self._archive_file or self._recomputed or not self.archive_id: + return self._archive_file + + self._archive_file = self.get_ytdlp_opts().get_all().get("download_archive") + if self._archive_file: + self._archive_file = self._archive_file.strip() + + return self._archive_file + + def archive_add(self) -> bool: + """ + Archive the item by adding its archive ID to the download archive file. + + Returns: + bool: True if the item was archived, False otherwise. + + """ + if self.is_archived or not self.is_archivable or not self.archive_id or not self._archive_file: + return False + + self.is_archived = archive_add(self._archive_file, [self.archive_id]) + + return self.is_archived + + def archive_delete(self) -> bool: + """ + Remove the item's archive ID from the download archive file. + + Returns: + bool: True if the item was removed from the archive, False otherwise. + + """ + if not self.is_archivable or not self.is_archived or not self.archive_id or not self._archive_file: + return False + + archive_delete(self._archive_file, [self.archive_id]) + self.is_archived = False + + return True + @staticmethod def removed_fields() -> tuple: """Fields that once existed but are no longer used.""" @@ -261,4 +415,11 @@ class ItemDTO: "output_template_chapter", "config", "temp_path", + "_recomputed", + "_archive_file", ) + + def __post_init__(self): + self.get_archive_id() + self.get_archive_file() + self.archive_status() diff --git a/app/library/Presets.py b/app/library/Presets.py index ee6c149d..08d582d5 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -8,7 +8,6 @@ from typing import Any from aiohttp import web from .config import Config -from .encoder import Encoder from .Events import EventBus, Events from .Singleton import Singleton from .Utils import arg_converter, init_class @@ -92,6 +91,7 @@ class Preset: return self.__dict__ def json(self) -> str: + from .encoder import Encoder return Encoder().encode(self.serialize()) def get(self, key: str, default: Any = None) -> Any: diff --git a/app/library/Scheduler.py b/app/library/Scheduler.py index fd7c425b..e05a3031 100644 --- a/app/library/Scheduler.py +++ b/app/library/Scheduler.py @@ -126,7 +126,7 @@ class Scheduler(metaclass=Singleton): self._jobs[job_id] = job - LOG.debug(f"Added '{job_id}' to the scheduler.") + LOG.debug(f"Added '{job_id}' to the scheduler to run on '{timer}'.") return job_id diff --git a/app/library/Tasks.py b/app/library/Tasks.py index 00bdd1e8..33f70856 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -19,7 +19,7 @@ from .ItemDTO import Item from .Scheduler import Scheduler from .Services import Services from .Singleton import Singleton -from .Utils import extract_info, init_class, validate_url +from .Utils import archive_add, archive_delete, extract_info, init_class, validate_url from .YTDLPOpts import YTDLPOpts LOG: logging.Logger = logging.getLogger("tasks") @@ -47,51 +47,69 @@ class Task: def get(self, key: str, default: Any = None) -> Any: return self.serialize().get(key, default) - def mark(self) -> tuple[bool, str]: - if not self.url: - return False, "No URL found in task parameters." + def get_ytdlp_opts(self) -> YTDLPOpts: + params: YTDLPOpts = YTDLPOpts.get_instance() + + if self.preset: + params = params.preset(name=self.preset) - params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=self.preset) if self.cli: - params.add_cli(self.cli, from_user=True) + params = params.add_cli(self.cli, from_user=True) - params = params.get_all() - if not (_archive := params.get("download_archive", None)): - return False, "No archive file found in task parameters." + return params - _archive: Path = Path(_archive) - if not _archive.parent.exists(): - _archive.parent.mkdir(parents=True, exist_ok=True) + def mark(self) -> tuple[bool, str]: + ret = self._mark_logic() + if isinstance(ret, tuple): + return ret - if not _archive.exists(): - _archive.touch() + archive_file: Path = ret.get("file") + items: set[str] = ret.get("items", set()) - _info = extract_info(params, self.url, no_archive=True, follow_redirect=True) - if not _info or not isinstance(_info, dict): - return False, "Failed to extract information from URL." + if len(items) < 1 or not archive_add(archive_file, list(items)): + return (True, "No new items to mark as downloaded.") - if "playlist" != _info.get("_type"): - return False, "Expected a playlist type from extract_info." + return (True, f"Task '{self.name}' items marked as downloaded.") - archived_items: list[str] = [] - with _archive.open() as f: - for line in f: - line = line.strip() - if not line or not isinstance(line, str) or line.startswith("#"): - continue + def unmark(self) -> tuple[bool, str]: + ret: tuple[bool, str] | set[tuple[Path, set[str]]] = self._mark_logic() + if isinstance(ret, tuple): + return ret - archived_items.append(line) + archive_file: Path = ret.get("file") + items: set[str] = ret.get("items", set()) - def _process(item: dict) -> bool: - status = False + if len(items) < 1 or not archive_delete(archive_file, list(items)): + return (True, "No items to remove from archive file.") + + return (True, f"Removed '{self.name}' items from archive file.") + + def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]: + if not self.url: + return (False, "No URL found in task parameters.") + + params: dict = self.get_ytdlp_opts().get_all() + if not (archive_file := params.get("download_archive")): + return (False, "No archive file found.") + + archive_file: Path = Path(archive_file) + + ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=True) + if not ie_info or not isinstance(ie_info, dict): + return (False, "Failed to extract information from URL.") + + if "playlist" != ie_info.get("_type"): + return (False, "Expected a playlist type from extract_info.") + + items: set[str] = set() + + def _process(item: dict): for entry in item.get("entries", []): if not isinstance(entry, dict): continue if "playlist" == entry.get("_type"): - _status = _process(entry) - if status is False: - status = _status + _process(entry) continue if entry.get("_type") not in ("video", "url"): @@ -100,26 +118,13 @@ class Task: if not entry.get("id") or not entry.get("ie_key"): continue - archive_id = f'{entry.get("ie_key","").lower()} {entry.get("id")}' + archive_id: str = f"{entry.get('ie_key', '').lower()} {entry.get('id')}" - if archive_id in archived_items: - continue + items.add(archive_id) - archived_items.append(archive_id) - status = True + _process(ie_info) - return status - - updated = _process(_info) - - if not updated: - return True, "No new items to mark as downloaded." - - with _archive.open("a") as f: - for item in archived_items: - f.write(f"{item}\n") - - return True, f"Task '{self.name}' marked as downloaded. Updated archive file '{_archive}'." + return {"file": archive_file, "items": items} class Tasks(metaclass=Singleton): @@ -417,6 +422,7 @@ class Tasks(metaclass=Singleton): "folder": folder, "template": template, "cli": cli, + "auto_start": task.auto_start, } ) ) @@ -544,7 +550,11 @@ class HandleTask: if handler is None: return None - return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs) + try: + return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs) + except Exception as e: + LOG.exception(e) + raise def _discover(self) -> list[type]: import importlib diff --git a/app/library/Utils.py b/app/library/Utils.py index b1f74ad8..b01a6cac 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -13,7 +13,7 @@ from datetime import UTC, datetime, timedelta from functools import lru_cache from http.cookiejar import MozillaCookieJar from pathlib import Path -from typing import TypeVar +from typing import Any, TypeVar from Crypto.Cipher import AES from yt_dlp.utils import age_restricted, match_str @@ -63,7 +63,7 @@ REMOVE_KEYS: list = [ YTDLP_INFO_CLS: YTDLP = None -ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") +ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"} FILES_TYPE: list = [ {"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"}, @@ -203,7 +203,7 @@ def extract_info( if no_archive and "download_archive" in params: del params["download_archive"] - data = YTDLP(params=params).extract_info(url, download=False) + data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False) if data and follow_redirect and "_type" in data and "url" == data["_type"]: return extract_info( @@ -263,112 +263,6 @@ def merge_dict(source: dict, destination: dict) -> dict: return destination_copy -def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]: - """ - Check if the video is already downloaded. - - Args: - archive_file (str): Archive file path. - url (str): URL to check. - - Returns: - bool: True if the video is already downloaded. - dict: Video information. - - """ - idDict = {"id": None, "ie_key": None, "archive_id": None} - - if not url or not archive_file: - return (False, idDict) - - if not Path(archive_file).exists(): - return (False, idDict) - - idDict = get_archive_id(url=url) - - if idDict.get("archive_id"): - with open(archive_file) as f: - for line in f: - if idDict["archive_id"] in line: - return (True, idDict) - - return (False, idDict) - - -def remove_from_archive(archive_file: str | Path, url: str) -> bool: - """ - Remove the downloaded video record from the archive file. - - Args: - archive_file (str): Archive file path. - url (str): URL to check and remove. - - Returns: - bool: True if the record removed, False otherwise. - - """ - if not url or not archive_file: - return False - - archive_path: Path = Path(archive_file) if not isinstance(archive_file, Path) else archive_file - if not archive_path.exists(): - return False - - idDict = get_archive_id(url=url) - archive_id: str | None = idDict.get("archive_id") - - if not archive_id: - return False - - lines: list[str] = archive_path.read_text(encoding="utf-8").splitlines() - new_lines: list[str] = [line for line in lines if archive_id not in line] - - if len(lines) == len(new_lines): - return False - - archive_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") - return True - - -def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]: - """ - Load a JSON or JSON5 file and return the contents as a dictionary - - Args: - file (str): File path - check_type (type): Type to check the loaded file against. - - Returns tuple: - dict|list: Dictionary or list of the file contents. Empty dict if the file could not be loaded. - bool: True if the file was loaded successfully. - str: Error message if the file could not be loaded. - - """ - try: - with open(file) as json_data: - opts = json.load(json_data) - - if check_type: - assert isinstance(opts, check_type) - - return (opts, True, "") - except Exception: - with open(file) as json_data: - from pyjson5 import load as json5_load - - try: - opts = json5_load(json_data) - - if check_type: - assert isinstance(opts, check_type) - - return (opts, True, "") - except AssertionError: - return ({}, False, f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.") - except Exception as e: - return ({}, False, f"{e}") - - def check_id(file: Path) -> bool | str: """ Check if we are able to get an id from the file name. @@ -405,13 +299,9 @@ def check_id(file: Path) -> bool | str: @lru_cache(maxsize=512) def is_private_address(hostname: str) -> bool: - try: - ip = socket.gethostbyname(hostname) - ip_obj = ipaddress.ip_address(ip) - return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local - except socket.gaierror: - # Could not resolve - treat as invalid or restricted - return True + ip = socket.gethostbyname(hostname) + ip_obj = ipaddress.ip_address(ip) + return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local def validate_url(url: str, allow_internal: bool = False) -> bool: @@ -447,10 +337,20 @@ def validate_url(url: str, allow_internal: bool = False) -> bool: raise ValueError(msg) hostname: str | None = parsed_url.host - if allow_internal is False and (not hostname or is_private_address(hostname)): - msg = "Access to internal urls or private networks is not allowed." + if not hostname: + msg = "Invalid hostname." raise ValueError(msg) + if allow_internal is False: + try: + if is_private_address(hostname): + msg = "Access to internal urls or private networks is not allowed." + raise ValueError(msg) + except socket.gaierror as e: + LOG.error(f"Error resolving hostname '{hostname}': {e!s}") + msg = "Invalid hostname." + raise ValueError(msg) from e + return True @@ -489,6 +389,10 @@ def arg_converter( default_opts = _default_opts([]).ydl_opts + if args: + # important to ignore external config files. + args = "--ignore-config " + args + opts = yt_dlp.parse_options(shlex.split(args)).ydl_opts diff = {k: v for k, v in opts.items() if default_opts[k] != v} if not keep_defaults else opts.items() if "postprocessors" in diff: @@ -1101,7 +1005,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]: raise ValueError(msg) from e -def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | None]]: +def get_archive_id(url: str) -> dict[str, str | None]: """ Get the archive ID for a given URL. @@ -1109,8 +1013,11 @@ def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | N url (str): URL to check. Returns: - bool: True if the video is already downloaded. - dict: Video information. + dict: { + "id": str | None, + "ie_key": str | None, + "archive_id": str | None + } """ global YTDLP_INFO_CLS # noqa: PLW0603 @@ -1279,14 +1186,11 @@ def load_modules(root_path: Path, directory: Path): package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".") - LOG.debug(f"Loading routes from '{directory}' with package name '{package_name}'.") - for _, name, _ in pkgutil.iter_modules([directory]): full_name: str = f"{package_name}.{name}" if name.startswith("_"): continue try: - LOG.debug(f"Loading module '{full_name}'.") importlib.import_module(full_name) except ImportError as e: LOG.error(f"Failed to import module '{full_name}': {e}") @@ -1449,3 +1353,145 @@ def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]: folders.extend(list_folders(entry, base, depth_limit)) return folders + + +def archive_add(file: str | Path, ids: list[str], skip_check: bool = False) -> bool: + """ + Add IDs to an archive file. + + Args: + file (str|Path): The archive file path. + ids (list[str]): List of IDs to add. + skip_check (bool): If True, skip checking for existing IDs. + + """ + if not ids or not file: + return False + + path: Path = Path(file) if not isinstance(file, Path) else file + existing_ids: set[str] = set() + + if not skip_check and path.exists(): + with path.open("r", encoding="utf-8") as f: + for line in f: + s = line.strip() + + if not s or len(s.split()) < 2: + continue + + existing_ids.add(s) + + new_ids: list[str] = [] + for raw in ids: + s: str = str(raw).strip() + + if not s or len(s.split()) < 2 or s in existing_ids or s in new_ids: + continue + + new_ids.append(s) + + if not new_ids: + return False + + try: + if not path.parent.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + + with path.open("a", encoding="utf-8") as f: + f.write("".join(f"{x}\n" for x in new_ids)) + + return True + except OSError as e: + LOG.error(f"Failed to write to archive file '{path!s}'. {e!s}") + return False + + +def archive_read(file: str | Path, ids: list[str] | None = None) -> list[str]: + """ + Read IDs from an archive file with optional filtering. + + - If `ids` is empty, return all IDs present in the archive file. + - If `ids` is provided, return only the ids that exist in the archive file, + + Args: + file (str|Path): The archive file path. + ids (list[str]): Optional list of IDs to query; empty list returns all. + + Returns: + list[str]: List of ids found in the archive file filtered by `ids` if provided. + + """ + if not file: + return [] + + path: Path = Path(file) if not isinstance(file, Path) else file + if not file or not path.exists(): + return [] + + ids_set: set[str] | None = ( + {s.strip() for s in ids if str(s).strip() and len(str(s).strip().split()) >= 2} if ids else None + ) + + found: list[str] = [] + with path.open("r", encoding="utf-8") as f: + for line in f: + s: str = line.strip() + + if not s or len(s.split()) < 2: + continue + + if ids_set is None or s in ids_set: + found.append(s) + + return found + + +def archive_delete(file: str | Path, ids: list[str]) -> bool: + """ + Delete the given IDs from an archive file. + + Args: + file (str|Path): The archive file path. + ids (list[str]): List of IDs to remove. + + Returns: + bool: True if deletion succeeded (or nothing to do), False on error. + + """ + if not file or not ids: + return False + + path: Path = Path(file) if not isinstance(file, Path) else file + + if not path.exists(): + return False + + remove_ids: set[str] = {x.strip() for x in ids if str(x).strip() and len(str(x).strip().split()) >= 2} + if not remove_ids: + return True + + changed = False + kept_lines: list[str] = [] + removed_ids: list[str] = [] + with path.open("r", encoding="utf-8") as f: + for line in f: + s: str = line.strip() + + if not s or len(s.split()) < 2: + changed = True + continue + + if s in remove_ids: + changed = True + removed_ids.append(s) + continue + + kept_lines.append(line) + + if not changed: + return True + + with path.open("w", encoding="utf-8") as f: + f.writelines(kept_lines) + + return True diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index fc3fafe7..cd33bfb1 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -200,7 +200,8 @@ class YTDLPOpts: if data["format"] == "-best": data["format"] = data["format"][1:] - LOG.debug(f"Final yt-dlp options: '{data!s}'.") + if self._config.debug: + LOG.debug(f"Final yt-dlp options: '{data!s}'.") return data diff --git a/app/library/config.py b/app/library/config.py index a94d2e1c..dc3d68b0 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -103,9 +103,6 @@ class Config: archive_file: str = "{config_path}/archive.log" """The path to the download archive file.""" - manual_archive: str = "{config_path}/archive.manual.log" - """The path to the manual archive file.""" - apprise_config: str = "{config_path}/apprise.yml" """The path to the Apprise configuration file.""" @@ -387,34 +384,20 @@ class Config: except Exception as e: LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}") - ytdl_options = {} opts_file: Path = Path(self.config_path) / "ytdlp.cli" if opts_file.exists() and opts_file.stat().st_size > 2: - LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.") + LOG.warning( + "Usage of 'ytdlp.cli' global config file is deprecated and will be removed in future releases. please migrate to presets." + ) with open(opts_file) as f: self.ytdlp_cli = f.read().strip() if self.ytdlp_cli: self._ytdlp_cli_mutable = self.ytdlp_cli try: - removed_options = [] - ytdl_options = arg_converter(args=self.ytdlp_cli, level=True, removed_options=removed_options) - - try: - LOG.debug("Parsed yt-dlp cli options '%s'.", ytdl_options) - except Exception: - pass - - if len(removed_options) > 0: - LOG.warning( - "Removed the following options: '%s' from '%s'", ", ".join(removed_options), opts_file - ) + arg_converter(args=self.ytdlp_cli, level=True) except Exception as e: msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'." raise ValueError(msg) from e - else: - LOG.warning(f"Empty yt-dlp custom options file '{opts_file}'.") - else: - LOG.info(f"No yt-dlp custom options found at '{opts_file}'.") self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}" @@ -424,7 +407,7 @@ class Config: LOG.info(f"Creating archive file '{archive_file}'.") archive_file.touch(exist_ok=True) - LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}'.") + LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}' by default.") self._ytdlp_cli_mutable += f"\n--download-archive {archive_file.as_posix()!s}" if self.temp_keep: @@ -519,6 +502,16 @@ class Config: return "production" == self.app_env def get_ytdlp_args(self) -> dict: + """ + Get the yt-dlp command line options as a dictionary. + + Returns: + dict: The yt-dlp command line options. + + Deprecated: + Usage of global ytdlp.cli file is deprecated, please use presets instead. + + """ try: return arg_converter(args=self._ytdlp_cli_mutable, level=True) except Exception as e: @@ -537,6 +530,7 @@ class Config: ytdlp_args = self.get_ytdlp_args() + # TODO: this doesn't make sense, as each item might have it's own archive file or none at all. if not data.get("keep_archive", False) and ytdlp_args.get("download_archive", None): data["keep_archive"] = True @@ -544,7 +538,7 @@ class Config: return data @staticmethod - def _ytdlp_version(): + def _ytdlp_version() -> str: try: from yt_dlp.version import __version__ as YTDLP_VERSION diff --git a/app/library/encoder.py b/app/library/encoder.py index 5f292d64..d16aaf12 100644 --- a/app/library/encoder.py +++ b/app/library/encoder.py @@ -5,8 +5,6 @@ from pathlib import Path from yt_dlp.networking.impersonate import ImpersonateTarget from yt_dlp.utils import DateRange -from .ItemDTO import ItemDTO - class Encoder(json.JSONEncoder): """ @@ -16,6 +14,8 @@ class Encoder(json.JSONEncoder): """ def default(self, o): + from .ItemDTO import ItemDTO + if isinstance(o, Path): return str(o) diff --git a/app/library/task_handlers/youtube.py b/app/library/task_handlers/youtube.py index 97552e2d..eae2b340 100644 --- a/app/library/task_handlers/youtube.py +++ b/app/library/task_handlers/youtube.py @@ -1,18 +1,19 @@ import asyncio import logging import re -from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +from xml.etree.ElementTree import Element from app.library.config import Config from app.library.DownloadQueue import DownloadQueue from app.library.Events import EventBus, Events from app.library.ItemDTO import Item, ItemDTO from app.library.Tasks import Task -from app.library.Utils import is_downloaded -from app.library.YTDLPOpts import YTDLPOpts +from app.library.Utils import archive_read if TYPE_CHECKING: + from xml.etree.ElementTree import Element + from app.library.Download import Download LOG: logging.Logger = logging.getLogger(__name__) @@ -25,24 +26,22 @@ EventBus.get_instance().subscribe( class YoutubeHandler: - queued_ids: set[str] = set() + queued: set[str] = set() failure_count: dict[str, int] = {} FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}" - FEED_PLAYLIST = "https://www.youtube.com/feeds/videos.xml?playlist_id={id}" CHANNEL_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$") PLAYLIST_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:playlist\?list=(?P<id>[A-Za-z0-9_-]+)|).*$") @staticmethod - def can_handle(task: Task, config: Config) -> bool: - has, _ = YoutubeHandler.has_archive(task, config) - if not has: - LOG.debug(f"Task '{task.id}: {task.name}' does not have an archive file configured.") + def can_handle(task: Task) -> bool: + if not task.get_ytdlp_opts().get_all().get("download_archive"): + LOG.debug(f"Task '{task.name}' does not have an archive file configured.") return False - LOG.debug(f"Checking if task '{task.id}: {task.name}' can handle YouTube URL: {task.url}") + LOG.debug(f"Checking if task '{task.name}' is using parsable YouTube URL: {task.url}") return YoutubeHandler.parse(task.url) is not None @staticmethod @@ -58,24 +57,24 @@ class YoutubeHandler: queue (DownloadQueue): The download queue instance. """ - archive_file, params = YoutubeHandler.has_archive(task, config) - if not archive_file: - LOG.error(f"Task '{task.id}: {task.name}' does not have an archive file configured.") + params: dict = task.get_ytdlp_opts().get_all() + if not (archive_file := params.get("download_archive")): + LOG.error(f"Task '{task.name}' does not have an archive file.") return import httpx from defusedxml.ElementTree import fromstring - parsed = YoutubeHandler.parse(task.url) + parsed: dict[str, str] | None = YoutubeHandler.parse(task.url) if not parsed: - LOG.error(f"Cannot parse '{task.id}: {task.name}' URL: {task.url}") + LOG.error(f"Cannot parse '{task.name}' URL: {task.url}") return - feed_url = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"]) + feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"]) - LOG.debug(f"Fetching '{task.id}: {task.name}' feed.") - opts = { - "proxy": params.get("proxy", None), + LOG.debug(f"Fetching '{task.name}' feed.") + opts: dict[str, Any] = { + "proxy": params.get("proxy"), "headers": { "User-Agent": params.get( "user_agent", @@ -84,109 +83,111 @@ class YoutubeHandler: }, } + try: + from httpx_curl_cffi import AsyncCurlTransport, CurlOpt + + opts["transport"] = AsyncCurlTransport( + impersonate="chrome", + default_headers=True, + curl_options={CurlOpt.FRESH_CONNECT: True}, + ) + opts.pop("headers", None) + except Exception: + pass + items: list = [] + has_items = False async with httpx.AsyncClient(**opts) as client: - response = await client.request(method="GET", url=feed_url, timeout=120) + response: httpx.Response = await client.request(method="GET", url=feed_url, timeout=120) response.raise_for_status() - root = fromstring(response.text) - ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"} + root: Element[str] = fromstring(response.text) + ns: dict[str, str] = { + "atom": "http://www.w3.org/2005/Atom", + "yt": "http://www.youtube.com/xml/schemas/2015", + } for entry in root.findall("atom:entry", ns): - vid_elem = entry.find("yt:videoId", ns) - title_elem = entry.find("atom:title", ns) - pub_elem = entry.find("atom:published", ns) - vid = vid_elem.text if vid_elem is not None else "" - title = title_elem.text if title_elem is not None else "" - published = pub_elem.text if pub_elem is not None else "" - items.append( - { - "id": vid, - "url": f"https://www.youtube.com/watch?v={vid}", - "title": title, - "published": published, - } - ) + vid_elem: Element[str] | None = entry.find("yt:videoId", ns) + vid: str | None = vid_elem.text if vid_elem is not None else "" + if not vid: + LOG.warning(f"Entry in '{task.name}' feed is missing a video ID. Skipping entry.") + continue + + archive_id: str = f"youtube {vid}" + url: str = f"https://www.youtube.com/watch?v={vid}" + + title_elem: Element[str] | None = entry.find("atom:title", ns) + title: str | None = title_elem.text if title_elem is not None else "" + + pub_elem: Element[str] | None = entry.find("atom:published", ns) + published: str | None = pub_elem.text if pub_elem is not None else "" + has_items = True + + if archive_id in YoutubeHandler.queued: + continue + + items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id}) if len(items) < 1: - LOG.warning(f"No entries found in '{task.id}: {task.name}' feed. URL: {feed_url}") + if not has_items: + LOG.warning(f"No entries found in '{task.name}' feed. URL: {feed_url}") + else: + LOG.debug(f"No new items found in '{task.name}' feed.") return filtered: list = [] + + downloaded: list[str] = archive_read(archive_file, [item["archive_id"] for item in items]) + for item in items: - status, _ = is_downloaded(archive_file, url=item["url"]) - if status is True or item["id"] in YoutubeHandler.queued_ids: + YoutubeHandler.queued.add(item["archive_id"]) + if item["archive_id"] in downloaded: continue if queue.queue.exists(url=item["url"]): - LOG.debug(f"Item '{item['id']}' exists in the queue.") - YoutubeHandler.queued_ids.add(item["id"]) continue try: done: Download = queue.done.get(url=item["url"]) if "error" != done.info.status: - LOG.debug(f"Item '{item['id']}' exists in the download history.") - YoutubeHandler.queued_ids.add(item["id"]) continue except KeyError: pass - YoutubeHandler.queued_ids.add(item["id"]) + if item["archive_id"] not in YoutubeHandler.failure_count: + YoutubeHandler.failure_count[item["archive_id"]] = 0 + filtered.append(item) if len(filtered) < 1: - LOG.debug(f"No new items found in '{task.id}: {task.name}' feed.") + LOG.debug(f"No new items found in '{task.name}' feed.") return - LOG.info(f"Found '{len(filtered)}' new items from '{task.id}: {task.name}' feed.") + LOG.info(f"Found '{len(filtered)}' new items from '{task.name}' feed.") - preset: str = str(task.preset or config.default_preset) - folder: str = task.folder if task.folder else "" - template: str = task.template if task.template else "" - cli: str = task.cli if task.cli else "" + rItem: Item = Item.format( + { + "url": feed_url, + "preset": str(task.preset or config.default_preset), + "folder": task.folder if task.folder else "", + "template": task.template if task.template else "", + "cli": task.cli if task.cli else "", + "auto_start": task.auto_start, + "extras": {"source_task": task.id}, + } + ) try: await asyncio.gather( - *[ - notify.emit( - Events.ADD_URL, - data=Item.format( - {"url": item["url"], "preset": preset, "folder": folder, "template": template, "cli": cli} - ).serialize(), - ) - for item in filtered - ] + *[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered] ) except Exception as e: - LOG.error(f"Error while adding items from '{task.id}: {task.name}'. {e!s}") + LOG.exception(e) + LOG.error(f"Error while adding items from '{task.name}'. {e!s}") return - @staticmethod - def has_archive(task: Task, config: Config) -> tuple[Path | None, dict]: - archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None - params: YTDLPOpts = YTDLPOpts.get_instance() - - if task.preset: - params.preset(name=task.preset) - - if task.cli: - params.add_cli(task.cli, from_user=True) - - params = params.get_all() - if user_archive_file := params.get("download_archive", None): - archive_file = Path(user_archive_file) - - if not archive_file: - return (None, params) - - if not archive_file.exists(): - archive_file.parent.mkdir(parents=True, exist_ok=True) - archive_file.touch(exist_ok=True) - - return (archive_file, params) - @staticmethod def parse(url: str) -> dict[str, str] | None: """ @@ -222,29 +223,33 @@ class YoutubeHandler: if not item or not isinstance(item, ItemDTO): return - cls.queued_ids.add(item.id) - - if item.id not in cls.queued_ids: + if not item.archive_id or not cls.failure_count.get(item.archive_id, None): + LOG.debug(f"Item '{item.name()}' not queued by the handler.") return - currentFailureCount: int = cls.failure_count.get(item.id, 0) + failCount: int = int(cls.failure_count.get(item.archive_id, 0)) - LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{currentFailureCount + 1}'.") - cls.queued_ids.remove(item.id) + LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{failCount + 1}'.") + if item.archive_id in cls.queued: + cls.queued.remove(item.archive_id) - cls.failure_count[item.id] = cls.failure_count.get(item.id, 0) + 1 + cls.failure_count[item.archive_id] = 1 + failCount @staticmethod - def tests() -> list[str]: + def tests() -> list[tuple[str, bool]]: """ - Return a list of test URLs to validate the parsing logic. + Test cases for the URL parser. + + Returns: + list[tuple[str, bool]]: A list of tuples containing the URL and expected result. + """ return [ - "https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN", - "https://youtube.com/c/MyCustomName", - "https://youtube.com/user/SomeUser123", - "https://youtube.com/@SomeHandle", - "https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ", - "https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ", - "https://youtube.com/watch?v=foo", + ("https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN", True), + ("https://youtube.com/c/MyCustomName", False), + ("https://youtube.com/user/SomeUser123", False), + ("https://youtube.com/@SomeHandle", False), + ("https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ", True), + ("https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ", True), + ("https://youtube.com/watch?v=foo", False), ] diff --git a/app/routes/api/archive.py b/app/routes/api/archive.py deleted file mode 100644 index cbd42f23..00000000 --- a/app/routes/api/archive.py +++ /dev/null @@ -1,168 +0,0 @@ -import logging -from datetime import UTC, datetime -from pathlib import Path -from typing import TYPE_CHECKING - -import anyio -from aiohttp import web -from aiohttp.web import Request, Response - -from app.library.config import Config -from app.library.Download import Download -from app.library.DownloadQueue import DownloadQueue -from app.library.router import route -from app.library.Utils import is_downloaded, remove_from_archive -from app.library.YTDLPOpts import YTDLPOpts - -if TYPE_CHECKING: - from library.Download import Download - -LOG: logging.Logger = logging.getLogger(__name__) - - -@route("DELETE", r"api/archive/{id}", "archive.remove") -async def archive_remove(request: Request, queue: DownloadQueue, config: Config) -> Response: - """ - Remove an item from the archive. - - Args: - request (Request): The request object. - queue (DownloadQueue): The download queue instance. - config (Config): The configuration instance. - - Returns: - Response: The response object. - - """ - item = None - try: - data: dict | None = await request.json() - except Exception: - data = {} - - title: str = "" - - url: str | None = data.get("url", None) if data else None - - if not url: - id: str = request.match_info.get("id") - if not id: - return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) - - try: - item: Download | None = queue.done.get_by_id(id) - if not item: - return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) - - url = item.info.url - title = f" '{item.info.title}'" - except KeyError: - return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) - - if config.manual_archive: - remove_from_archive(archive_file=Path(config.manual_archive), url=url) - - archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None - if item: - params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset) - if item.info.cli: - params.add_cli(item.info.cli, from_user=True) - - params = params.get_all() - if user_file := params.get("download_archive", None): - archive_file = Path(user_file) - - if not archive_file: - return web.json_response( - data={ - "error": "Archive file is not configured." if not config.keep_archive else "Archive file is not set." - }, - status=web.HTTPBadRequest.status_code, - ) - - if not archive_file.exists(): - return web.json_response( - data={"error": f"Archive file '{archive_file}' does not exist."}, - status=web.HTTPNotFound.status_code, - ) - - if not remove_from_archive(archive_file=archive_file, url=url): - return web.json_response( - data={"error": f"item{title} not found in '{archive_file}' archive."}, - status=web.HTTPNotFound.status_code, - ) - - return web.json_response( - data={"message": f"item{title} removed from '{archive_file}' archive."}, - status=web.HTTPOk.status_code, - ) - - -@route("POST", r"api/archive/{id}", "archive.item") -async def archive_item(request: Request, queue: DownloadQueue, config: Config): - """ - Manually mark an item as archived. - - Args: - request (Request): The request object. - queue (DownloadQueue): The download queue instance. - config (Config): The configuration instance. - - Returns: - Response: The response object. - - """ - id: str = request.match_info.get("id") - - if not id: - return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) - - try: - item: Download | None = queue.done.get_by_id(id) - if not item: - return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) - except KeyError: - return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) - - if config.manual_archive: - manual_archive = Path(config.manual_archive) - if manual_archive.exists(): - exists, idDict = is_downloaded(manual_archive, item.info.url) - if exists is False and idDict.get("archive_id"): - async with await anyio.open_file(manual_archive, "a") as f: - await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n") - - params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset) - if item.info.cli: - params.add_cli(item.info.cli, from_user=True) - - params = params.get_all() - - user_file: str | None = params.get("download_archive", None) - archive_file: Path = Path(user_file) if user_file else Path(config.archive_file) - if not archive_file.exists(): - return web.json_response( - data={"error": f"Archive file '{archive_file}' does not exist."}, - status=web.HTTPNotFound.status_code, - ) - - exists, idDict = is_downloaded(archive_file, item.info.url) - item_id: str | None = idDict.get("archive_id") - if not item_id: - return web.json_response( - data={"error": "item does not have an archive ID."}, status=web.HTTPBadRequest.status_code - ) - - if exists is True: - return web.json_response( - data={"error": f"item '{item_id}' already archived in file '{archive_file}'."}, - status=web.HTTPConflict.status_code, - ) - - async with await anyio.open_file(archive_file, "a") as f: - await f.write(f"{item_id}\n") - - return web.json_response( - data={"message": f"item '{item_id}' archived in file '{archive_file}'."}, - status=web.HTTPOk.status_code, - ) diff --git a/app/routes/api/history.py b/app/routes/api/history.py index 568ed480..2bd99639 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -7,6 +7,7 @@ from aiohttp import web from aiohttp.web import Request, Response from app.library.config import Config +from app.library.Download import Download from app.library.DownloadQueue import DownloadQueue from app.library.encoder import Encoder from app.library.Events import EventBus, Events @@ -98,7 +99,7 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, co if not item.info: return web.json_response(data={"error": "item has no info."}, status=web.HTTPNotFound.status_code) - info = { + info: dict = { **item.info.serialize(), "ffprobe": {}, } @@ -256,3 +257,119 @@ async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) -> response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")}) return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode) + + +@route("POST", r"api/history/{id}/archive", "history.item.archive.add") +async def item_archive_add(request: Request, queue: DownloadQueue, notify: EventBus) -> Response: + """ + Manually mark an item as archived. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + notify (EventBus): The event bus instance. + + Returns: + Response: The response object. + + """ + if not (id := request.match_info.get("id")): + return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) + + try: + item: Download | None = queue.done.get_by_id(id) + if not item: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + except KeyError: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + + if not item.info.is_archivable: + return web.json_response( + data={"error": f"item '{item.info.title}' does not have an archive file."}, + status=web.HTTPBadRequest.status_code, + ) + + if not item.info.archive_id: + return web.json_response( + data={"error": f"item '{item.info.title}' does not have an archive ID."}, + status=web.HTTPBadRequest.status_code, + ) + + if item.info.is_archived: + return web.json_response( + data={"error": f"item '{item.info.title}' already archived."}, + status=web.HTTPConflict.status_code, + ) + + if not item.info.archive_add(): + return web.json_response( + data={"error": f"item '{item.info.title}' could not be added to archive."}, + status=web.HTTPInternalServerError.status_code, + ) + + item.info.archive_status(force=True) + queue.done.put(item, no_notify=True) + await notify.emit(Events.ITEM_UPDATED, data=item.info) + + return web.json_response( + data={"message": f"item '{item.info.title}' archived."}, + status=web.HTTPOk.status_code, + ) + + +@route("DELETE", r"api/history/{id}/archive", "history.item.archive.delete") +async def item_archive_delete(request: Request, queue: DownloadQueue, notify: EventBus) -> Response: + """ + Remove an item from the archive. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + notify (EventBus): The event bus instance. + + Returns: + Response: The response object. + + """ + if not (id := request.match_info.get("id")): + return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) + + try: + item: Download | None = queue.done.get_by_id(id) + if not item: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + except KeyError: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + + if not item.info.is_archivable: + return web.json_response( + data={"error": f"item '{item.info.title}' does not have an archive file."}, + status=web.HTTPBadRequest.status_code, + ) + + if not item.info.archive_id: + return web.json_response( + data={"error": f"item '{item.info.title}' does not have an archive ID."}, + status=web.HTTPBadRequest.status_code, + ) + + if not item.info.is_archived: + return web.json_response( + data={"error": f"item '{item.info.title}' not archived."}, + status=web.HTTPConflict.status_code, + ) + + if not item.info.archive_delete(): + return web.json_response( + data={"error": f"item '{item.info.title}' not found in archive file."}, + status=web.HTTPInternalServerError.status_code, + ) + + item.info.archive_status(force=True) + queue.done.put(item, no_notify=True) + await notify.emit(Events.ITEM_UPDATED, data=item.info) + + return web.json_response( + data={"message": f"item '{item.info.title}' removed from archive."}, + status=web.HTTPOk.status_code, + ) diff --git a/app/routes/api/images.py b/app/routes/api/images.py index 6e5f14b6..68cd371e 100644 --- a/app/routes/api/images.py +++ b/app/routes/api/images.py @@ -43,19 +43,19 @@ async def get_thumbnail(request: Request, config: Config) -> Response: return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code) try: + ytdlp_args = config.get_ytdlp_args() opts = { "proxy": ytdlp_args.get("proxy", None), "headers": { - "User-Agent": ytdlp_args.get( - "user_agent", request.headers.get("User-Agent", random_user_agent()) - ), + "User-Agent": ytdlp_args.get("user_agent", request.headers.get("User-Agent", random_user_agent())), }, } async with httpx.AsyncClient(**opts) as client: LOG.debug(f"Fetching thumbnail from '{url}'.") - response = await client.request(method="GET", url=url) + response = await client.request(method="GET", url=url, follow_redirects=True) + return web.Response( body=response.content, headers={ @@ -122,6 +122,17 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp "User-Agent": ytdlp_args.get("user_agent", random_user_agent()), }, } + try: + from httpx_curl_cffi import AsyncCurlTransport, CurlOpt + + opts["transport"] = AsyncCurlTransport( + impersonate="chrome", + default_headers=True, + curl_options={CurlOpt.FRESH_CONNECT: True}, + ) + opts.pop("headers", None) + except Exception: + pass async with httpx.AsyncClient(**opts) as client: if backend.startswith("https://www.bing.com/HPImageArchive.aspx"): diff --git a/app/routes/api/system.py b/app/routes/api/system.py index 84a510c1..123a631e 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -1,11 +1,13 @@ import asyncio import logging +import time from aiohttp import web from aiohttp.web import Request, Response from aiohttp.web_runner import GracefulExit from app.library.config import Config +from app.library.DownloadQueue import DownloadQueue from app.library.encoder import Encoder from app.library.Events import EventBus, Events from app.library.router import route @@ -13,10 +15,80 @@ from app.library.router import route LOG: logging.Logger = logging.getLogger(__name__) +@route("POST", "api/system/pause", "system.pause") +async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: + """ + Pause non-active downloads. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + encoder (Encoder): The encoder instance. + notify (EventBus): The event bus instance. + + Returns: + Response: The response object. + + """ + if queue.is_paused(): + return web.json_response( + {"message": "Non-active downloads are already paused."}, + status=web.HTTPNotAcceptable.status_code, + dumps=encoder.encode, + ) + + queue.pause() + + msg = "Non-active downloads have been paused." + await notify.emit( + Events.PAUSED, + data={"paused": True, "at": time.time()}, + title="Downloads Paused", + message=msg, + ) + + return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode) + + +@route("POST", "api/system/resume", "system.resume") +async def downloads_resume(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: + """ + Resume non-active downloads. + + Args: + request (Request): The request object. + queue (DownloadQueue): The download queue instance. + encoder (Encoder): The encoder instance. + notify (EventBus): The event bus instance. + + Returns: + Response: The response object. + + """ + if not queue.is_paused(): + return web.json_response( + {"message": "Non-active downloads are not paused."}, + status=web.HTTPNotAcceptable.status_code, + dumps=encoder.encode, + ) + + queue.resume() + + msg = "Resumed all downloads." + await notify.emit( + Events.RESUMED, + data={"paused": False, "at": time.time()}, + title="Downloads Resumed", + message=msg, + ) + + return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode) + + @route("POST", "api/system/shutdown", "system.shutdown") async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response: """ - Get the presets. + Initiate application shutdown. Args: request (Request): The request object. diff --git a/app/routes/api/tasks.py b/app/routes/api/tasks.py index ac7b7886..203fb4d5 100644 --- a/app/routes/api/tasks.py +++ b/app/routes/api/tasks.py @@ -94,7 +94,7 @@ async def tasks_add(request: Request, encoder: Encoder) -> Response: @route("POST", "api/tasks/{id}/mark", "tasks_mark") -async def mark_task(request: Request, encoder: Encoder) -> Response: +async def task_mark(request: Request, encoder: Encoder) -> Response: """ Mark all items from task as downloaded. @@ -111,9 +111,9 @@ async def mark_task(request: Request, encoder: Encoder) -> Response: if not task_id: return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code) - tasks = Tasks.get_instance() + tasks: Tasks = Tasks.get_instance() try: - task = tasks.get(task_id) + task: Task | None = tasks.get(task_id) if not task: return web.json_response( data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code @@ -126,3 +126,38 @@ async def mark_task(request: Request, encoder: Encoder) -> Response: return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode) except ValueError as e: return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code) + + +@route("DELETE", "api/tasks/{id}/mark", "tasks_unmark") +async def task_unmark(request: Request, encoder: Encoder) -> Response: + """ + Remove All tasks items from download archive. + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object + + """ + task_id: str = request.match_info.get("id", None) + + if not task_id: + return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code) + + tasks: Tasks = Tasks.get_instance() + try: + task: Task | None = tasks.get(task_id) + if not task: + return web.json_response( + data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code + ) + + _status, _message = task.unmark() + if not _status: + return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code) + + return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode) + except ValueError as e: + return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code) diff --git a/app/routes/api/yt_dlp.py b/app/routes/api/yt_dlp.py index 49291c20..1b56a0db 100644 --- a/app/routes/api/yt_dlp.py +++ b/app/routes/api/yt_dlp.py @@ -1,12 +1,9 @@ -import asyncio import json import logging import time from collections import OrderedDict -from pathlib import Path from typing import Any -import anyio from aiohttp import web from aiohttp.web import Request, Response @@ -14,7 +11,14 @@ from app.library.cache import Cache from app.library.config import Config from app.library.Presets import Presets from app.library.router import route -from app.library.Utils import REMOVE_KEYS, arg_converter, extract_info, validate_url +from app.library.Utils import ( + REMOVE_KEYS, + archive_read, + arg_converter, + extract_info, + get_archive_id, + validate_url, +) from app.library.YTDLPOpts import YTDLPOpts LOG: logging.Logger = logging.getLogger(__name__) @@ -199,6 +203,14 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: "expires": time.time() + 300, } + is_archived = False + if (archive_file := ytdlp_opts.get("download_archive")) and ( + archive_id := get_archive_id(url=url).get("archive_id") + ): + is_archived: bool = len(archive_read(archive_file, [archive_id])) > 0 + + data["is_archived"] = is_archived + data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1])))) cache.set(key=key, value=data, ttl=300) @@ -217,87 +229,6 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: ) -@route("GET", "api/yt-dlp/archive/recheck/", "archive_recheck") -async def archive_recheck(cache: Cache) -> Response: - """ - Recheck the manual archive entries. - - Args: - cache (Cache): The cache instance. - - Returns: - Response: The response object - - """ - config: Config = Config.get_instance() - if not config.manual_archive: - return web.json_response(data={"error": "Manual archive is not enabled."}, status=web.HTTPNotFound.status_code) - - manual_archive = Path(config.manual_archive) - if not manual_archive.exists(): - return web.json_response( - data={"error": "Manual archive file not found.", "file": manual_archive}, - status=web.HTTPNotFound.status_code, - ) - - tasks: list = [] - response: list = [] - - def info_wrapper(id: str, url: str) -> tuple[str, dict]: - try: - return ( - id, - extract_info( - config={ - "proxy": config.get_ytdlp_args().get("proxy", None), - "simulate": True, - "dump_single_json": True, - }, - url=url, - no_archive=True, - ), - ) - except Exception as e: - return (id, {"error": str(e)}) - - async with await anyio.open_file(manual_archive) as f: - # line format is "youtube ID - at: ISO8601" - async for line in f: - line = line.strip() - - if not line or not line.startswith("youtube"): - continue - - id = line.split(" ")[1].strip() - - if not id: - continue - - url = f"https://www.youtube.com/watch?v={id}" - key = cache.hash(id) - - if cache.has(key): - data = cache.get(key) - response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False}) - continue - - tasks.append( - asyncio.get_event_loop().run_in_executor(None, lambda i=id, url=url: info_wrapper(id=i, url=url)) - ) - - if len(tasks) > 0: - results = await asyncio.gather(*tasks) - for data in results: - if not data: - continue - - id, info = data - cache.set(key=cache.hash(id), value=info, ttl=3600 * 6) - response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False}) - - return web.json_response(data=response, status=web.HTTPOk.status_code) - - @route("GET", "api/yt-dlp/options/", "get_options") async def get_options() -> Response: """ @@ -309,6 +240,37 @@ async def get_options() -> Response: """ from app.library.ytdlp import ytdlp_options - return web.json_response( - body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code - ) + return web.json_response(body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code) + + +@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids") +async def get_archive_ids(request: Request) -> Response: + """ + Get the yt-dlp CLI options. + + Returns: + Response: The response object with the yt-dlp CLI options. + + """ + from app.library.Utils import get_archive_id + + data = (await request.json()) if request.body_exists else None + if not data or not isinstance(data, list): + return web.json_response( + data={"error": "Invalid request. expecting list with URLs."}, + status=web.HTTPBadRequest.status_code, + ) + + response = [] + + for i, url in enumerate(data): + dct = {"index": i, "url": url} + try: + validate_url(url) + dct.update(get_archive_id(url)) + except ValueError as e: + dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)}) + + response.append(dct) + + return web.json_response(data=response, status=web.HTTPOk.status_code) diff --git a/app/routes/socket/history.py b/app/routes/socket/history.py index 1202deb6..39421469 100644 --- a/app/routes/socket/history.py +++ b/app/routes/socket/history.py @@ -1,43 +1,13 @@ import logging -import time -from datetime import UTC, datetime -from pathlib import Path -import anyio - -from app.library.config import Config from app.library.DownloadQueue import DownloadQueue from app.library.Events import EventBus, Events from app.library.ItemDTO import Item from app.library.router import RouteType, route -from app.library.Utils import is_downloaded -from app.library.YTDLPOpts import YTDLPOpts LOG: logging.Logger = logging.getLogger(__name__) -@route(RouteType.SOCKET, "pause", "pause_downloads") -async def pause(notify: EventBus, queue: DownloadQueue): - queue.pause() - await notify.emit( - Events.PAUSED, - data={"paused": True, "at": time.time()}, - title="Downloads Paused", - message="Non-active downloads have been paused.", - ) - - -@route(RouteType.SOCKET, "resume", "resume_downloads") -async def resume(notify: EventBus, queue: DownloadQueue): - queue.resume() - await notify.emit( - Events.RESUMED, - data={"paused": False, "at": time.time()}, - title="Downloads Resumed", - message="Resumed all downloads.", - ) - - @route(RouteType.SOCKET, "add_url", "add_url") async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict): data = data if isinstance(data, dict) else {} @@ -79,57 +49,6 @@ async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: di await queue.clear([id], remove_file=bool(data.get("remove_file", False))) -@route(RouteType.SOCKET, "archive_item", "archive_item") -async def archive_item(config: Config, data: dict): - if not isinstance(data, dict) or "url" not in data: - return - - params: YTDLPOpts = YTDLPOpts.get_instance() - - if "preset" in data and isinstance(data["preset"], str): - params.preset(name=data["preset"]) - - if "cli" in data and isinstance(data["cli"], str) and len(data["cli"]) > 1: - params.add_cli(data["cli"], from_user=True) - - params = params.get_all() - - file: str = params.get("download_archive", None) - - if not file: - return - - exists, idDict = is_downloaded(file, data["url"]) - if exists or "archive_id" not in idDict or idDict["archive_id"] is None: - return - - async with await anyio.open_file(file, "a") as f: - await f.write(f"{idDict['archive_id']}\n") - - manual_archive: str = config.manual_archive - if not manual_archive: - return - - manual_archive = Path(manual_archive) - - if not manual_archive.exists(): - manual_archive.touch(exist_ok=True) - - previouslyArchived = False - async with await anyio.open_file(manual_archive) as f: - async for line in f: - if idDict["archive_id"] in line: - previouslyArchived = True - break - - if not previouslyArchived: - async with await anyio.open_file(manual_archive, "a") as f: - await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n") - LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.") - else: - LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.") - - @route(RouteType.SOCKET, "item_start", "item_start") async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None: if not data: diff --git a/pyproject.toml b/pyproject.toml index 302dc87c..13df0d5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "apprise>=1.9.3", "bgutil-ytdlp-pot-provider>=1.2.1", "pycryptodome>=3.23.0", + "httpx-curl-cffi>=0.1.4", ] [tool.ruff] diff --git a/ui/app/components/DeprecatedNotice.vue b/ui/app/components/DeprecatedNotice.vue new file mode 100644 index 00000000..9cad0fe1 --- /dev/null +++ b/ui/app/components/DeprecatedNotice.vue @@ -0,0 +1,67 @@ +<template> + <Message :message_class="messageClass" :title="title" :icon="icon" :useClose="true" @close="() => dismissed = true" + v-if="!isDismissed"> + <slot /> + </Message> +</template> + +<script setup lang="ts"> +import { computed } from 'vue' +import { useStorage } from '@vueuse/core' +import Message from "~/components/Message.vue"; + +const props = withDefaults(defineProps<{ + version: string + storageKey?: string + title?: string + icon?: string + tone?: 'warning' | 'danger' | 'info' | 'success' +}>(), { + storageKey: 'deprecated-notice', + title: 'Deprecated Feature', + icon: 'fas fa-exclamation-triangle', + tone: 'warning', +}) + +const config = useConfigStore() +const isDev = computed(() => 'development' === config.app?.app_env) + +const storageKeyComputed = computed<string>(() => `${props.storageKey}:${props.version}`) +const dismissed = useStorage<boolean>(storageKeyComputed, false) +const isDismissed = computed(() => dismissed.value) + +const messageClass = computed(() => { + switch (props.tone) { + case 'danger': + return 'is-danger has-background-danger-90 has-text-dark' + case 'info': + return 'is-info has-background-info-90 has-text-dark' + case 'success': + return 'is-success has-background-success-90 has-text-dark' + case 'warning': + default: + return 'is-warning has-background-warning-90 has-text-dark' + } +}) + +onMounted(() => { + if (!isDev.value) { + return + } + document.addEventListener('keydown', handle_event) +}) + +onBeforeUnmount(() => { + if (!isDev.value) { + return + } + document.removeEventListener('keydown', handle_event) +}) + +const handle_event = (e: KeyboardEvent) => { + if (e.ctrlKey && e.altKey && 'd' === e.key.toLowerCase()) { + e.preventDefault() + dismissed.value = !dismissed.value + } +} +</script> diff --git a/ui/app/components/GetInfo.vue b/ui/app/components/GetInfo.vue index 9b16433b..5692c13e 100644 --- a/ui/app/components/GetInfo.vue +++ b/ui/app/components/GetInfo.vue @@ -47,6 +47,7 @@ const emitter = defineEmits<{ (e: 'closeModel'): void }>() const props = defineProps<{ link?: string preset?: string + cli?: string useUrl?: boolean externalModel?: boolean }>() @@ -71,13 +72,16 @@ onMounted(async (): Promise<void> => { if (props.preset) { params.append('preset', props.preset) } + if (props.cli) { + params.append('args', props.cli) + } params.append('url', props.link || '') url += '?' + params.toString() } try { isLoading.value = true - const response = await request(url, { credentials: 'include' }) + const response = await request(url) const body = await response.text() try { diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 02601d8f..67c3980b 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -118,7 +118,8 @@ </span> </div> <div v-if="showThumbnails && item.extras.thumbnail"> - <FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" + <FloatingImage + :image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :title="`[${item.preset}] - ${item.title}`"> <div class="is-text-overflow"> <NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink> @@ -138,11 +139,8 @@ </div> </td> <td class="is-vcentered has-text-centered is-unselectable"> - <span class="icon-text"> - <span class="icon" :class="setIconColor(item)"><i - :class="[setIcon(item), is_queued(item)]" /></span> - <span>{{ setStatus(item) }}</span> - </span> + <span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span> + <span>{{ setStatus(item) }}</span> </td> <td class="is-vcentered has-text-centered is-unselectable"> <span class="user-hint" :date-datetime="item.datetime" @@ -195,7 +193,7 @@ </NuxtLink> <hr class="dropdown-divider" /> </template> - <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"> + <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"> <span class="icon"><i class="fa-solid fa-info" /></span> <span>yt-dlp Information</span> </NuxtLink> @@ -205,25 +203,23 @@ <span>Local Information</span> </NuxtLink> - <template v-if="item.status != 'finished' || !item.filename"> - <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item" @click="retryItem(item, true)"> - <span class="icon"><i class="fa-solid fa-rotate-right" /></span> - <span>Add to download form</span> - </NuxtLink> - </template> + <hr class="dropdown-divider" /> + <NuxtLink class="dropdown-item" @click="retryItem(item, true)"> + <span class="icon"><i class="fa-solid fa-rotate-right" /></span> + <span>Add to download form</span> + </NuxtLink> - <template v-if="'finished' !== item.status && config.app?.keep_archive"> + <template v-if="item.is_archivable && !item.is_archived"> <hr class="dropdown-divider" /> <NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)"> <span class="icon"><i class="fa-solid fa-box-archive" /></span> - <span>Archive Item</span> + <span>Add to archive</span> </NuxtLink> </template> - <template v-if="'finished' === item.status && item.filename && config.app?.keep_archive"> + <template v-if="item.is_archivable && item.is_archived"> <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)"> + <NuxtLink class="dropdown-item has-text-danger" @click="removeFromArchiveDialog(item)"> <span class="icon"><i class="fa-solid fa-box-archive" /></span> <span>Remove from archive</span> </NuxtLink> @@ -276,20 +272,22 @@ <figure class="image is-3by1"> <span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay"> <div class="play-icon"></div> - <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" + <img @load="e => pImg(e)" + :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" v-if="item.extras?.thumbnail" /> <img v-else src="/images/placeholder.png" /> </span> <span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string" class="play-overlay"> <div class="play-icon embed-icon"></div> - <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" + <img @load="e => pImg(e)" + :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" v-if="item.extras?.thumbnail" /> <img v-else src="/images/placeholder.png" /> </span> <template v-else> <img @load="e => pImg(e)" v-if="item.extras?.thumbnail" - :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" /> + :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" /> <img v-else src="/images/placeholder.png" /> </template> </figure> @@ -297,10 +295,8 @@ <div class="card-content"> <div class="columns is-mobile is-multiline"> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> - <span class="icon-text"> - <span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span> - <span>{{ setStatus(item) }}</span> - </span> + <span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span> + <span>{{ setStatus(item) }}</span> </div> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <span class="icon"><i class="fa-solid fa-sliders" /></span> @@ -370,7 +366,7 @@ <hr class="dropdown-divider" /> </template> - <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)" + <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)" v-if="!config.app.basic_mode"> <span class="icon"><i class="fa-solid fa-info" /></span> <span>yt-dlp Information</span> @@ -382,15 +378,13 @@ <span>Local Information</span> </NuxtLink> - <template v-if="item.status != 'finished' || !item.filename"> - <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item" @click="retryItem(item, true)"> - <span class="icon"><i class="fa-solid fa-rotate-right" /></span> - <span>Add to download form</span> - </NuxtLink> - </template> + <hr class="dropdown-divider" /> + <NuxtLink class="dropdown-item" @click="retryItem(item, true)"> + <span class="icon"><i class="fa-solid fa-rotate-right" /></span> + <span>Add to download form</span> + </NuxtLink> - <template v-if="'finished' !== item.status && config.app?.keep_archive && !config.app.basic_mode"> + <template v-if="item.is_archivable && !item.is_archived"> <hr class="dropdown-divider" /> <NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)"> <span class="icon"><i class="fa-solid fa-box-archive" /></span> @@ -398,10 +392,9 @@ </NuxtLink> </template> - <template - v-if="'finished' === item.status && item.filename && config.app?.keep_archive && !config.app.basic_mode"> + <template v-if="item.is_archivable && item.is_archived"> <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)"> + <NuxtLink class="dropdown-item has-text-danger" @click="removeFromArchiveDialog(item)"> <span class="icon"><i class="fa-solid fa-box-archive" /></span> <span>Remove from archive</span> </NuxtLink> @@ -468,7 +461,7 @@ import { useStorage } from '@vueuse/core' import type { StoreItem } from '~/types/store' const emitter = defineEmits<{ - (e: 'getInfo', url: string, preset: string): void + (e: 'getInfo', url: string, preset: string, cli: string): void (e: 'add_new', item: Partial<StoreItem>): void (e: 'getItemInfo', id: string): void (e: 'clear_search'): void @@ -613,9 +606,6 @@ const deleteSelectedItems = () => { continue } const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem - if ('finished' === item.status) { - socket.emit('archive_item', item) - } socket.emit('item_delete', { id: item._id, remove_file: config.app.remove_files, @@ -739,10 +729,7 @@ const addArchiveDialog = (item: StoreItem) => { const archiveItem = async (item: StoreItem, opts = {}) => { try { - const req = await request(`/api/archive/${item._id}`, { - credentials: 'include', - method: 'POST', - }) + const req = await request(`/api/history/${item._id}/archive`, { method: 'POST' }) const data = await req.json() dialog_confirm.value.visible = false if (!req.ok) { @@ -834,8 +821,7 @@ const downloadSelected = async () => { try { const response = await request('/api/file/download', { method: 'POST', - credentials: 'include', - body: JSON.stringify(files_list), + body: JSON.stringify(files_list) }) const json = await response.json() if (!response.ok) { @@ -871,12 +857,8 @@ const removeFromArchiveDialog = (item: StoreItem) => { } const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean }) => { - console.log('Removing from archive:', item, opts) try { - const req = await request(`/api/archive/${item._id}`, { - credentials: 'include', - method: 'DELETE', - }) + const req = await request(`/api/history/${item._id}/archive`, { method: 'DELETE' }) const data = await req.json() if (!req.ok) { toast.error(data.error) diff --git a/ui/app/components/ImageView.vue b/ui/app/components/ImageView.vue index 1f8cad2d..51e4d4a8 100644 --- a/ui/app/components/ImageView.vue +++ b/ui/app/components/ImageView.vue @@ -50,7 +50,7 @@ onMounted(async () => { try { isLoading.value = true - const imgRequest = await request(url, { credentials: 'include' }) + const imgRequest = await request(url) if (200 !== imgRequest.status) { return } diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index 53ed1eb6..0e5e6fa0 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -63,8 +63,9 @@ </label> </div> <div class="control is-expanded"> - <input type="text" class="input is-fullwidth" id="path" v-model="form.folder" placeholder="Default" - :disabled="!socket.isConnected || addInProgress" list="folders"> + <input type="text" class="input is-fullwidth" id="path" v-model="form.folder" + :placeholder="get_download_folder()" :disabled="!socket.isConnected || addInProgress" + list="folders"> </div> </div> </div> @@ -166,16 +167,11 @@ </NuxtLink> <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset)"> + <NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset, form.cli)"> <span class="icon has-text-info"><i class="fa-solid fa-info" /></span> <span>yt-dlp Information</span> </NuxtLink> - <NuxtLink class="dropdown-item" @click="removeFromArchive(form.url)"> - <span class="icon has-text-warning"><i class="fa-solid fa-box-archive" /></span> - <span>Remove from archive</span> - </NuxtLink> - <hr class="dropdown-divider" /> <NuxtLink class="dropdown-item" @click="resetConfig"> <span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span> @@ -193,7 +189,8 @@ </button> </div> <div class="control"> - <button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)" + <button type="button" class="button is-info" + @click="emitter('getInfo', form.url, form.preset, form.cli)" :class="{ 'is-loading': !socket.isConnected }" :disabled="!socket.isConnected || addInProgress || !form?.url"> <span class="icon"><i class="fa-solid fa-info" /></span> @@ -201,15 +198,6 @@ </button> </div> - <div class="control"> - <button type="button" class="button is-warning" @click="removeFromArchive(form.url)" - :class="{ 'is-loading': !socket.isConnected }" - :disabled="!socket.isConnected || addInProgress || !form?.url"> - <span class="icon"><i class="fa-solid fa-box-archive" /></span> - <span>Remove from archive</span> - </button> - </div> - <div class="control"> <button type="button" class="button is-danger" @click="resetConfig" :disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'"> @@ -247,7 +235,7 @@ import type { AutoCompleteOptions } from '~/types/autocomplete'; const props = defineProps<{ item?: Partial<item_request> }>() const emitter = defineEmits<{ - (e: 'getInfo', url: string, preset: string | undefined): void + (e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void (e: 'clear_form'): void (e: 'remove_archive', url: string): void }>() @@ -363,7 +351,6 @@ const addDownload = async () => { try { addInProgress.value = true const response = await request('/api/history', { - credentials: 'include', method: 'POST', body: JSON.stringify(request_data), }) @@ -496,27 +483,6 @@ const filter_presets = (flag: boolean = true) => config.presets.filter(item => i const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name) const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap']) -const removeFromArchive = async (url: string) => { - try { - const req = await request(`/api/archive/0`, { - credentials: 'include', - method: 'DELETE', - body: JSON.stringify({ url }), - }) - - const data = await req.json() - - if (!req.ok) { - toast.error(data.error) - return - } - - toast.success(data.message ?? `Removed item from archive.`) - } catch (e: any) { - toast.error(`Error: ${e.message}`) - } -} - const get_output_template = () => { if (form.value.preset && !hasFormatInConfig.value) { const preset = config.presets.find(p => p.name === form.value.preset) @@ -527,5 +493,15 @@ const get_output_template = () => { return config.app.output_template || '%(title)s.%(ext)s' } +const get_download_folder = (): string => { + if (form.value.preset && false === hasFormatInConfig.value) { + const preset = config.presets.find(p => p.name === form.value.preset) + if (preset?.folder) { + return preset.folder.replace(config.app.download_path, '') + } + } + return '/' +} + const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0))) </script> diff --git a/ui/app/components/Queue.vue b/ui/app/components/Queue.vue index e22ef611..63811d7a 100644 --- a/ui/app/components/Queue.vue +++ b/ui/app/components/Queue.vue @@ -84,7 +84,8 @@ </span> </div> <div v-if="showThumbnails && item.extras?.thumbnail"> - <FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" + <FloatingImage + :image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" :title="item.title"> <div class="is-text-overflow"> <NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink> @@ -98,12 +99,10 @@ </template> </td> <td class="has-text-centered is-text-overflow is-unselectable"> - <span class="icon-text"> - <span class="icon" :class="setIconColor(item)"> - <i class="fas fa-solid" :class="setIcon(item)" /> - </span> - <span v-text="setStatus(item)" /> + <span class="icon" :class="setIconColor(item)"> + <i class="fas fa-solid" :class="setIcon(item)" /> </span> + <span v-text="setStatus(item)" /> </td> <td> <div class="progress-bar is-unselectable"> @@ -151,7 +150,7 @@ <template v-if="!config.app.basic_mode"> <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"> + <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"> <span class="icon"><i class="fa-solid fa-info" /></span> <span>yt-dlp Information</span> </NuxtLink> @@ -206,13 +205,14 @@ <span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string" class="play-overlay"> <div class="play-icon embed-icon"></div> - <img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" + <img @load="e => pImg(e)" + :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" v-if="item.extras?.thumbnail" /> <img v-else src="/images/placeholder.png" /> </span> <template v-else> <img @load="e => pImg(e)" v-if="item.extras?.thumbnail" - :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" /> + :src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" /> <img v-else src="/images/placeholder.png" /> </template> </figure> @@ -226,12 +226,10 @@ </div> </div> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> - <span class="icon-text"> - <span class="icon" :class="setIconColor(item)"> - <i class="fas fa-solid" :class="setIcon(item)" /> - </span> - <span v-text="setStatus(item)" /> + <span class="icon" :class="setIconColor(item)"> + <i class="fas fa-solid" :class="setIcon(item)" /> </span> + <span v-text="setStatus(item)" /> </div> <div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable"> <span class="icon"><i class="fa-solid fa-sliders" /></span> @@ -277,7 +275,7 @@ <hr class="dropdown-divider" v-if="!config.app.basic_mode" /> </template> - <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)" + <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)" v-if="!config.app.basic_mode"> <span class="icon"><i class="fa-solid fa-info" /></span> <span>yt-dlp Information</span> @@ -321,7 +319,7 @@ import { useStorage } from '@vueuse/core' import type { StoreItem } from '~/types/store' const emitter = defineEmits<{ - (e: 'getInfo', url: string, preset: string): void + (e: 'getInfo', url: string, preset: string, cli: string): void (e: 'getItemInfo', id: string): void (e: 'clear_search'): void }>() diff --git a/ui/app/components/TaskForm.vue b/ui/app/components/TaskForm.vue index a2401a2d..bffed254 100644 --- a/ui/app/components/TaskForm.vue +++ b/ui/app/components/TaskForm.vue @@ -510,7 +510,7 @@ const convert_url = async (url: string): Promise<string> => { try { convertInProgress.value = true - const resp = await request('/api/yt-dlp/url/info?' + params.toString(), { credentials: 'include' }) + const resp = await request('/api/yt-dlp/url/info?' + params.toString()) const body = await resp.json() const channel_id = ag(body, 'channel_id', null) console.log('convert_url', { url, channel_id, body }) diff --git a/ui/app/components/YTDLPOptions.vue b/ui/app/components/YTDLPOptions.vue index e5c4c1df..19feb297 100644 --- a/ui/app/components/YTDLPOptions.vue +++ b/ui/app/components/YTDLPOptions.vue @@ -170,7 +170,7 @@ const filters = reactive({ const reload = async (): Promise<void> => { try { isLoading.value = true - const resp = await request('/api/yt-dlp/options', { credentials: 'include' }) + const resp = await request('/api/yt-dlp/options') if (!resp.ok) { return } diff --git a/ui/app/pages/index.vue b/ui/app/pages/index.vue index 771625c3..27c065ba 100644 --- a/ui/app/pages/index.vue +++ b/ui/app/pages/index.vue @@ -29,8 +29,7 @@ <span class="icon"><i class="fas fa-pause" /></span> <span v-if="!isMobile">Pause</span> </button> - <button class="button is-danger" @click="socket.emit('resume', {})" v-else - v-tooltip.bottom="'Resume downloading.'"> + <button class="button is-danger" @click="resumeDownload" v-else v-tooltip.bottom="'Resume downloading.'"> <span class="icon"><i class="fas fa-play" /></span> <span v-if="!isMobile">Resume</span> </button> @@ -63,16 +62,50 @@ </div> </div> + <div v-if="config.is_loaded" class="columns is-multiline"> + <div class="column is-12"> + <DeprecatedNotice :version="config.app.app_version" title="Deprecation Notice" tone="warning" + icon="fas fa-exclamation-triangle fa-fade fa-spin-10"> + <p> + The following environment variables and features are deprecated and will be removed in future releases: + </p> + <ul> + <li> + The environment variables <code>YTP_KEEP_ARCHIVE</code> and <code>YTP_SOCKET_TIMEOUT</code> will no + longer be user-configurable. Their behavior will be part of the <strong>default presets</strong>. To keep + your current behavior <strong>and avoid re-downloading</strong>, please add the following <strong>Command + options for yt-dlp</strong> to your presets: + <code>--socket-timeout 30 --download-archive /config/archive.log</code> + </li> + <li> + The global yt-dlp config file <code>/config/ytdlp.cli</code> is deprecated and will be removed. Please + migrate any global options into your presets. + </li> + <li> + The <strong>Basic mode</strong> (which limited the interface to the new download form) is being removed. + Everything except what is available behind configurable flag will become part of the standard interface. + </li> + <li>The <strong>archive.manual.log</strong> feature has been removed.</li> + </ul> + <p> + These changes help reduce confusion from multiple sources of truth. Going forward, <strong>presets</strong> + and the <strong>Command options for yt-dlp</strong> will be the single source of truth. + </p> + </DeprecatedNotice> + </div> + </div> + <NewDownload v-if="config.showForm || config.app.basic_mode" - @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :item="item_form" - @clear_form="item_form = {}" @remove_archive="" /> - <Queue @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :thumbnails="show_thumbnail" - :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> - <History @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" + @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" + :item="item_form" @clear_form="item_form = {}" @remove_archive="" /> + <Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" + :thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" + @clear_search="query = ''" /> + <History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" @add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> - <GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :useUrl="info_view.useUrl" - @closeModel="close_info()" /> + <GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli" + :useUrl="info_view.useUrl" @closeModel="close_info()" /> <ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title" :html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm" @cancel="() => dialog_confirm.visible = false" /> @@ -81,6 +114,7 @@ <script setup lang="ts"> import { useStorage } from '@vueuse/core' +import DeprecatedNotice from '~/components/DeprecatedNotice.vue' import type { item_request } from '~/types/item' import type { StoreItem } from '~/types/store' @@ -95,8 +129,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true) const info_view = ref({ url: '', preset: '', + cli: '', useUrl: false, -}) as Ref<{ url: string, preset: string, useUrl: boolean }> +}) as Ref<{ url: string, preset: string, cli: string, useUrl: boolean }> const item_form = ref<item_request | object>({}) const query = ref() const toggleFilter = ref(false) @@ -139,6 +174,8 @@ watch(() => stateStore.queue, () => { }, { deep: true }) +const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' }) + const pauseDownload = () => { dialog_confirm.value.visible = true dialog_confirm.value.html_message = ` @@ -153,8 +190,8 @@ const pauseDownload = () => { <li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li> </ul> </span>` - dialog_confirm.value.confirm = () => { - socket.emit('pause', {}) + dialog_confirm.value.confirm = async () => { + await request('/api/system/pause', { method: 'POST' }) dialog_confirm.value.visible = false } } @@ -165,10 +202,11 @@ const close_info = () => { info_view.value.useUrl = false } -const view_info = (url: string, useUrl: boolean = false, preset: string = '') => { +const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => { info_view.value.url = url info_view.value.useUrl = useUrl info_view.value.preset = preset + info_view.value.cli = cli } watch(() => info_view.value.url, v => { diff --git a/ui/app/pages/tasks.vue b/ui/app/pages/tasks.vue index f7b21fb4..4277af39 100644 --- a/ui/app/pages/tasks.vue +++ b/ui/app/pages/tasks.vue @@ -194,9 +194,14 @@ <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)"> + <NuxtLink class="dropdown-item" @click="archiveAll(item)"> <span class="icon"><i class="fa-solid fa-box-archive" /></span> - <span>Archive all</span> + <span>Archive All</span> + </NuxtLink> + + <NuxtLink class="dropdown-item" @click="unarchiveAll(item)"> + <span class="icon"><i class="fa-solid fa-box-archive" /></span> + <span>Unarchive All</span> </NuxtLink> <hr class="dropdown-divider" /> @@ -313,9 +318,14 @@ <hr class="dropdown-divider" /> - <NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)"> + <NuxtLink class="dropdown-item" @click="archiveAll(item)"> <span class="icon"><i class="fa-solid fa-box-archive" /></span> - <span>Archive all</span> + <span>Archive All</span> + </NuxtLink> + + <NuxtLink class="dropdown-item" @click="unarchiveAll(item)"> + <span class="icon"><i class="fa-solid fa-box-archive" /></span> + <span>Unarchive All</span> </NuxtLink> </Dropdown> </div> @@ -370,6 +380,7 @@ const box = useConfirm() const toast = useNotification() const config = useConfigStore() const socket = useSocketStore() +const { confirmDialog: cDialog } = useDialog() const display_style = useStorage<string>("tasks_display_style", "cards") const tasks = ref<Array<task_item>>([]) @@ -428,7 +439,7 @@ watch(() => config.app.basic_mode, async v => { return } await navigateTo('/') -},{ immediate: true }) +}, { immediate: true }) watch(() => socket.isConnected, async () => { if (socket.isConnected && initialLoad.value) { @@ -764,17 +775,18 @@ const get_tags = (name: string): Array<string> => { const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim(); -const archiveItems = async (item: task_item) => { - dialog_confirm.value.visible = true - dialog_confirm.value.title = 'Archive All videos' - dialog_confirm.value.message = `Archive all items for '${item.name}' task? This will mark all items as downloaded and update the archive file.` - dialog_confirm.value.confirm = async () => await archiveAll(item) -} - const archiveAll = async (item: task_item) => { try { - dialog_confirm.value.visible = false + const { status } = await cDialog({ + message: `Mark all '${item.name}' items as downloaded in download archive?` + }) + + if (true !== status) { + return; + } + item.in_progress = true + const response = await request(`/api/tasks/${item.id}/mark`, { method: 'POST' }) const data = await response.json() @@ -791,4 +803,33 @@ const archiveAll = async (item: task_item) => { item.in_progress = false } } + +const unarchiveAll = async (item: task_item) => { + try { + const { status } = await cDialog({ + message: `Remove all '${item.name}' items from download archive?` + }) + + if (true !== status) { + return; + } + + item.in_progress = true + + const response = await request(`/api/tasks/${item.id}/mark`, { method: 'DELETE' }) + const data = await response.json() + + if (data?.error) { + toast.error(data.error) + return + } + + toast.success(data.message) + } catch (e: any) { + toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`) + return + } finally { + item.in_progress = false + } +} </script> diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts index c658f025..85161d6b 100644 --- a/ui/app/types/config.d.ts +++ b/ui/app/types/config.d.ts @@ -45,7 +45,9 @@ type AppConfig = { /** App branch name, e.g. "main" or "develop" */ app_branch: string /** When the app started */ - started: number + started: number, + /** Application environment, e.g. "production", "development" */ + app_env: string } type Preset = { diff --git a/ui/app/types/store.d.ts b/ui/app/types/store.d.ts index ce575596..c08089ab 100644 --- a/ui/app/types/store.d.ts +++ b/ui/app/types/store.d.ts @@ -82,6 +82,12 @@ type StoreItem = { speed?: number | null /** Time remaining for the item download if available */ eta?: number | null + /** If the item can be archived */ + is_archivable?: boolean + /** If the item is archived */ + is_archived?: boolean + /** Item archive ID */ + archive_id?: string | null } export type { ItemStatus, StoreItem } diff --git a/ui/package.json b/ui/package.json index ab129c9f..6de7ecc7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,20 +12,20 @@ "web-types": "./web-types.json", "dependencies": { "@pinia/nuxt": "^0.11.2", - "@sentry/nuxt": "^10.5.0", - "@vueuse/core": "^13.7.0", - "@vueuse/nuxt": "^13.7.0", + "@sentry/nuxt": "^10.8.0", + "@vueuse/core": "^13.8.0", + "@vueuse/nuxt": "^13.8.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "cron-parser": "^5.3.0", "cronstrue": "^3.2.0", "floating-vue": "^5.2.2", - "hls.js": "^1.6.10", + "hls.js": "^1.6.11", "moment": "^2.30.1", "nuxt": "^4.0.3", "pinia": "^3.0.3", "socket.io-client": "^4.8.1", - "vue": "^3.5.19", + "vue": "^3.5.20", "vue-router": "^4.5.1", "vue-toastification": "2.0.0-rc.5" }, diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 90011175..bb2a6ab1 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -10,16 +10,16 @@ importers: dependencies: '@pinia/nuxt': specifier: ^0.11.2 - version: 0.11.2(magicast@0.3.5)(pinia@3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2))) + version: 0.11.2(magicast@0.3.5)(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))) '@sentry/nuxt': - specifier: ^10.5.0 - version: 10.5.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)))(rollup@4.48.0)(vue@3.5.19(typescript@5.9.2)) + specifier: ^10.8.0 + version: 10.8.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))(rollup@4.49.0)(vue@3.5.20(typescript@5.9.2)) '@vueuse/core': - specifier: ^13.7.0 - version: 13.7.0(vue@3.5.19(typescript@5.9.2)) + specifier: ^13.8.0 + version: 13.8.0(vue@3.5.20(typescript@5.9.2)) '@vueuse/nuxt': - specifier: ^13.7.0 - version: 13.7.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2)) + specifier: ^13.8.0 + version: 13.8.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2)) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -34,31 +34,31 @@ importers: version: 3.2.0 floating-vue: specifier: ^5.2.2 - version: 5.2.2(@nuxt/kit@3.18.1(magicast@0.3.5))(vue@3.5.19(typescript@5.9.2)) + version: 5.2.2(@nuxt/kit@3.18.1(magicast@0.3.5))(vue@3.5.20(typescript@5.9.2)) hls.js: - specifier: ^1.6.10 - version: 1.6.10 + specifier: ^1.6.11 + version: 1.6.11 moment: specifier: ^2.30.1 version: 2.30.1 nuxt: specifier: ^4.0.3 - version: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) + version: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) pinia: specifier: ^3.0.3 - version: 3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)) + version: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)) socket.io-client: specifier: ^4.8.1 version: 4.8.1 vue: - specifier: ^3.5.19 - version: 3.5.19(typescript@5.9.2) + specifier: ^3.5.20 + version: 3.5.20(typescript@5.9.2) vue-router: specifier: ^4.5.1 - version: 4.5.1(vue@3.5.19(typescript@5.9.2)) + version: 4.5.1(vue@3.5.20(typescript@5.9.2)) vue-toastification: specifier: 2.0.0-rc.5 - version: 2.0.0-rc.5(vue@3.5.19(typescript@5.9.2)) + version: 2.0.0-rc.5(vue@3.5.20(typescript@5.9.2)) packages: @@ -202,14 +202,14 @@ packages: resolution: {integrity: sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==} engines: {node: '>=18'} - '@emnapi/core@1.4.5': - resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + '@emnapi/core@1.5.0': + resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} - '@emnapi/runtime@1.4.5': - resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} + '@emnapi/runtime@1.5.0': + resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/wasi-threads@1.0.4': - resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} '@esbuild/aix-ppc64@0.25.5': resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} @@ -529,8 +529,8 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@ioredis/commands@1.3.0': - resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} + '@ioredis/commands@1.3.1': + resolution: {integrity: sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==} '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -600,8 +600,8 @@ packages: resolution: {integrity: sha512-pfCkH50JV06SGMNsNPjn8t17hOcId4fA881HeYQgMBOrewjsw4csaYgHEnCxCEu24Y5x75E2ULbFpqm9CvRCqw==} engines: {node: '>=18.0.0'} - '@netlify/serverless-functions-api@2.2.1': - resolution: {integrity: sha512-PAEyziX2pkENwQLCqWfS2Jw5CKATwAty/4mcnBcAEVWrfWE5vqKx82qta1nDrbeFOcBw6QD5ShYCfbXUnQ4MNA==} + '@netlify/serverless-functions-api@2.3.0': + resolution: {integrity: sha512-eSC+glm4bX+9t+ajNzAs4Bca0Q/xGLgcYYh6M2Z9Dcya/MjVod1UrjPB88b0ANSBAy/aGFpDhVbwLwBokfnppQ==} engines: {node: '>=18.0.0'} '@netlify/zip-it-and-ship-it@12.2.1': @@ -703,8 +703,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-dataloader@0.21.0': - resolution: {integrity: sha512-Xu4CZ1bfhdkV3G6iVHFgKTgHx8GbKSqrTU01kcIJRGHpowVnyOPEv1CW5ow+9GU2X4Eki8zoNuVUenFc3RluxQ==} + '@opentelemetry/instrumentation-dataloader@0.21.1': + resolution: {integrity: sha512-hNAm/bwGawLM8VDjKR0ZUDJ/D/qKR3s6lA5NV+btNaPVm2acqhPcT47l2uCVi+70lng2mywfQncor9v8/ykuyw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -751,8 +751,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-kafkajs@0.12.0': - resolution: {integrity: sha512-bIe4aSAAxytp88nzBstgr6M7ZiEpW6/D1/SuKXdxxuprf18taVvFL2H5BDNGZ7A14K27haHqzYqtCTqFXHZOYg==} + '@opentelemetry/instrumentation-kafkajs@0.13.0': + resolution: {integrity: sha512-FPQyJsREOaGH64hcxlzTsIEQC4DYANgTwHjiB7z9lldmvua1LRMVn3/FfBlzXoqF179B0VGYviz6rn75E9wsDw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -787,8 +787,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation-mysql2@0.49.0': - resolution: {integrity: sha512-dCub9wc02mkJWNyHdVEZ7dvRzy295SmNJa+LrAJY2a/+tIiVBQqEAajFzKwp9zegVVnel9L+WORu34rGLQDzxA==} + '@opentelemetry/instrumentation-mysql2@0.50.0': + resolution: {integrity: sha512-PoOMpmq73rOIE3nlTNLf3B1SyNYGsp7QXHYKmeTZZnJ2Ou7/fdURuOhWOI0e6QZ5gSem18IR1sJi6GOULBQJ9g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -1240,16 +1240,16 @@ packages: '@poppinss/exception@1.2.2': resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} - '@prisma/instrumentation@6.13.0': - resolution: {integrity: sha512-b97b0sBycGh89RQcqobSgjGl3jwPaC5cQIOFod6EX1v0zIxlXPmL3ckSXxoHpy+Js0QV/tgCzFvqicMJCtezBA==} + '@prisma/instrumentation@6.14.0': + resolution: {integrity: sha512-Po/Hry5bAeunRDq0yAQueKookW3glpP+qjjvvyOfm6dI2KG5/Y6Bgg3ahyWd7B0u2E+Wf9xRk2rtdda7ySgK1A==} peerDependencies: '@opentelemetry/api': ^1.8 '@rolldown/pluginutils@1.0.0-beta.29': resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==} - '@rolldown/pluginutils@1.0.0-beta.33': - resolution: {integrity: sha512-she25NCG6NoEPC/SEB4pHs5STcnfI4VBFOzjeI63maSPrWME5J2XC8ogrBgp8NaE/xzj28/kbpSaebiMvFRj+w==} + '@rolldown/pluginutils@1.0.0-beta.34': + resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} @@ -1323,132 +1323,132 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.48.0': - resolution: {integrity: sha512-aVzKH922ogVAWkKiyKXorjYymz2084zrhrZRXtLrA5eEx5SO8Dj0c/4FpCHZyn7MKzhW2pW4tK28vVr+5oQ2xw==} + '@rollup/rollup-android-arm-eabi@4.49.0': + resolution: {integrity: sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.48.0': - resolution: {integrity: sha512-diOdQuw43xTa1RddAFbhIA8toirSzFMcnIg8kvlzRbK26xqEnKJ/vqQnghTAajy2Dcy42v+GMPMo6jq67od+Dw==} + '@rollup/rollup-android-arm64@4.49.0': + resolution: {integrity: sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.48.0': - resolution: {integrity: sha512-QhR2KA18fPlJWFefySJPDYZELaVqIUVnYgAOdtJ+B/uH96CFg2l1TQpX19XpUMWUqMyIiyY45wje8K6F4w4/CA==} + '@rollup/rollup-darwin-arm64@4.49.0': + resolution: {integrity: sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.48.0': - resolution: {integrity: sha512-Q9RMXnQVJ5S1SYpNSTwXDpoQLgJ/fbInWOyjbCnnqTElEyeNvLAB3QvG5xmMQMhFN74bB5ZZJYkKaFPcOG8sGg==} + '@rollup/rollup-darwin-x64@4.49.0': + resolution: {integrity: sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.48.0': - resolution: {integrity: sha512-3jzOhHWM8O8PSfyft+ghXZfBkZawQA0PUGtadKYxFqpcYlOYjTi06WsnYBsbMHLawr+4uWirLlbhcYLHDXR16w==} + '@rollup/rollup-freebsd-arm64@4.49.0': + resolution: {integrity: sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.48.0': - resolution: {integrity: sha512-NcD5uVUmE73C/TPJqf78hInZmiSBsDpz3iD5MF/BuB+qzm4ooF2S1HfeTChj5K4AV3y19FFPgxonsxiEpy8v/A==} + '@rollup/rollup-freebsd-x64@4.49.0': + resolution: {integrity: sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.48.0': - resolution: {integrity: sha512-JWnrj8qZgLWRNHr7NbpdnrQ8kcg09EBBq8jVOjmtlB3c8C6IrynAJSMhMVGME4YfTJzIkJqvSUSVJRqkDnu/aA==} + '@rollup/rollup-linux-arm-gnueabihf@4.49.0': + resolution: {integrity: sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.48.0': - resolution: {integrity: sha512-9xu92F0TxuMH0tD6tG3+GtngwdgSf8Bnz+YcsPG91/r5Vgh5LNofO48jV55priA95p3c92FLmPM7CvsVlnSbGQ==} + '@rollup/rollup-linux-arm-musleabihf@4.49.0': + resolution: {integrity: sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.48.0': - resolution: {integrity: sha512-NLtvJB5YpWn7jlp1rJiY0s+G1Z1IVmkDuiywiqUhh96MIraC0n7XQc2SZ1CZz14shqkM+XN2UrfIo7JB6UufOA==} + '@rollup/rollup-linux-arm64-gnu@4.49.0': + resolution: {integrity: sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.48.0': - resolution: {integrity: sha512-QJ4hCOnz2SXgCh+HmpvZkM+0NSGcZACyYS8DGbWn2PbmA0e5xUk4bIP8eqJyNXLtyB4gZ3/XyvKtQ1IFH671vQ==} + '@rollup/rollup-linux-arm64-musl@4.49.0': + resolution: {integrity: sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loongarch64-gnu@4.48.0': - resolution: {integrity: sha512-Pk0qlGJnhILdIC5zSKQnprFjrGmjfDM7TPZ0FKJxRkoo+kgMRAg4ps1VlTZf8u2vohSicLg7NP+cA5qE96PaFg==} + '@rollup/rollup-linux-loongarch64-gnu@4.49.0': + resolution: {integrity: sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.48.0': - resolution: {integrity: sha512-/dNFc6rTpoOzgp5GKoYjT6uLo8okR/Chi2ECOmCZiS4oqh3mc95pThWma7Bgyk6/WTEvjDINpiBCuecPLOgBLQ==} + '@rollup/rollup-linux-ppc64-gnu@4.49.0': + resolution: {integrity: sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.48.0': - resolution: {integrity: sha512-YBwXsvsFI8CVA4ej+bJF2d9uAeIiSkqKSPQNn0Wyh4eMDY4wxuSp71BauPjQNCKK2tD2/ksJ7uhJ8X/PVY9bHQ==} + '@rollup/rollup-linux-riscv64-gnu@4.49.0': + resolution: {integrity: sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.48.0': - resolution: {integrity: sha512-FI3Rr2aGAtl1aHzbkBIamsQyuauYtTF9SDUJ8n2wMXuuxwchC3QkumZa1TEXYIv/1AUp1a25Kwy6ONArvnyeVQ==} + '@rollup/rollup-linux-riscv64-musl@4.49.0': + resolution: {integrity: sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.48.0': - resolution: {integrity: sha512-Dx7qH0/rvNNFmCcIRe1pyQ9/H0XO4v/f0SDoafwRYwc2J7bJZ5N4CHL/cdjamISZ5Cgnon6iazAVRFlxSoHQnQ==} + '@rollup/rollup-linux-s390x-gnu@4.49.0': + resolution: {integrity: sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.48.0': - resolution: {integrity: sha512-GUdZKTeKBq9WmEBzvFYuC88yk26vT66lQV8D5+9TgkfbewhLaTHRNATyzpQwwbHIfJvDJ3N9WJ90wK/uR3cy3Q==} + '@rollup/rollup-linux-x64-gnu@4.49.0': + resolution: {integrity: sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.48.0': - resolution: {integrity: sha512-ao58Adz/v14MWpQgYAb4a4h3fdw73DrDGtaiF7Opds5wNyEQwtO6M9dBh89nke0yoZzzaegq6J/EXs7eBebG8A==} + '@rollup/rollup-linux-x64-musl@4.49.0': + resolution: {integrity: sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.48.0': - resolution: {integrity: sha512-kpFno46bHtjZVdRIOxqaGeiABiToo2J+st7Yce+aiAoo1H0xPi2keyQIP04n2JjDVuxBN6bSz9R6RdTK5hIppw==} + '@rollup/rollup-win32-arm64-msvc@4.49.0': + resolution: {integrity: sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.48.0': - resolution: {integrity: sha512-rFYrk4lLk9YUTIeihnQMiwMr6gDhGGSbWThPEDfBoU/HdAtOzPXeexKi7yU8jO+LWRKnmqPN9NviHQf6GDwBcQ==} + '@rollup/rollup-win32-ia32-msvc@4.49.0': + resolution: {integrity: sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.48.0': - resolution: {integrity: sha512-sq0hHLTgdtwOPDB5SJOuaoHyiP1qSwg+71TQWk8iDS04bW1wIE0oQ6otPiRj2ZvLYNASLMaTp8QRGUVZ+5OL5A==} + '@rollup/rollup-win32-x64-msvc@4.49.0': + resolution: {integrity: sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==} cpu: [x64] os: [win32] - '@sentry-internal/browser-utils@10.5.0': - resolution: {integrity: sha512-4KIJdEj/8Ip9yqJleVSFe68r/U5bn5o/lYUwnFNEnDNxmpUbOlr7x3DXYuRFi1sfoMUxK9K1DrjnBkR7YYF00g==} + '@sentry-internal/browser-utils@10.8.0': + resolution: {integrity: sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.5.0': - resolution: {integrity: sha512-x79P4VZwUxb1EGZb9OQ5EEgrDWFCUlrbzHBwV/oocQA5Ss1SFz5u6cP5Ak7yJtILiJtdGzAyAoQOy4GKD13D4Q==} + '@sentry-internal/feedback@10.8.0': + resolution: {integrity: sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.5.0': - resolution: {integrity: sha512-5nrRKd5swefd9+sFXFZ/NeL3bz/VxBls3ubAQ3afak15FikkSyHq3oKRKpMOtDsiYKXE3Bc0y3rF5A+y3OXjIA==} + '@sentry-internal/replay-canvas@10.8.0': + resolution: {integrity: sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg==} engines: {node: '>=18'} - '@sentry-internal/replay@10.5.0': - resolution: {integrity: sha512-Dp4coE/nPzhFrYH3iVrpVKmhNJ1m/jGXMEDBCNg3wJZRszI41Hrj0jCAM0Y2S3Q4IxYOmFYaFbGtVpAznRyOHg==} + '@sentry-internal/replay@10.8.0': + resolution: {integrity: sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ==} engines: {node: '>=18'} - '@sentry/babel-plugin-component-annotate@4.1.1': - resolution: {integrity: sha512-HUpqrCK7zDVojTV6KL6BO9ZZiYrEYQqvYQrscyMsq04z+WCupXaH6YEliiNRvreR8DBJgdsG3lBRpebhUGmvfA==} + '@sentry/babel-plugin-component-annotate@4.2.0': + resolution: {integrity: sha512-GFpS3REqaHuyX4LCNqlneAQZIKyHb5ePiI1802n0fhtYjk68I1DTQ3PnbzYi50od/vAsTQVCknaS5F6tidNqTQ==} engines: {node: '>= 14'} - '@sentry/browser@10.5.0': - resolution: {integrity: sha512-o5pEJeZ/iZ7Fmaz2sIirThfnmSVNiP5ZYhacvcDi0qc288TmBbikCX3fXxq3xiSkhXfe1o5QIbNyovzfutyuVw==} + '@sentry/browser@10.8.0': + resolution: {integrity: sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA==} engines: {node: '>=18'} - '@sentry/bundler-plugin-core@4.1.1': - resolution: {integrity: sha512-Hx9RgXaD1HEYmL5aYoWwCKkVvPp4iklwfD9mvmdpQtcwLg6b6oLnPVDQaOry1ak6Pxt8smlrWcKy4IiKASlvig==} + '@sentry/bundler-plugin-core@4.2.0': + resolution: {integrity: sha512-EDG6ELSEN/Dzm4KUQOynoI2suEAdPdgwaBXVN4Ww705zdrYT79OGh51rkz74KGhovt7GukaPf0Z9LJwORXUbhg==} engines: {node: '>= 14'} '@sentry/cli-darwin@2.52.0': @@ -1503,8 +1503,8 @@ packages: engines: {node: '>= 10'} hasBin: true - '@sentry/cloudflare@10.5.0': - resolution: {integrity: sha512-SZU7CJxOl7WH6MRz+IALpyotkA7DxIdn6WKZiprzHKn8Q2u4bINt0Vs6fkocNy7kb9tc5GCcmIdo0dN/+mfi2Q==} + '@sentry/cloudflare@10.8.0': + resolution: {integrity: sha512-3VbnyR0cdgD/odgvrWAVGjgdatoYXX97zNeEUb1d+XBmwmcUc8l2Zux4N/0x2zgqSCO8wnow2J70HTkGeRVsdg==} engines: {node: '>=18'} peerDependencies: '@cloudflare/workers-types': ^4.x @@ -1512,12 +1512,12 @@ packages: '@cloudflare/workers-types': optional: true - '@sentry/core@10.5.0': - resolution: {integrity: sha512-jTJ8NhZSKB2yj3QTVRXfCCngQzAOLThQUxCl9A7Mv+XF10tP7xbH/88MVQ5WiOr2IzcmrB9r2nmUe36BnMlLjA==} + '@sentry/core@10.8.0': + resolution: {integrity: sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q==} engines: {node: '>=18'} - '@sentry/node-core@10.5.0': - resolution: {integrity: sha512-VC4FCKMvvbUT32apTE0exfI/WigqKskzQA+VdFz61Y+T7mTCADngNrOjG3ilVYPBU7R9KEEziEd/oKgencqkmQ==} + '@sentry/node-core@10.8.0': + resolution: {integrity: sha512-KCFy5Otq6KTXge8hBKMgU13EDRFkO4gNwSyZGXub8a7KHYFtoUgpRkborR59SWxeJmC6aEYTyh0PyOoWZJbHUQ==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -1528,18 +1528,18 @@ packages: '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0 '@opentelemetry/semantic-conventions': ^1.34.0 - '@sentry/node@10.5.0': - resolution: {integrity: sha512-GqTkOc7tkWqRTKNjipysElh/bzIkhfLsvNGwH6+zel5kU15IdOCFtAqIri85ZLo9vbaIVtjQELXOzfo/5MMAFQ==} + '@sentry/node@10.8.0': + resolution: {integrity: sha512-1TtCjxzn4SxoGw+ulLK+jF/v9NaZfP0yCclQIqfvWNDjMf2F+SbZL1UnXx4L184FGlNpRQnJBDrBe88gxnMX0A==} engines: {node: '>=18'} - '@sentry/nuxt@10.5.0': - resolution: {integrity: sha512-0CTaImscs7aYMerz7S8tAJ389otj/ZpLTNqXRJNZLHxepnLwS+hqi0gHH/tOJCBXjgVes2HaYskXsFX/91UPmA==} + '@sentry/nuxt@10.8.0': + resolution: {integrity: sha512-GdxA7LiVn2EouI2PxNNmHUwtYUrECxDCdW4ARiSgEoKf3/bt8AP7Fi2RvOw2OGVv4Iwn2irDZtbFApsEQqkwSg==} engines: {node: '>=18.19.1'} peerDependencies: nuxt: '>=3.7.0 || 4.x' - '@sentry/opentelemetry@10.5.0': - resolution: {integrity: sha512-/Qva5vngtuh79YUUBA8kbbrD6w/A+u1vy1jnLoPMKDxWTfNPqT4tCiOOmWYotnITaE3QO0UtXK/j7LMX8FhtUA==} + '@sentry/opentelemetry@10.8.0': + resolution: {integrity: sha512-62R/RPwTYVaiZ5lVcxcjHCAGwgCyfn8Q3kaQld8/LPm8FRizZeUJmmtrI80KaYCvPJhSB/Pvfma4X3w+aN5Q3A==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -1548,18 +1548,18 @@ packages: '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0 '@opentelemetry/semantic-conventions': ^1.34.0 - '@sentry/rollup-plugin@4.1.1': - resolution: {integrity: sha512-AAZ9OzR2gsJRxgKN2k5jB+MxT13Uj2GJeSofi0EHbgu/yUdod8zTGX+4NRB90aXZIEOAc0Xrwnw1sm8nZYvaFw==} + '@sentry/rollup-plugin@4.2.0': + resolution: {integrity: sha512-fn6nukFKQPwPsKb0cvkZKtxqUs+tgXbrXISrzYL/RUMrBvezXQG3Z/rA0hxG4ZLV/pnWhWKcOjdpMFrlRgS2iQ==} engines: {node: '>= 14'} peerDependencies: rollup: '>=3.2.0' - '@sentry/vite-plugin@4.1.1': - resolution: {integrity: sha512-kNIZiqRbFHJHzV0QF1RyuwMprwK2Lk354qs98P7DduU1TkzrNG3+2f8liYJaiYCrsjDvJlPHyVFBDF9IRhJGdA==} + '@sentry/vite-plugin@4.2.0': + resolution: {integrity: sha512-VnPHYgU/RCXxMjj8zyHeKRg4i0mP7bIhxX0VAT3XPNbq2OwvdcZs+mJsFaJy2CNcYz2O47tdJTWdWTvcdBGEkw==} engines: {node: '>= 14'} - '@sentry/vue@10.5.0': - resolution: {integrity: sha512-UbQF7F2C6WaUaDuZEfePiCblZH11nGZfs7/+U/Qt4BRKWL74I0JtXy28tZ3cYI/cM2EenxpnUkjUAMATeZ5/Xg==} + '@sentry/vue@10.8.0': + resolution: {integrity: sha512-Pt9wwSBKWdSxh/PF+1jJ0L9Uc2w7qRvzKw44wenhFif10TVCSstvrSRDcwKoPLibCHE7gIGGo4y+qyQrygeLdQ==} engines: {node: '>=18'} peerDependencies: pinia: 2.x || 3.x @@ -1628,30 +1628,30 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/project-service@8.40.0': - resolution: {integrity: sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==} + '@typescript-eslint/project-service@8.41.0': + resolution: {integrity: sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.40.0': - resolution: {integrity: sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==} + '@typescript-eslint/tsconfig-utils@8.41.0': + resolution: {integrity: sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.40.0': - resolution: {integrity: sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==} + '@typescript-eslint/types@8.41.0': + resolution: {integrity: sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.40.0': - resolution: {integrity: sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==} + '@typescript-eslint/typescript-estree@8.41.0': + resolution: {integrity: sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.40.0': - resolution: {integrity: sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==} + '@typescript-eslint/visitor-keys@8.41.0': + resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unhead/vue@2.0.14': @@ -1664,8 +1664,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@vitejs/plugin-vue-jsx@5.0.1': - resolution: {integrity: sha512-X7qmQMXbdDh+sfHUttXokPD0cjPkMFoae7SgbkF9vi3idGUKmxLcnU2Ug49FHwiKXebfzQRIm5yK3sfCJzNBbg==} + '@vitejs/plugin-vue-jsx@5.1.1': + resolution: {integrity: sha512-uQkfxzlF8SGHJJVH966lFTdjM/lGcwJGzwAHpVqAPDD/QcsqoUGa+q31ox1BrUfi+FLP2ChVp7uLXE3DkHyDdQ==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1709,17 +1709,17 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@vue/compiler-core@3.5.19': - resolution: {integrity: sha512-/afpyvlkrSNYbPo94Qu8GtIOWS+g5TRdOvs6XZNw6pWQQmj5pBgSZvEPOIZlqWq0YvoUhDDQaQ2TnzuJdOV4hA==} + '@vue/compiler-core@3.5.20': + resolution: {integrity: sha512-8TWXUyiqFd3GmP4JTX9hbiTFRwYHgVL/vr3cqhr4YQ258+9FADwvj7golk2sWNGHR67QgmCZ8gz80nQcMokhwg==} - '@vue/compiler-dom@3.5.19': - resolution: {integrity: sha512-Drs6rPHQZx/pN9S6ml3Z3K/TWCIRPvzG2B/o5kFK9X0MNHt8/E+38tiRfojufrYBfA6FQUFB2qBBRXlcSXWtOA==} + '@vue/compiler-dom@3.5.20': + resolution: {integrity: sha512-whB44M59XKjqUEYOMPYU0ijUV0G+4fdrHVKDe32abNdX/kJe1NUEMqsi4cwzXa9kyM9w5S8WqFsrfo1ogtBZGQ==} - '@vue/compiler-sfc@3.5.19': - resolution: {integrity: sha512-YWCm1CYaJ+2RvNmhCwI7t3I3nU+hOrWGWMsn+Z/kmm1jy5iinnVtlmkiZwbLlbV1SRizX7vHsc0/bG5dj0zRTg==} + '@vue/compiler-sfc@3.5.20': + resolution: {integrity: sha512-SFcxapQc0/feWiSBfkGsa1v4DOrnMAQSYuvDMpEaxbpH5dKbnEM5KobSNSgU+1MbHCl+9ftm7oQWxvwDB6iBfw==} - '@vue/compiler-ssr@3.5.19': - resolution: {integrity: sha512-/wx0VZtkWOPdiQLWPeQeqpHWR/LuNC7bHfSX7OayBTtUy8wur6vT6EQIX6Et86aED6J+y8tTw43qo2uoqGg5sw==} + '@vue/compiler-ssr@3.5.20': + resolution: {integrity: sha512-RSl5XAMc5YFUXpDQi+UQDdVjH9FnEpLDHIALg5J0ITHxkEzJ8uQLlo7CIbjPYqmZtt6w0TsIPbo1izYXwDG7JA==} '@vue/compiler-vue2@2.7.16': resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} @@ -1749,39 +1749,39 @@ packages: typescript: optional: true - '@vue/reactivity@3.5.19': - resolution: {integrity: sha512-4bueZg2qs5MSsK2dQk3sssV0cfvxb/QZntTC8v7J448GLgmfPkQ+27aDjlt40+XFqOwUq5yRxK5uQh14Fc9eVA==} + '@vue/reactivity@3.5.20': + resolution: {integrity: sha512-hS8l8x4cl1fmZpSQX/NXlqWKARqEsNmfkwOIYqtR2F616NGfsLUm0G6FQBK6uDKUCVyi1YOL8Xmt/RkZcd/jYQ==} - '@vue/runtime-core@3.5.19': - resolution: {integrity: sha512-TaooCr8Hge1sWjLSyhdubnuofs3shhzZGfyD11gFolZrny76drPwBVQj28/z/4+msSFb18tOIg6VVVgf9/IbIA==} + '@vue/runtime-core@3.5.20': + resolution: {integrity: sha512-vyQRiH5uSZlOa+4I/t4Qw/SsD/gbth0SW2J7oMeVlMFMAmsG1rwDD6ok0VMmjXY3eI0iHNSSOBilEDW98PLRKw==} - '@vue/runtime-dom@3.5.19': - resolution: {integrity: sha512-qmahqeok6ztuUTmV8lqd7N9ymbBzctNF885n8gL3xdCC1u2RnM/coX16Via0AiONQXUoYpxPojL3U1IsDgSWUQ==} + '@vue/runtime-dom@3.5.20': + resolution: {integrity: sha512-KBHzPld/Djw3im0CQ7tGCpgRedryIn4CcAl047EhFTCCPT2xFf4e8j6WeKLgEEoqPSl9TYqShc3Q6tpWpz/Xgw==} - '@vue/server-renderer@3.5.19': - resolution: {integrity: sha512-ZJ/zV9SQuaIO+BEEVq/2a6fipyrSYfjKMU3267bPUk+oTx/hZq3RzV7VCh0Unlppt39Bvh6+NzxeopIFv4HJNg==} + '@vue/server-renderer@3.5.20': + resolution: {integrity: sha512-HthAS0lZJDH21HFJBVNTtx+ULcIbJQRpjSVomVjfyPkFSpCwvsPTA+jIzOaUm3Hrqx36ozBHePztQFg6pj5aKg==} peerDependencies: - vue: 3.5.19 + vue: 3.5.20 - '@vue/shared@3.5.19': - resolution: {integrity: sha512-IhXCOn08wgKrLQxRFKKlSacWg4Goi1BolrdEeLYn6tgHjJNXVrWJ5nzoxZqNwl5p88aLlQ8LOaoMa3AYvaKJ/Q==} + '@vue/shared@3.5.20': + resolution: {integrity: sha512-SoRGP596KU/ig6TfgkCMbXkr4YJ91n/QSdMuqeP5r3hVIYA3CPHUBCc7Skak0EAKV+5lL4KyIh61VA/pK1CIAA==} - '@vueuse/core@13.7.0': - resolution: {integrity: sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg==} + '@vueuse/core@13.8.0': + resolution: {integrity: sha512-rmBcgpEpxY0ZmyQQR94q1qkUcHREiLxQwNyWrtjMDipD0WTH/JBcAt0gdcn2PsH0SA76ec291cHFngmyaBhlxA==} peerDependencies: vue: ^3.5.0 - '@vueuse/metadata@13.7.0': - resolution: {integrity: sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg==} + '@vueuse/metadata@13.8.0': + resolution: {integrity: sha512-BYMp3Gp1kBUPv7AfQnJYP96mkX7g7cKdTIgwv/Jgd+pfQhz678naoZOAcknRtPLP4cFblDDW7rF4e3KFa+PfIA==} - '@vueuse/nuxt@13.7.0': - resolution: {integrity: sha512-LYSitaGaTowchiXQVqIO7aJ2M2qpwAjxhkAbAXhplJ2GAnKUgPGaVauai3u97LJUbI1cU8/e0b6fYOi3RTUF6g==} + '@vueuse/nuxt@13.8.0': + resolution: {integrity: sha512-RpD/CWl6nJ6q92+EpPCsnwlq/N7YqTm1TRV9SY7ERt3WRaMzkIyMfYMGOonpGRG2Y0XSHK9aiWi8+QpoB1YKDw==} peerDependencies: nuxt: ^3.0.0 || ^4.0.0-0 vue: ^3.5.0 - '@vueuse/shared@13.7.0': - resolution: {integrity: sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg==} + '@vueuse/shared@13.8.0': + resolution: {integrity: sha512-x4nfM0ykW+RmNJ4/1IzZsuLuWWrNTxlTWUiehTGI54wnOxIgI9EDdu/O5S77ac6hvQ3hk2KpOVFHaM0M796Kbw==} peerDependencies: vue: ^3.5.0 @@ -1931,8 +1931,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.25.3: - resolution: {integrity: sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==} + browserslist@4.25.4: + resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2323,8 +2323,8 @@ packages: peerDependencies: typescript: ^5.4.4 - devalue@5.1.1: - resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} + devalue@5.3.2: + resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} diff@8.0.2: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} @@ -2368,8 +2368,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.208: - resolution: {integrity: sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg==} + electron-to-chromium@1.5.211: + resolution: {integrity: sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2688,8 +2688,8 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - hls.js@1.6.10: - resolution: {integrity: sha512-16XHorwFNh+hYazYxDNXBLEm5aRoU+oxMX6qVnkbGH3hJil4xLav3/M6NH92VkD1qSOGKXeSm+5unuawPXK6OQ==} + hls.js@1.6.11: + resolution: {integrity: sha512-tdDwOAgPGXohSiNE4oxGr3CI9Hx9lsGLFe6TULUvRk2TfHS+w1tSAJntrvxsHaxvjtr6BXsDZM7NOqJFhU4mmg==} hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -3387,6 +3387,9 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.0.0: + resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -3767,8 +3770,8 @@ packages: rollup: optional: true - rollup@4.48.0: - resolution: {integrity: sha512-BXHRqK1vyt9XVSEHZ9y7xdYtuYbwVod2mLwOMFP7t/Eqoc1pHRlG/WdV2qNeNvZHRQdLedaFycljaYYM96RqJQ==} + rollup@4.49.0: + resolution: {integrity: sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4113,6 +4116,10 @@ packages: resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==} engines: {node: '>=18.12.0'} + unplugin-utils@0.3.0: + resolution: {integrity: sha512-JLoggz+PvLVMJo+jZt97hdIIIZ2yTzGgft9e9q8iMrC4ewufl62ekeW7mixBghonn2gVb/ICjyvlmOCUBnJLQg==} + engines: {node: '>=20.19.0'} + unplugin-vue-router@0.15.0: resolution: {integrity: sha512-PyGehCjd9Ny9h+Uer4McbBjjib3lHihcyUEILa7pHKl6+rh8N7sFyw4ZkV+N30Oq2zmIUG7iKs3qpL0r+gXAaQ==} peerDependencies: @@ -4125,8 +4132,8 @@ packages: unplugin@1.0.1: resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} - unplugin@2.3.8: - resolution: {integrity: sha512-lkaSIlxceytPyt9yfb1h7L9jDFqwMqvUZeGsKB7Z8QrvAO3xZv2S+xMQQYzxk0AGJHcQhbcvhKEstrMy99jnuQ==} + unplugin@2.3.9: + resolution: {integrity: sha512-2dcbZq6aprwXTkzptq3k5qm5B8cvpjG9ynPd5fyM2wDJuuF7PeUK64Sxf0d+X1ZyDOeGydbNzMqBSIVlH8GIfA==} engines: {node: '>=18.12.0'} unstorage@1.17.0: @@ -4242,8 +4249,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite-plugin-checker@0.10.2: - resolution: {integrity: sha512-FX9U8TnIS6AGOlqmC6O2YmkJzcZJRrjA03UF7FOhcUJ7it3HmCoxcIPMcoHliBP6EFOuNzle9K4c0JL4suRPow==} + vite-plugin-checker@0.10.3: + resolution: {integrity: sha512-f4sekUcDPF+T+GdbbE8idb1i2YplBAoH+SfRS0e/WRBWb2rYb1Jf5Pimll0Rj+3JgIYWwG2K5LtBPCXxoibkLg==} engines: {node: '>=14.16'} peerDependencies: '@biomejs/biome': '>=1.7' @@ -4276,8 +4283,8 @@ packages: vue-tsc: optional: true - vite-plugin-inspect@11.3.2: - resolution: {integrity: sha512-nzwvyFQg58XSMAmKVLr2uekAxNYvAbz1lyPmCAFVIBncCgN9S/HPM+2UM9Q9cvc4JEbC5ZBgwLAdaE2onmQuKg==} + vite-plugin-inspect@11.3.3: + resolution: {integrity: sha512-u2eV5La99oHoYPHE6UvbwgEqKKOQGz86wMg40CCosP6q8BkB6e5xPneZfYagK4ojPJSj5anHCrnvC20DpwVdRA==} engines: {node: '>=14'} peerDependencies: '@nuxt/kit': '*' @@ -4356,8 +4363,8 @@ packages: peerDependencies: vue: ^3.0.2 - vue@3.5.19: - resolution: {integrity: sha512-ZRh0HTmw6KChRYWgN8Ox/wi7VhpuGlvMPrHjIsdRbzKNgECFLzy+dKL5z9yGaBSjCpmcfJCbh3I1tNSRmBz2tg==} + vue@3.5.20: + resolution: {integrity: sha512-2sBz0x/wis5TkF1XZ2vH25zWq3G1bFEPOfkBcx2ikowmphoQsPH6X0V3mmPCXA2K1N/XGTnifVyDQP4GfDDeQw==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -4556,7 +4563,7 @@ snapshots: dependencies: '@babel/compat-data': 7.28.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.3 + browserslist: 4.25.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -4701,18 +4708,18 @@ snapshots: gonzales-pe: 4.3.0 node-source-walk: 7.0.1 - '@emnapi/core@1.4.5': + '@emnapi/core@1.5.0': dependencies: - '@emnapi/wasi-threads': 1.0.4 + '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.4.5': + '@emnapi/runtime@1.5.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.0.4': + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true @@ -4882,7 +4889,7 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@ioredis/commands@1.3.0': {} + '@ioredis/commands@1.3.1': {} '@isaacs/cliui@8.0.2': dependencies: @@ -4944,8 +4951,8 @@ snapshots: '@napi-rs/wasm-runtime@1.0.3': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.5.0 + '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.0 optional: true @@ -4970,12 +4977,12 @@ snapshots: uuid: 11.1.0 write-file-atomic: 6.0.0 - '@netlify/functions@3.1.10(rollup@4.48.0)': + '@netlify/functions@3.1.10(rollup@4.49.0)': dependencies: '@netlify/blobs': 9.1.2 '@netlify/dev-utils': 2.2.0 '@netlify/serverless-functions-api': 1.41.2 - '@netlify/zip-it-and-ship-it': 12.2.1(rollup@4.48.0) + '@netlify/zip-it-and-ship-it': 12.2.1(rollup@4.49.0) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -4995,15 +5002,15 @@ snapshots: '@netlify/serverless-functions-api@1.41.2': {} - '@netlify/serverless-functions-api@2.2.1': {} + '@netlify/serverless-functions-api@2.3.0': {} - '@netlify/zip-it-and-ship-it@12.2.1(rollup@4.48.0)': + '@netlify/zip-it-and-ship-it@12.2.1(rollup@4.49.0)': dependencies: '@babel/parser': 7.28.3 '@babel/types': 7.28.0 '@netlify/binary-info': 1.0.0 - '@netlify/serverless-functions-api': 2.2.1 - '@vercel/nft': 0.29.4(rollup@4.48.0) + '@netlify/serverless-functions-api': 2.3.0 + '@vercel/nft': 0.29.4(rollup@4.49.0) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -5101,12 +5108,12 @@ snapshots: prompts: 2.4.2 semver: 7.7.2 - '@nuxt/devtools@2.6.3(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2))': + '@nuxt/devtools@2.6.3(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))': dependencies: '@nuxt/devtools-kit': 2.6.3(magicast@0.3.5)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) '@nuxt/devtools-wizard': 2.6.3 '@nuxt/kit': 3.18.1(magicast@0.3.5) - '@vue/devtools-core': 7.7.7(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2)) + '@vue/devtools-core': 7.7.7(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2)) '@vue/devtools-kit': 7.7.7 birpc: 2.5.0 consola: 3.4.2 @@ -5132,8 +5139,8 @@ snapshots: structured-clone-es: 1.0.0 tinyglobby: 0.2.14 vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) - vite-plugin-inspect: 11.3.2(@nuxt/kit@3.18.1(magicast@0.3.5))(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) - vite-plugin-vue-tracer: 1.0.0(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2)) + vite-plugin-inspect: 11.3.3(@nuxt/kit@3.18.1(magicast@0.3.5))(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + vite-plugin-vue-tracer: 1.0.0(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2)) which: 5.0.0 ws: 8.18.3 transitivePeerDependencies: @@ -5197,7 +5204,7 @@ snapshots: '@nuxt/schema@4.0.3': dependencies: - '@vue/shared': 3.5.19 + '@vue/shared': 3.5.20 consola: 3.4.2 defu: 6.1.4 pathe: 2.0.3 @@ -5221,12 +5228,12 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/vite-builder@4.0.3(@types/node@24.3.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2))(yaml@2.8.1)': + '@nuxt/vite-builder@4.0.3(@types/node@24.3.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))(yaml@2.8.1)': dependencies: '@nuxt/kit': 4.0.3(magicast@0.3.5) - '@rollup/plugin-replace': 6.0.2(rollup@4.48.0) - '@vitejs/plugin-vue': 6.0.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2)) - '@vitejs/plugin-vue-jsx': 5.0.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2)) + '@rollup/plugin-replace': 6.0.2(rollup@4.49.0) + '@vitejs/plugin-vue': 6.0.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2)) + '@vitejs/plugin-vue-jsx': 5.1.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2)) autoprefixer: 10.4.21(postcss@8.5.6) consola: 3.4.2 cssnano: 7.1.1(postcss@8.5.6) @@ -5244,14 +5251,14 @@ snapshots: pathe: 2.0.3 pkg-types: 2.3.0 postcss: 8.5.6 - rollup-plugin-visualizer: 6.0.3(rollup@4.48.0) + rollup-plugin-visualizer: 6.0.3(rollup@4.49.0) std-env: 3.9.0 ufo: 1.6.1 unenv: 2.0.0-rc.19 vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) - vite-plugin-checker: 0.10.2(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) - vue: 3.5.19(typescript@5.9.2) + vite-plugin-checker: 0.10.3(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + vue: 3.5.20(typescript@5.9.2) vue-bundle-renderer: 2.1.2 transitivePeerDependencies: - '@biomejs/biome' @@ -5316,7 +5323,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-dataloader@0.21.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-dataloader@0.21.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) @@ -5382,7 +5389,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-kafkajs@0.12.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-kafkajs@0.13.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) @@ -5431,7 +5438,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mysql2@0.49.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mysql2@0.50.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) @@ -5738,10 +5745,10 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 - '@pinia/nuxt@0.11.2(magicast@0.3.5)(pinia@3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)))': + '@pinia/nuxt@0.11.2(magicast@0.3.5)(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))': dependencies: '@nuxt/kit': 3.18.1(magicast@0.3.5) - pinia: 3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)) + pinia: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)) transitivePeerDependencies: - magicast @@ -5762,7 +5769,7 @@ snapshots: '@poppinss/exception@1.2.2': {} - '@prisma/instrumentation@6.13.0(@opentelemetry/api@1.9.0)': + '@prisma/instrumentation@6.14.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.0) @@ -5771,15 +5778,15 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.29': {} - '@rolldown/pluginutils@1.0.0-beta.33': {} + '@rolldown/pluginutils@1.0.0-beta.34': {} - '@rollup/plugin-alias@5.1.1(rollup@4.48.0)': + '@rollup/plugin-alias@5.1.1(rollup@4.49.0)': optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/plugin-commonjs@28.0.6(rollup@4.48.0)': + '@rollup/plugin-commonjs@28.0.6(rollup@4.49.0)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.48.0) + '@rollup/pluginutils': 5.2.0(rollup@4.49.0) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) @@ -5787,147 +5794,147 @@ snapshots: magic-string: 0.30.18 picomatch: 4.0.3 optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/plugin-inject@5.0.5(rollup@4.48.0)': + '@rollup/plugin-inject@5.0.5(rollup@4.49.0)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.48.0) + '@rollup/pluginutils': 5.2.0(rollup@4.49.0) estree-walker: 2.0.2 magic-string: 0.30.18 optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/plugin-json@6.1.0(rollup@4.48.0)': + '@rollup/plugin-json@6.1.0(rollup@4.49.0)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.48.0) + '@rollup/pluginutils': 5.2.0(rollup@4.49.0) optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/plugin-node-resolve@16.0.1(rollup@4.48.0)': + '@rollup/plugin-node-resolve@16.0.1(rollup@4.49.0)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.48.0) + '@rollup/pluginutils': 5.2.0(rollup@4.49.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/plugin-replace@6.0.2(rollup@4.48.0)': + '@rollup/plugin-replace@6.0.2(rollup@4.49.0)': dependencies: - '@rollup/pluginutils': 5.2.0(rollup@4.48.0) + '@rollup/pluginutils': 5.2.0(rollup@4.49.0) magic-string: 0.30.18 optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/plugin-terser@0.4.4(rollup@4.48.0)': + '@rollup/plugin-terser@0.4.4(rollup@4.49.0)': dependencies: serialize-javascript: 6.0.2 smob: 1.5.0 terser: 5.43.1 optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/pluginutils@5.2.0(rollup@4.48.0)': + '@rollup/pluginutils@5.2.0(rollup@4.49.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - '@rollup/rollup-android-arm-eabi@4.48.0': + '@rollup/rollup-android-arm-eabi@4.49.0': optional: true - '@rollup/rollup-android-arm64@4.48.0': + '@rollup/rollup-android-arm64@4.49.0': optional: true - '@rollup/rollup-darwin-arm64@4.48.0': + '@rollup/rollup-darwin-arm64@4.49.0': optional: true - '@rollup/rollup-darwin-x64@4.48.0': + '@rollup/rollup-darwin-x64@4.49.0': optional: true - '@rollup/rollup-freebsd-arm64@4.48.0': + '@rollup/rollup-freebsd-arm64@4.49.0': optional: true - '@rollup/rollup-freebsd-x64@4.48.0': + '@rollup/rollup-freebsd-x64@4.49.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.48.0': + '@rollup/rollup-linux-arm-gnueabihf@4.49.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.48.0': + '@rollup/rollup-linux-arm-musleabihf@4.49.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.48.0': + '@rollup/rollup-linux-arm64-gnu@4.49.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.48.0': + '@rollup/rollup-linux-arm64-musl@4.49.0': optional: true - '@rollup/rollup-linux-loongarch64-gnu@4.48.0': + '@rollup/rollup-linux-loongarch64-gnu@4.49.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.48.0': + '@rollup/rollup-linux-ppc64-gnu@4.49.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.48.0': + '@rollup/rollup-linux-riscv64-gnu@4.49.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.48.0': + '@rollup/rollup-linux-riscv64-musl@4.49.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.48.0': + '@rollup/rollup-linux-s390x-gnu@4.49.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.48.0': + '@rollup/rollup-linux-x64-gnu@4.49.0': optional: true - '@rollup/rollup-linux-x64-musl@4.48.0': + '@rollup/rollup-linux-x64-musl@4.49.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.48.0': + '@rollup/rollup-win32-arm64-msvc@4.49.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.48.0': + '@rollup/rollup-win32-ia32-msvc@4.49.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.48.0': + '@rollup/rollup-win32-x64-msvc@4.49.0': optional: true - '@sentry-internal/browser-utils@10.5.0': + '@sentry-internal/browser-utils@10.8.0': dependencies: - '@sentry/core': 10.5.0 + '@sentry/core': 10.8.0 - '@sentry-internal/feedback@10.5.0': + '@sentry-internal/feedback@10.8.0': dependencies: - '@sentry/core': 10.5.0 + '@sentry/core': 10.8.0 - '@sentry-internal/replay-canvas@10.5.0': + '@sentry-internal/replay-canvas@10.8.0': dependencies: - '@sentry-internal/replay': 10.5.0 - '@sentry/core': 10.5.0 + '@sentry-internal/replay': 10.8.0 + '@sentry/core': 10.8.0 - '@sentry-internal/replay@10.5.0': + '@sentry-internal/replay@10.8.0': dependencies: - '@sentry-internal/browser-utils': 10.5.0 - '@sentry/core': 10.5.0 + '@sentry-internal/browser-utils': 10.8.0 + '@sentry/core': 10.8.0 - '@sentry/babel-plugin-component-annotate@4.1.1': {} + '@sentry/babel-plugin-component-annotate@4.2.0': {} - '@sentry/browser@10.5.0': + '@sentry/browser@10.8.0': dependencies: - '@sentry-internal/browser-utils': 10.5.0 - '@sentry-internal/feedback': 10.5.0 - '@sentry-internal/replay': 10.5.0 - '@sentry-internal/replay-canvas': 10.5.0 - '@sentry/core': 10.5.0 + '@sentry-internal/browser-utils': 10.8.0 + '@sentry-internal/feedback': 10.8.0 + '@sentry-internal/replay': 10.8.0 + '@sentry-internal/replay-canvas': 10.8.0 + '@sentry/core': 10.8.0 - '@sentry/bundler-plugin-core@4.1.1': + '@sentry/bundler-plugin-core@4.2.0': dependencies: '@babel/core': 7.28.3 - '@sentry/babel-plugin-component-annotate': 4.1.1 + '@sentry/babel-plugin-component-annotate': 4.2.0 '@sentry/cli': 2.52.0 dotenv: 16.6.1 find-up: 5.0.0 @@ -5982,14 +5989,14 @@ snapshots: - encoding - supports-color - '@sentry/cloudflare@10.5.0': + '@sentry/cloudflare@10.8.0': dependencies: '@opentelemetry/api': 1.9.0 - '@sentry/core': 10.5.0 + '@sentry/core': 10.8.0 - '@sentry/core@10.5.0': {} + '@sentry/core@10.8.0': {} - '@sentry/node-core@10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': + '@sentry/node-core@10.8.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) @@ -5998,11 +6005,11 @@ snapshots: '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 - '@sentry/core': 10.5.0 - '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/core': 10.8.0 + '@sentry/opentelemetry': 10.8.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) import-in-the-middle: 1.14.2 - '@sentry/node@10.5.0': + '@sentry/node@10.8.0': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) @@ -6010,7 +6017,7 @@ snapshots: '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-amqplib': 0.50.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-connect': 0.47.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-dataloader': 0.21.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.21.1(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-express': 0.52.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-fs': 0.23.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-generic-pool': 0.47.0(@opentelemetry/api@1.9.0) @@ -6018,14 +6025,14 @@ snapshots: '@opentelemetry/instrumentation-hapi': 0.50.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-http': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-ioredis': 0.51.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-kafkajs': 0.12.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.13.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-knex': 0.48.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-koa': 0.51.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-lru-memoizer': 0.48.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-mongodb': 0.56.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-mongoose': 0.50.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-mysql': 0.49.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql2': 0.49.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.50.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-pg': 0.55.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-redis': 0.51.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation-tedious': 0.22.0(@opentelemetry/api@1.9.0) @@ -6033,26 +6040,26 @@ snapshots: '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 - '@prisma/instrumentation': 6.13.0(@opentelemetry/api@1.9.0) - '@sentry/core': 10.5.0 - '@sentry/node-core': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) - '@sentry/opentelemetry': 10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@prisma/instrumentation': 6.14.0(@opentelemetry/api@1.9.0) + '@sentry/core': 10.8.0 + '@sentry/node-core': 10.8.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/opentelemetry': 10.8.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) import-in-the-middle: 1.14.2 minimatch: 9.0.5 transitivePeerDependencies: - supports-color - '@sentry/nuxt@10.5.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)))(rollup@4.48.0)(vue@3.5.19(typescript@5.9.2))': + '@sentry/nuxt@10.8.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))(rollup@4.49.0)(vue@3.5.20(typescript@5.9.2))': dependencies: '@nuxt/kit': 3.18.1(magicast@0.3.5) - '@sentry/browser': 10.5.0 - '@sentry/cloudflare': 10.5.0 - '@sentry/core': 10.5.0 - '@sentry/node': 10.5.0 - '@sentry/rollup-plugin': 4.1.1(rollup@4.48.0) - '@sentry/vite-plugin': 4.1.1 - '@sentry/vue': 10.5.0(pinia@3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)))(vue@3.5.19(typescript@5.9.2)) - nuxt: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) + '@sentry/browser': 10.8.0 + '@sentry/cloudflare': 10.8.0 + '@sentry/core': 10.8.0 + '@sentry/node': 10.8.0 + '@sentry/rollup-plugin': 4.2.0(rollup@4.49.0) + '@sentry/vite-plugin': 4.2.0 + '@sentry/vue': 10.8.0(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2)) + nuxt: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) transitivePeerDependencies: - '@cloudflare/workers-types' - encoding @@ -6062,39 +6069,39 @@ snapshots: - supports-color - vue - '@sentry/opentelemetry@10.5.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': + '@sentry/opentelemetry@10.8.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.36.0 - '@sentry/core': 10.5.0 + '@sentry/core': 10.8.0 - '@sentry/rollup-plugin@4.1.1(rollup@4.48.0)': + '@sentry/rollup-plugin@4.2.0(rollup@4.49.0)': dependencies: - '@sentry/bundler-plugin-core': 4.1.1 - rollup: 4.48.0 + '@sentry/bundler-plugin-core': 4.2.0 + rollup: 4.49.0 unplugin: 1.0.1 transitivePeerDependencies: - encoding - supports-color - '@sentry/vite-plugin@4.1.1': + '@sentry/vite-plugin@4.2.0': dependencies: - '@sentry/bundler-plugin-core': 4.1.1 + '@sentry/bundler-plugin-core': 4.2.0 unplugin: 1.0.1 transitivePeerDependencies: - encoding - supports-color - '@sentry/vue@10.5.0(pinia@3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)))(vue@3.5.19(typescript@5.9.2))': + '@sentry/vue@10.8.0(pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))': dependencies: - '@sentry/browser': 10.5.0 - '@sentry/core': 10.5.0 - vue: 3.5.19(typescript@5.9.2) + '@sentry/browser': 10.8.0 + '@sentry/core': 10.8.0 + vue: 3.5.20(typescript@5.9.2) optionalDependencies: - pinia: 3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)) + pinia: 3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)) '@sindresorhus/is@7.0.2': {} @@ -6156,27 +6163,27 @@ snapshots: '@types/node': 24.3.0 optional: true - '@typescript-eslint/project-service@8.40.0(typescript@5.9.2)': + '@typescript-eslint/project-service@8.41.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.40.0(typescript@5.9.2) - '@typescript-eslint/types': 8.40.0 + '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.9.2) + '@typescript-eslint/types': 8.41.0 debug: 4.4.1 typescript: 5.9.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/tsconfig-utils@8.40.0(typescript@5.9.2)': + '@typescript-eslint/tsconfig-utils@8.41.0(typescript@5.9.2)': dependencies: typescript: 5.9.2 - '@typescript-eslint/types@8.40.0': {} + '@typescript-eslint/types@8.41.0': {} - '@typescript-eslint/typescript-estree@8.40.0(typescript@5.9.2)': + '@typescript-eslint/typescript-estree@8.41.0(typescript@5.9.2)': dependencies: - '@typescript-eslint/project-service': 8.40.0(typescript@5.9.2) - '@typescript-eslint/tsconfig-utils': 8.40.0(typescript@5.9.2) - '@typescript-eslint/types': 8.40.0 - '@typescript-eslint/visitor-keys': 8.40.0 + '@typescript-eslint/project-service': 8.41.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.41.0(typescript@5.9.2) + '@typescript-eslint/types': 8.41.0 + '@typescript-eslint/visitor-keys': 8.41.0 debug: 4.4.1 fast-glob: 3.3.3 is-glob: 4.0.3 @@ -6187,21 +6194,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.40.0': + '@typescript-eslint/visitor-keys@8.41.0': dependencies: - '@typescript-eslint/types': 8.40.0 + '@typescript-eslint/types': 8.41.0 eslint-visitor-keys: 4.2.1 - '@unhead/vue@2.0.14(vue@3.5.19(typescript@5.9.2))': + '@unhead/vue@2.0.14(vue@3.5.20(typescript@5.9.2))': dependencies: hookable: 5.5.3 unhead: 2.0.14 - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) - '@vercel/nft@0.29.4(rollup@4.48.0)': + '@vercel/nft@0.29.4(rollup@4.49.0)': dependencies: '@mapbox/node-pre-gyp': 2.0.0 - '@rollup/pluginutils': 5.2.0(rollup@4.48.0) + '@rollup/pluginutils': 5.2.0(rollup@4.49.0) acorn: 8.15.0 acorn-import-attributes: 1.9.5(acorn@8.15.0) async-sema: 3.1.1 @@ -6217,22 +6224,23 @@ snapshots: - rollup - supports-color - '@vitejs/plugin-vue-jsx@5.0.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2))': + '@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))': dependencies: '@babel/core': 7.28.3 + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.3) '@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.3) - '@rolldown/pluginutils': 1.0.0-beta.33 + '@rolldown/pluginutils': 1.0.0-beta.34 '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.3) vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2))': + '@vitejs/plugin-vue@6.0.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) '@volar/language-core@2.4.23': dependencies: @@ -6240,15 +6248,15 @@ snapshots: '@volar/source-map@2.4.23': {} - '@vue-macros/common@3.0.0-beta.16(vue@3.5.19(typescript@5.9.2))': + '@vue-macros/common@3.0.0-beta.16(vue@3.5.20(typescript@5.9.2))': dependencies: - '@vue/compiler-sfc': 3.5.19 + '@vue/compiler-sfc': 3.5.20 ast-kit: 2.1.2 local-pkg: 1.1.2 magic-string-ast: 1.0.2 unplugin-utils: 0.2.5 optionalDependencies: - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) '@vue/babel-helper-vue-transform-on@1.5.0': {} @@ -6262,7 +6270,7 @@ snapshots: '@babel/types': 7.28.2 '@vue/babel-helper-vue-transform-on': 1.5.0 '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.28.3) - '@vue/shared': 3.5.19 + '@vue/shared': 3.5.20 optionalDependencies: '@babel/core': 7.28.3 transitivePeerDependencies: @@ -6275,39 +6283,39 @@ snapshots: '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 '@babel/parser': 7.28.3 - '@vue/compiler-sfc': 3.5.19 + '@vue/compiler-sfc': 3.5.20 transitivePeerDependencies: - supports-color - '@vue/compiler-core@3.5.19': + '@vue/compiler-core@3.5.20': dependencies: '@babel/parser': 7.28.3 - '@vue/shared': 3.5.19 + '@vue/shared': 3.5.20 entities: 4.5.0 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.19': + '@vue/compiler-dom@3.5.20': dependencies: - '@vue/compiler-core': 3.5.19 - '@vue/shared': 3.5.19 + '@vue/compiler-core': 3.5.20 + '@vue/shared': 3.5.20 - '@vue/compiler-sfc@3.5.19': + '@vue/compiler-sfc@3.5.20': dependencies: '@babel/parser': 7.28.3 - '@vue/compiler-core': 3.5.19 - '@vue/compiler-dom': 3.5.19 - '@vue/compiler-ssr': 3.5.19 - '@vue/shared': 3.5.19 + '@vue/compiler-core': 3.5.20 + '@vue/compiler-dom': 3.5.20 + '@vue/compiler-ssr': 3.5.20 + '@vue/shared': 3.5.20 estree-walker: 2.0.2 magic-string: 0.30.18 postcss: 8.5.6 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.19': + '@vue/compiler-ssr@3.5.20': dependencies: - '@vue/compiler-dom': 3.5.19 - '@vue/shared': 3.5.19 + '@vue/compiler-dom': 3.5.20 + '@vue/shared': 3.5.20 '@vue/compiler-vue2@2.7.16': dependencies: @@ -6320,7 +6328,7 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.7 - '@vue/devtools-core@7.7.7(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2))': + '@vue/devtools-core@7.7.7(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))': dependencies: '@vue/devtools-kit': 7.7.7 '@vue/devtools-shared': 7.7.7 @@ -6328,7 +6336,7 @@ snapshots: nanoid: 5.1.5 pathe: 2.0.3 vite-hot-client: 2.1.0(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) transitivePeerDependencies: - vite @@ -6349,9 +6357,9 @@ snapshots: '@vue/language-core@3.0.6(typescript@5.9.2)': dependencies: '@volar/language-core': 2.4.23 - '@vue/compiler-dom': 3.5.19 + '@vue/compiler-dom': 3.5.20 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.19 + '@vue/shared': 3.5.20 alien-signals: 2.0.7 muggle-string: 0.4.1 path-browserify: 1.0.1 @@ -6359,53 +6367,53 @@ snapshots: optionalDependencies: typescript: 5.9.2 - '@vue/reactivity@3.5.19': + '@vue/reactivity@3.5.20': dependencies: - '@vue/shared': 3.5.19 + '@vue/shared': 3.5.20 - '@vue/runtime-core@3.5.19': + '@vue/runtime-core@3.5.20': dependencies: - '@vue/reactivity': 3.5.19 - '@vue/shared': 3.5.19 + '@vue/reactivity': 3.5.20 + '@vue/shared': 3.5.20 - '@vue/runtime-dom@3.5.19': + '@vue/runtime-dom@3.5.20': dependencies: - '@vue/reactivity': 3.5.19 - '@vue/runtime-core': 3.5.19 - '@vue/shared': 3.5.19 + '@vue/reactivity': 3.5.20 + '@vue/runtime-core': 3.5.20 + '@vue/shared': 3.5.20 csstype: 3.1.3 - '@vue/server-renderer@3.5.19(vue@3.5.19(typescript@5.9.2))': + '@vue/server-renderer@3.5.20(vue@3.5.20(typescript@5.9.2))': dependencies: - '@vue/compiler-ssr': 3.5.19 - '@vue/shared': 3.5.19 - vue: 3.5.19(typescript@5.9.2) + '@vue/compiler-ssr': 3.5.20 + '@vue/shared': 3.5.20 + vue: 3.5.20(typescript@5.9.2) - '@vue/shared@3.5.19': {} + '@vue/shared@3.5.20': {} - '@vueuse/core@13.7.0(vue@3.5.19(typescript@5.9.2))': + '@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2))': dependencies: '@types/web-bluetooth': 0.0.21 - '@vueuse/metadata': 13.7.0 - '@vueuse/shared': 13.7.0(vue@3.5.19(typescript@5.9.2)) - vue: 3.5.19(typescript@5.9.2) + '@vueuse/metadata': 13.8.0 + '@vueuse/shared': 13.8.0(vue@3.5.20(typescript@5.9.2)) + vue: 3.5.20(typescript@5.9.2) - '@vueuse/metadata@13.7.0': {} + '@vueuse/metadata@13.8.0': {} - '@vueuse/nuxt@13.7.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2))': + '@vueuse/nuxt@13.8.0(magicast@0.3.5)(nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2))': dependencies: - '@nuxt/kit': 4.0.3(magicast@0.3.5) - '@vueuse/core': 13.7.0(vue@3.5.19(typescript@5.9.2)) - '@vueuse/metadata': 13.7.0 + '@nuxt/kit': 3.18.1(magicast@0.3.5) + '@vueuse/core': 13.8.0(vue@3.5.20(typescript@5.9.2)) + '@vueuse/metadata': 13.8.0 local-pkg: 1.1.2 - nuxt: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) - vue: 3.5.19(typescript@5.9.2) + nuxt: 4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1) + vue: 3.5.20(typescript@5.9.2) transitivePeerDependencies: - magicast - '@vueuse/shared@13.7.0(vue@3.5.19(typescript@5.9.2))': + '@vueuse/shared@13.8.0(vue@3.5.20(typescript@5.9.2))': dependencies: - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) '@whatwg-node/disposablestack@0.0.6': dependencies: @@ -6518,7 +6526,7 @@ snapshots: autoprefixer@10.4.21(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 caniuse-lite: 1.0.30001737 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -6553,12 +6561,12 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.25.3: + browserslist@4.25.4: dependencies: caniuse-lite: 1.0.30001737 - electron-to-chromium: 1.5.208 + electron-to-chromium: 1.5.211 node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.3) + update-browserslist-db: 1.1.3(browserslist@4.25.4) buffer-crc32@0.2.13: {} @@ -6610,7 +6618,7 @@ snapshots: caniuse-api@3.0.0: dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 caniuse-lite: 1.0.30001737 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 @@ -6788,7 +6796,7 @@ snapshots: cssnano-preset-default@7.0.9(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 css-declaration-sorter: 7.2.0(postcss@8.5.6) cssnano-utils: 5.0.1(postcss@8.5.6) postcss: 8.5.6 @@ -6915,7 +6923,7 @@ snapshots: detective-typescript@14.0.0(typescript@5.9.2): dependencies: - '@typescript-eslint/typescript-estree': 8.40.0(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) ast-module-types: 6.0.1 node-source-walk: 7.0.1 typescript: 5.9.2 @@ -6925,7 +6933,7 @@ snapshots: detective-vue2@2.2.0(typescript@5.9.2): dependencies: '@dependents/detective-less': 5.0.1 - '@vue/compiler-sfc': 3.5.19 + '@vue/compiler-sfc': 3.5.20 detective-es6: 5.0.1 detective-sass: 6.0.1 detective-scss: 5.0.1 @@ -6935,7 +6943,7 @@ snapshots: transitivePeerDependencies: - supports-color - devalue@5.1.1: {} + devalue@5.3.2: {} diff@8.0.2: {} @@ -6977,7 +6985,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.208: {} + electron-to-chromium@1.5.211: {} emoji-regex@8.0.0: {} @@ -7190,11 +7198,11 @@ snapshots: path-exists: 5.0.0 unicorn-magic: 0.1.0 - floating-vue@5.2.2(@nuxt/kit@3.18.1(magicast@0.3.5))(vue@3.5.19(typescript@5.9.2)): + floating-vue@5.2.2(@nuxt/kit@3.18.1(magicast@0.3.5))(vue@3.5.20(typescript@5.9.2)): dependencies: '@floating-ui/dom': 1.1.1 - vue: 3.5.19(typescript@5.9.2) - vue-resize: 2.0.0-alpha.1(vue@3.5.19(typescript@5.9.2)) + vue: 3.5.20(typescript@5.9.2) + vue-resize: 2.0.0-alpha.1(vue@3.5.20(typescript@5.9.2)) optionalDependencies: '@nuxt/kit': 3.18.1(magicast@0.3.5) @@ -7342,7 +7350,7 @@ snapshots: he@1.2.0: {} - hls.js@1.6.10: {} + hls.js@1.6.11: {} hookable@5.5.3: {} @@ -7396,7 +7404,7 @@ snapshots: exsolve: 1.0.7 mocked-exports: 0.1.1 pathe: 2.0.3 - unplugin: 2.3.8 + unplugin: 2.3.9 unplugin-utils: 0.2.5 imurmurhash@0.1.4: {} @@ -7409,7 +7417,7 @@ snapshots: ioredis@5.7.0: dependencies: - '@ioredis/commands': 1.3.0 + '@ioredis/commands': 1.3.1 cluster-key-slot: 1.1.2 debug: 4.4.1 denque: 2.1.0 @@ -7625,7 +7633,7 @@ snapshots: regexp-tree: 0.1.27 type-level-regexp: 0.1.17 ufo: 1.6.1 - unplugin: 2.3.8 + unplugin: 2.3.9 magic-string-ast@1.0.2: dependencies: @@ -7746,15 +7754,15 @@ snapshots: nitropack@2.12.4(@netlify/blobs@9.1.2): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 - '@netlify/functions': 3.1.10(rollup@4.48.0) - '@rollup/plugin-alias': 5.1.1(rollup@4.48.0) - '@rollup/plugin-commonjs': 28.0.6(rollup@4.48.0) - '@rollup/plugin-inject': 5.0.5(rollup@4.48.0) - '@rollup/plugin-json': 6.1.0(rollup@4.48.0) - '@rollup/plugin-node-resolve': 16.0.1(rollup@4.48.0) - '@rollup/plugin-replace': 6.0.2(rollup@4.48.0) - '@rollup/plugin-terser': 0.4.4(rollup@4.48.0) - '@vercel/nft': 0.29.4(rollup@4.48.0) + '@netlify/functions': 3.1.10(rollup@4.49.0) + '@rollup/plugin-alias': 5.1.1(rollup@4.49.0) + '@rollup/plugin-commonjs': 28.0.6(rollup@4.49.0) + '@rollup/plugin-inject': 5.0.5(rollup@4.49.0) + '@rollup/plugin-json': 6.1.0(rollup@4.49.0) + '@rollup/plugin-node-resolve': 16.0.1(rollup@4.49.0) + '@rollup/plugin-replace': 6.0.2(rollup@4.49.0) + '@rollup/plugin-terser': 0.4.4(rollup@4.49.0) + '@vercel/nft': 0.29.4(rollup@4.49.0) archiver: 7.0.1 c12: 3.2.0(magicast@0.3.5) chokidar: 4.0.3 @@ -7796,8 +7804,8 @@ snapshots: pkg-types: 2.3.0 pretty-bytes: 6.1.1 radix3: 1.1.2 - rollup: 4.48.0 - rollup-plugin-visualizer: 6.0.3(rollup@4.48.0) + rollup: 4.49.0 + rollup-plugin-visualizer: 6.0.3(rollup@4.49.0) scule: 1.3.0 semver: 7.7.2 serve-placeholder: 2.0.2 @@ -7903,17 +7911,17 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.19)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1): + nuxt@4.0.3(@netlify/blobs@9.1.2)(@parcel/watcher@2.5.1)(@types/node@24.3.0)(@vue/compiler-sfc@3.5.20)(db0@0.3.2)(ioredis@5.7.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(yaml@2.8.1): dependencies: '@nuxt/cli': 3.28.0(magicast@0.3.5) '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 2.6.3(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2)) + '@nuxt/devtools': 2.6.3(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2)) '@nuxt/kit': 4.0.3(magicast@0.3.5) '@nuxt/schema': 4.0.3 '@nuxt/telemetry': 2.6.6(magicast@0.3.5) - '@nuxt/vite-builder': 4.0.3(@types/node@24.3.0)(magicast@0.3.5)(rollup@4.48.0)(terser@5.43.1)(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2))(yaml@2.8.1) - '@unhead/vue': 2.0.14(vue@3.5.19(typescript@5.9.2)) - '@vue/shared': 3.5.19 + '@nuxt/vite-builder': 4.0.3(@types/node@24.3.0)(magicast@0.3.5)(rollup@4.49.0)(terser@5.43.1)(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2))(yaml@2.8.1) + '@unhead/vue': 2.0.14(vue@3.5.20(typescript@5.9.2)) + '@vue/shared': 3.5.20 c12: 3.2.0(magicast@0.3.5) chokidar: 4.0.3 compatx: 0.2.0 @@ -7921,7 +7929,7 @@ snapshots: cookie-es: 2.0.0 defu: 6.1.4 destr: 2.0.5 - devalue: 5.1.1 + devalue: 5.3.2 errx: 0.1.0 esbuild: 0.25.9 escape-string-regexp: 5.0.0 @@ -7961,14 +7969,14 @@ snapshots: uncrypto: 0.1.3 unctx: 2.4.1 unimport: 5.2.0 - unplugin: 2.3.8 - unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.19)(typescript@5.9.2)(vue-router@4.5.1(vue@3.5.19(typescript@5.9.2)))(vue@3.5.19(typescript@5.9.2)) + unplugin: 2.3.9 + unplugin-vue-router: 0.15.0(@vue/compiler-sfc@3.5.20)(typescript@5.9.2)(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2)) unstorage: 1.17.0(@netlify/blobs@9.1.2)(db0@0.3.2)(ioredis@5.7.0) untyped: 2.0.0 - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) vue-bundle-renderer: 2.1.2 vue-devtools-stub: 0.1.0 - vue-router: 4.5.1(vue@3.5.19(typescript@5.9.2)) + vue-router: 4.5.1(vue@3.5.20(typescript@5.9.2)) optionalDependencies: '@parcel/watcher': 2.5.1 '@types/node': 24.3.0 @@ -8216,6 +8224,8 @@ snapshots: perfect-debounce@1.0.0: {} + perfect-debounce@2.0.0: {} + pg-int8@1.0.1: {} pg-protocol@1.10.3: {} @@ -8234,10 +8244,10 @@ snapshots: picomatch@4.0.3: {} - pinia@3.0.3(typescript@5.9.2)(vue@3.5.19(typescript@5.9.2)): + pinia@3.0.3(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)): dependencies: '@vue/devtools-api': 7.7.7 - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) optionalDependencies: typescript: 5.9.2 @@ -8261,7 +8271,7 @@ snapshots: postcss-colormin@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 caniuse-api: 3.0.0 colord: 2.9.3 postcss: 8.5.6 @@ -8269,7 +8279,7 @@ snapshots: postcss-convert-values@7.0.7(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -8298,7 +8308,7 @@ snapshots: postcss-merge-rules@7.0.6(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 caniuse-api: 3.0.0 cssnano-utils: 5.0.1(postcss@8.5.6) postcss: 8.5.6 @@ -8318,7 +8328,7 @@ snapshots: postcss-minify-params@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 cssnano-utils: 5.0.1(postcss@8.5.6) postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -8360,7 +8370,7 @@ snapshots: postcss-normalize-unicode@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -8382,7 +8392,7 @@ snapshots: postcss-reduce-initial@7.0.4(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 caniuse-api: 3.0.0 postcss: 8.5.6 @@ -8585,39 +8595,39 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-visualizer@6.0.3(rollup@4.48.0): + rollup-plugin-visualizer@6.0.3(rollup@4.49.0): dependencies: open: 8.4.2 picomatch: 4.0.3 source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: - rollup: 4.48.0 + rollup: 4.49.0 - rollup@4.48.0: + rollup@4.49.0: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.48.0 - '@rollup/rollup-android-arm64': 4.48.0 - '@rollup/rollup-darwin-arm64': 4.48.0 - '@rollup/rollup-darwin-x64': 4.48.0 - '@rollup/rollup-freebsd-arm64': 4.48.0 - '@rollup/rollup-freebsd-x64': 4.48.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.48.0 - '@rollup/rollup-linux-arm-musleabihf': 4.48.0 - '@rollup/rollup-linux-arm64-gnu': 4.48.0 - '@rollup/rollup-linux-arm64-musl': 4.48.0 - '@rollup/rollup-linux-loongarch64-gnu': 4.48.0 - '@rollup/rollup-linux-ppc64-gnu': 4.48.0 - '@rollup/rollup-linux-riscv64-gnu': 4.48.0 - '@rollup/rollup-linux-riscv64-musl': 4.48.0 - '@rollup/rollup-linux-s390x-gnu': 4.48.0 - '@rollup/rollup-linux-x64-gnu': 4.48.0 - '@rollup/rollup-linux-x64-musl': 4.48.0 - '@rollup/rollup-win32-arm64-msvc': 4.48.0 - '@rollup/rollup-win32-ia32-msvc': 4.48.0 - '@rollup/rollup-win32-x64-msvc': 4.48.0 + '@rollup/rollup-android-arm-eabi': 4.49.0 + '@rollup/rollup-android-arm64': 4.49.0 + '@rollup/rollup-darwin-arm64': 4.49.0 + '@rollup/rollup-darwin-x64': 4.49.0 + '@rollup/rollup-freebsd-arm64': 4.49.0 + '@rollup/rollup-freebsd-x64': 4.49.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.49.0 + '@rollup/rollup-linux-arm-musleabihf': 4.49.0 + '@rollup/rollup-linux-arm64-gnu': 4.49.0 + '@rollup/rollup-linux-arm64-musl': 4.49.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.49.0 + '@rollup/rollup-linux-ppc64-gnu': 4.49.0 + '@rollup/rollup-linux-riscv64-gnu': 4.49.0 + '@rollup/rollup-linux-riscv64-musl': 4.49.0 + '@rollup/rollup-linux-s390x-gnu': 4.49.0 + '@rollup/rollup-linux-x64-gnu': 4.49.0 + '@rollup/rollup-linux-x64-musl': 4.49.0 + '@rollup/rollup-win32-arm64-msvc': 4.49.0 + '@rollup/rollup-win32-ia32-msvc': 4.49.0 + '@rollup/rollup-win32-x64-msvc': 4.49.0 fsevents: 2.3.3 run-applescript@7.0.0: {} @@ -8839,7 +8849,7 @@ snapshots: stylehacks@7.0.6(postcss@8.5.6): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 postcss: 8.5.6 postcss-selector-parser: 7.1.0 @@ -8943,7 +8953,7 @@ snapshots: acorn: 8.15.0 estree-walker: 3.0.3 magic-string: 0.30.18 - unplugin: 2.3.8 + unplugin: 2.3.9 undici-types@7.10.0: {} @@ -8977,7 +8987,7 @@ snapshots: scule: 1.3.0 strip-literal: 3.0.0 tinyglobby: 0.2.14 - unplugin: 2.3.8 + unplugin: 2.3.9 unplugin-utils: 0.2.5 unixify@1.0.0: @@ -8989,10 +8999,15 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.3 - unplugin-vue-router@0.15.0(@vue/compiler-sfc@3.5.19)(typescript@5.9.2)(vue-router@4.5.1(vue@3.5.19(typescript@5.9.2)))(vue@3.5.19(typescript@5.9.2)): + unplugin-utils@0.3.0: dependencies: - '@vue-macros/common': 3.0.0-beta.16(vue@3.5.19(typescript@5.9.2)) - '@vue/compiler-sfc': 3.5.19 + pathe: 2.0.3 + picomatch: 4.0.3 + + unplugin-vue-router@0.15.0(@vue/compiler-sfc@3.5.20)(typescript@5.9.2)(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2)): + dependencies: + '@vue-macros/common': 3.0.0-beta.16(vue@3.5.20(typescript@5.9.2)) + '@vue/compiler-sfc': 3.5.20 '@vue/language-core': 3.0.6(typescript@5.9.2) ast-walker-scope: 0.8.2 chokidar: 4.0.3 @@ -9005,11 +9020,11 @@ snapshots: picomatch: 4.0.3 scule: 1.3.0 tinyglobby: 0.2.14 - unplugin: 2.3.8 + unplugin: 2.3.9 unplugin-utils: 0.2.5 yaml: 2.8.1 optionalDependencies: - vue-router: 4.5.1(vue@3.5.19(typescript@5.9.2)) + vue-router: 4.5.1(vue@3.5.20(typescript@5.9.2)) transitivePeerDependencies: - typescript - vue @@ -9021,7 +9036,7 @@ snapshots: webpack-sources: 3.3.3 webpack-virtual-modules: 0.5.0 - unplugin@2.3.8: + unplugin@2.3.9: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.15.0 @@ -9064,11 +9079,11 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 pkg-types: 2.3.0 - unplugin: 2.3.8 + unplugin: 2.3.9 - update-browserslist-db@1.1.3(browserslist@4.25.3): + update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: - browserslist: 4.25.3 + browserslist: 4.25.4 escalade: 3.2.0 picocolors: 1.1.1 @@ -9118,7 +9133,7 @@ snapshots: - tsx - yaml - vite-plugin-checker@0.10.2(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): + vite-plugin-checker@0.10.3(typescript@5.9.2)(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): dependencies: '@babel/code-frame': 7.27.1 chokidar: 4.0.3 @@ -9133,16 +9148,16 @@ snapshots: optionalDependencies: typescript: 5.9.2 - vite-plugin-inspect@11.3.2(@nuxt/kit@3.18.1(magicast@0.3.5))(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): + vite-plugin-inspect@11.3.3(@nuxt/kit@3.18.1(magicast@0.3.5))(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): dependencies: ansis: 4.1.0 debug: 4.4.1 error-stack-parser-es: 1.0.5 ohash: 2.0.11 open: 10.2.0 - perfect-debounce: 1.0.0 + perfect-debounce: 2.0.0 sirv: 3.0.1 - unplugin-utils: 0.2.5 + unplugin-utils: 0.3.0 vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) vite-dev-rpc: 1.1.0(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) optionalDependencies: @@ -9150,7 +9165,7 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-vue-tracer@1.0.0(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.19(typescript@5.9.2)): + vite-plugin-vue-tracer@1.0.0(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))(vue@3.5.20(typescript@5.9.2)): dependencies: estree-walker: 3.0.3 exsolve: 1.0.7 @@ -9158,7 +9173,7 @@ snapshots: pathe: 2.0.3 source-map-js: 1.2.1 vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1): dependencies: @@ -9166,7 +9181,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.48.0 + rollup: 4.49.0 tinyglobby: 0.2.14 optionalDependencies: '@types/node': 24.3.0 @@ -9183,26 +9198,26 @@ snapshots: vue-devtools-stub@0.1.0: {} - vue-resize@2.0.0-alpha.1(vue@3.5.19(typescript@5.9.2)): + vue-resize@2.0.0-alpha.1(vue@3.5.20(typescript@5.9.2)): dependencies: - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) - vue-router@4.5.1(vue@3.5.19(typescript@5.9.2)): + vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) - vue-toastification@2.0.0-rc.5(vue@3.5.19(typescript@5.9.2)): + vue-toastification@2.0.0-rc.5(vue@3.5.20(typescript@5.9.2)): dependencies: - vue: 3.5.19(typescript@5.9.2) + vue: 3.5.20(typescript@5.9.2) - vue@3.5.19(typescript@5.9.2): + vue@3.5.20(typescript@5.9.2): dependencies: - '@vue/compiler-dom': 3.5.19 - '@vue/compiler-sfc': 3.5.19 - '@vue/runtime-dom': 3.5.19 - '@vue/server-renderer': 3.5.19(vue@3.5.19(typescript@5.9.2)) - '@vue/shared': 3.5.19 + '@vue/compiler-dom': 3.5.20 + '@vue/compiler-sfc': 3.5.20 + '@vue/runtime-dom': 3.5.20 + '@vue/server-renderer': 3.5.20(vue@3.5.20(typescript@5.9.2)) + '@vue/shared': 3.5.20 optionalDependencies: typescript: 5.9.2 diff --git a/uv.lock b/uv.lock index 99818d2e..b941fd7b 100644 --- a/uv.lock +++ b/uv.lock @@ -591,6 +591,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "httpx-curl-cffi" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "curl-cffi" }, + { name = "httpx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/e9/42542f3f4bf9ba457f10dda6620dc3f56e77480722c67579a35956c5d2e2/httpx_curl_cffi-0.1.4.tar.gz", hash = "sha256:1e00b741b121b3781942d45781288f89842f41fe32a743e27672d7fc542c1cd1", size = 7907 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/25/7a093edd86d0ea2cc0ff5570f74b00c603ce100303031a77f7d9db9aa80a/httpx_curl_cffi-0.1.4-py3-none-any.whl", hash = "sha256:bbd6e89abbf26a37c436d9a3d2c55ae646b78b0dcf4bc1be2f2451912e39d7b6", size = 8913 }, +] + [[package]] name = "humanfriendly" version = "10.0" @@ -744,11 +758,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.3.8" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 }, + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654 }, ] [[package]] @@ -1103,66 +1117,66 @@ wheels = [ [[package]] name = "regex" -version = "2025.7.34" +version = "2025.8.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/de/e13fa6dc61d78b30ba47481f99933a3b49a57779d625c392d8036770a60d/regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a", size = 400714 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/10/2d333227cf5198eb3252f2d50c8ade5cd2015f11c22403f0c9e3d529e81a/regex-2025.8.29.tar.gz", hash = "sha256:731ddb27a0900fa227dfba976b4efccec8c1c6fba147829bb52e71d49e91a5d7", size = 400817 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/85/f497b91577169472f7c1dc262a5ecc65e39e146fc3a52c571e5daaae4b7d/regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8", size = 484594 }, - { url = "https://files.pythonhosted.org/packages/1c/c5/ad2a5c11ce9e6257fcbfd6cd965d07502f6054aaa19d50a3d7fd991ec5d1/regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a", size = 289294 }, - { url = "https://files.pythonhosted.org/packages/8e/01/83ffd9641fcf5e018f9b51aa922c3e538ac9439424fda3df540b643ecf4f/regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68", size = 285933 }, - { url = "https://files.pythonhosted.org/packages/77/20/5edab2e5766f0259bc1da7381b07ce6eb4401b17b2254d02f492cd8a81a8/regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78", size = 792335 }, - { url = "https://files.pythonhosted.org/packages/30/bd/744d3ed8777dce8487b2606b94925e207e7c5931d5870f47f5b643a4580a/regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719", size = 858605 }, - { url = "https://files.pythonhosted.org/packages/99/3d/93754176289718d7578c31d151047e7b8acc7a8c20e7706716f23c49e45e/regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33", size = 905780 }, - { url = "https://files.pythonhosted.org/packages/ee/2e/c689f274a92deffa03999a430505ff2aeace408fd681a90eafa92fdd6930/regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083", size = 798868 }, - { url = "https://files.pythonhosted.org/packages/0d/9e/39673688805d139b33b4a24851a71b9978d61915c4d72b5ffda324d0668a/regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3", size = 781784 }, - { url = "https://files.pythonhosted.org/packages/18/bd/4c1cab12cfabe14beaa076523056b8ab0c882a8feaf0a6f48b0a75dab9ed/regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d", size = 852837 }, - { url = "https://files.pythonhosted.org/packages/cb/21/663d983cbb3bba537fc213a579abbd0f263fb28271c514123f3c547ab917/regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd", size = 844240 }, - { url = "https://files.pythonhosted.org/packages/8e/2d/9beeeb913bc5d32faa913cf8c47e968da936af61ec20af5d269d0f84a100/regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a", size = 787139 }, - { url = "https://files.pythonhosted.org/packages/eb/f5/9b9384415fdc533551be2ba805dd8c4621873e5df69c958f403bfd3b2b6e/regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1", size = 264019 }, - { url = "https://files.pythonhosted.org/packages/18/9d/e069ed94debcf4cc9626d652a48040b079ce34c7e4fb174f16874958d485/regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a", size = 276047 }, - { url = "https://files.pythonhosted.org/packages/fd/cf/3bafbe9d1fd1db77355e7fbbbf0d0cfb34501a8b8e334deca14f94c7b315/regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0", size = 268362 }, - { url = "https://files.pythonhosted.org/packages/ff/f0/31d62596c75a33f979317658e8d261574785c6cd8672c06741ce2e2e2070/regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50", size = 485492 }, - { url = "https://files.pythonhosted.org/packages/d8/16/b818d223f1c9758c3434be89aa1a01aae798e0e0df36c1f143d1963dd1ee/regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f", size = 290000 }, - { url = "https://files.pythonhosted.org/packages/cd/70/69506d53397b4bd6954061bae75677ad34deb7f6ca3ba199660d6f728ff5/regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130", size = 286072 }, - { url = "https://files.pythonhosted.org/packages/b0/73/536a216d5f66084fb577bb0543b5cb7de3272eb70a157f0c3a542f1c2551/regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46", size = 797341 }, - { url = "https://files.pythonhosted.org/packages/26/af/733f8168449e56e8f404bb807ea7189f59507cbea1b67a7bbcd92f8bf844/regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4", size = 862556 }, - { url = "https://files.pythonhosted.org/packages/19/dd/59c464d58c06c4f7d87de4ab1f590e430821345a40c5d345d449a636d15f/regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0", size = 910762 }, - { url = "https://files.pythonhosted.org/packages/37/a8/b05ccf33ceca0815a1e253693b2c86544932ebcc0049c16b0fbdf18b688b/regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b", size = 801892 }, - { url = "https://files.pythonhosted.org/packages/5f/9a/b993cb2e634cc22810afd1652dba0cae156c40d4864285ff486c73cd1996/regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01", size = 786551 }, - { url = "https://files.pythonhosted.org/packages/2d/79/7849d67910a0de4e26834b5bb816e028e35473f3d7ae563552ea04f58ca2/regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77", size = 856457 }, - { url = "https://files.pythonhosted.org/packages/91/c6/de516bc082524b27e45cb4f54e28bd800c01efb26d15646a65b87b13a91e/regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da", size = 848902 }, - { url = "https://files.pythonhosted.org/packages/7d/22/519ff8ba15f732db099b126f039586bd372da6cd4efb810d5d66a5daeda1/regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282", size = 788038 }, - { url = "https://files.pythonhosted.org/packages/3f/7d/aabb467d8f57d8149895d133c88eb809a1a6a0fe262c1d508eb9dfabb6f9/regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588", size = 264417 }, - { url = "https://files.pythonhosted.org/packages/3b/39/bd922b55a4fc5ad5c13753274e5b536f5b06ec8eb9747675668491c7ab7a/regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62", size = 275387 }, - { url = "https://files.pythonhosted.org/packages/f7/3c/c61d2fdcecb754a40475a3d1ef9a000911d3e3fc75c096acf44b0dfb786a/regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176", size = 268482 }, - { url = "https://files.pythonhosted.org/packages/15/16/b709b2119975035169a25aa8e4940ca177b1a2e25e14f8d996d09130368e/regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5", size = 485334 }, - { url = "https://files.pythonhosted.org/packages/94/a6/c09136046be0595f0331bc58a0e5f89c2d324cf734e0b0ec53cf4b12a636/regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd", size = 289942 }, - { url = "https://files.pythonhosted.org/packages/36/91/08fc0fd0f40bdfb0e0df4134ee37cfb16e66a1044ac56d36911fd01c69d2/regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b", size = 285991 }, - { url = "https://files.pythonhosted.org/packages/be/2f/99dc8f6f756606f0c214d14c7b6c17270b6bbe26d5c1f05cde9dbb1c551f/regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad", size = 797415 }, - { url = "https://files.pythonhosted.org/packages/62/cf/2fcdca1110495458ba4e95c52ce73b361cf1cafd8a53b5c31542cde9a15b/regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59", size = 862487 }, - { url = "https://files.pythonhosted.org/packages/90/38/899105dd27fed394e3fae45607c1983e138273ec167e47882fc401f112b9/regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415", size = 910717 }, - { url = "https://files.pythonhosted.org/packages/ee/f6/4716198dbd0bcc9c45625ac4c81a435d1c4d8ad662e8576dac06bab35b17/regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f", size = 801943 }, - { url = "https://files.pythonhosted.org/packages/40/5d/cff8896d27e4e3dd11dd72ac78797c7987eb50fe4debc2c0f2f1682eb06d/regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1", size = 786664 }, - { url = "https://files.pythonhosted.org/packages/10/29/758bf83cf7b4c34f07ac3423ea03cee3eb3176941641e4ccc05620f6c0b8/regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c", size = 856457 }, - { url = "https://files.pythonhosted.org/packages/d7/30/c19d212b619963c5b460bfed0ea69a092c6a43cba52a973d46c27b3e2975/regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a", size = 849008 }, - { url = "https://files.pythonhosted.org/packages/9e/b8/3c35da3b12c87e3cc00010ef6c3a4ae787cff0bc381aa3d251def219969a/regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0", size = 788101 }, - { url = "https://files.pythonhosted.org/packages/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401 }, - { url = "https://files.pythonhosted.org/packages/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368 }, - { url = "https://files.pythonhosted.org/packages/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482 }, - { url = "https://files.pythonhosted.org/packages/ac/23/6376f3a23cf2f3c00514b1cdd8c990afb4dfbac3cb4a68b633c6b7e2e307/regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a", size = 485385 }, - { url = "https://files.pythonhosted.org/packages/73/5b/6d4d3a0b4d312adbfd6d5694c8dddcf1396708976dd87e4d00af439d962b/regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435", size = 289788 }, - { url = "https://files.pythonhosted.org/packages/92/71/5862ac9913746e5054d01cb9fb8125b3d0802c0706ef547cae1e7f4428fa/regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac", size = 286136 }, - { url = "https://files.pythonhosted.org/packages/27/df/5b505dc447eb71278eba10d5ec940769ca89c1af70f0468bfbcb98035dc2/regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72", size = 797753 }, - { url = "https://files.pythonhosted.org/packages/86/38/3e3dc953d13998fa047e9a2414b556201dbd7147034fbac129392363253b/regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e", size = 863263 }, - { url = "https://files.pythonhosted.org/packages/68/e5/3ff66b29dde12f5b874dda2d9dec7245c2051f2528d8c2a797901497f140/regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751", size = 910103 }, - { url = "https://files.pythonhosted.org/packages/9e/fe/14176f2182125977fba3711adea73f472a11f3f9288c1317c59cd16ad5e6/regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4", size = 801709 }, - { url = "https://files.pythonhosted.org/packages/5a/0d/80d4e66ed24f1ba876a9e8e31b709f9fd22d5c266bf5f3ab3c1afe683d7d/regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98", size = 786726 }, - { url = "https://files.pythonhosted.org/packages/12/75/c3ebb30e04a56c046f5c85179dc173818551037daae2c0c940c7b19152cb/regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7", size = 857306 }, - { url = "https://files.pythonhosted.org/packages/b1/b2/a4dc5d8b14f90924f27f0ac4c4c4f5e195b723be98adecc884f6716614b6/regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47", size = 848494 }, - { url = "https://files.pythonhosted.org/packages/0d/21/9ac6e07a4c5e8646a90b56b61f7e9dac11ae0747c857f91d3d2bc7c241d9/regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e", size = 787850 }, - { url = "https://files.pythonhosted.org/packages/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730 }, - { url = "https://files.pythonhosted.org/packages/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640 }, - { url = "https://files.pythonhosted.org/packages/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757 }, + { url = "https://files.pythonhosted.org/packages/ef/a2/e9b9ce5407af9147dc39a7de4f161fd72804c095ea398ab472e8dbc65533/regex-2025.8.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:156f711019968ffb3512723a38b06d94d379675c296bdb6104d1abb6e57374c6", size = 484663 }, + { url = "https://files.pythonhosted.org/packages/f1/7c/5b2cf5f1350c1c218542fb0be89cf28d8375ebe240cb5769f108325eb285/regex-2025.8.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9082c0db8d43c696fac70b5b0592934f21533940f0118239b5c32fa23e51ed1a", size = 289365 }, + { url = "https://files.pythonhosted.org/packages/1c/27/44733d2aa3b0c9532580872e9ed2df6a86fe7b975b75dc1f1733f6751e55/regex-2025.8.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3535b9a69a818735ebac392876dae4b215fe28c13b145353a2dac468ebae16", size = 286007 }, + { url = "https://files.pythonhosted.org/packages/b9/ac/2d4f6904422b95f22d1548d8655b288837f3218b54853c6050de61a87b7e/regex-2025.8.29-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c460628f6098cf8916b2d62fb39a37a39e49cca0279ac301ff9d94f7e75033e", size = 792412 }, + { url = "https://files.pythonhosted.org/packages/a1/61/8f67415c0ad59abf8f4dd24ad9de504eb37c363318f757be35c42b537d66/regex-2025.8.29-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dad3ce46390fe3d81ae1c131e29179f010925fa164e15b918fb037effdb7ad9", size = 858682 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c3552278e507ab255c51dce4dda0072252e78c801a16697085e71595b1c7/regex-2025.8.29-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f89e5beb3012d3c36c526fd4af163ada24011a0b417378f726b17c2fb382a35d", size = 905855 }, + { url = "https://files.pythonhosted.org/packages/ab/84/5150fdffe83df17a7b869930c06d8007b890be3fdf6eb509b849431cabeb/regex-2025.8.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40eeff06bbcfa69201b60488f3f3aa38ad3c92c7c0ab2cfc7c9599abfdf24262", size = 798943 }, + { url = "https://files.pythonhosted.org/packages/89/bc/695f94a6fada1838adc75312512843f8d9d94eda71c253958fb40bba5083/regex-2025.8.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d7a9bc68610d22735b6ac01a3c3ef5b03d9303a18bd3e2249340213389f273dc", size = 781859 }, + { url = "https://files.pythonhosted.org/packages/11/8e/641b228837f551c129bc03005a158c48aebb353a1f6a34dfcea025b5e4bc/regex-2025.8.29-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e785e40f7edfc19ff0b81b27f25eefdb0251cfd2ac4a9fa1eea03f5129e93758", size = 852914 }, + { url = "https://files.pythonhosted.org/packages/0c/49/b8d55dffd138369ee8378830b3bad4f7b815517df5ad16212031521f966f/regex-2025.8.29-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba1deae2ceaa0b181ac9fd4cb8f04d6ba1494f3c8d053c8999f7c0dadb93497b", size = 844314 }, + { url = "https://files.pythonhosted.org/packages/f7/73/48b6b616fdc1b6dc75a00c2670da7038400796c855b7bd0fbd4dad18c26c/regex-2025.8.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15869e4f36de7091342e1dae90216aafa3746e3a069f30b34503a36931036f95", size = 787215 }, + { url = "https://files.pythonhosted.org/packages/65/af/38af20de8ea862c5275da67d5a0e63023a92cc5df344ad9a80fc1fcd448e/regex-2025.8.29-cp311-cp311-win32.whl", hash = "sha256:aef62e0b08b0e3c2616783a9f75a02f001254695a0a1d28b829dc9fb6a3603e4", size = 264088 }, + { url = "https://files.pythonhosted.org/packages/84/d9/f765e5d9eaaa67e10267662002aea786334176c2b22066437df6d73a6424/regex-2025.8.29-cp311-cp311-win_amd64.whl", hash = "sha256:fd347592a4811ba1d246f99fb53db82a1898a5aebb511281ac0c2d81632e1789", size = 276119 }, + { url = "https://files.pythonhosted.org/packages/87/cd/44da9fae9a0c1af09f7171facc8d6313b1cbdfeea9f3526607495a28bdd7/regex-2025.8.29-cp311-cp311-win_arm64.whl", hash = "sha256:d93801012bb23901df403ae0adf528abfd50041c9e1136a303937d45c14466e0", size = 268429 }, + { url = "https://files.pythonhosted.org/packages/e3/a0/8c37d276a80ffda94f7e019e50cc88f898015512c7f104e49f1a0a6d3c59/regex-2025.8.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd61f18dc4446bc3a2904559a61f32e98091cef7fb796e06fa35b9bfefe4c0c5", size = 485565 }, + { url = "https://files.pythonhosted.org/packages/5d/34/baf5963bec36ac250fa242f0f0e7670f013de5004db6caa31c872981df42/regex-2025.8.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f21b416be10a8348a7313ba8c610569a1ab4bf8ec70731750540842a4551cd3d", size = 290073 }, + { url = "https://files.pythonhosted.org/packages/24/29/c5c18143cd60b736d7ff8acece126118fe5649f45a7a8db18e308f5f813d/regex-2025.8.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:008947a7fa92f4cb3b28201c9aa7becc0a44c31a7c2fcb934356e1877baccc09", size = 286144 }, + { url = "https://files.pythonhosted.org/packages/86/7c/0d90b687d2a33fe28b201f85ddfde6b378bf41677aedbe23eb7dc79385aa/regex-2025.8.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78ab1b3e68b890d7ebd69218cfbfe4a09dc00b8a47be8648510b81b932d55ff", size = 797417 }, + { url = "https://files.pythonhosted.org/packages/fb/67/c391c899e5ef274c4dd4ede029ffb853ddf5ba77aa251be02cfe3810574c/regex-2025.8.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a848368797515bc141d3fad5fd2d81bf9e8a6a22d9ac1a4be4690dd22e997854", size = 862630 }, + { url = "https://files.pythonhosted.org/packages/08/20/ae749a68da3496a133836c8724649bd2e004fc176c7c6647d9cb269cc975/regex-2025.8.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8eaf3ea6631f804efcf0f5bd0e4ab62ba984fd9b70e3aef44b05cc6b951cc728", size = 910837 }, + { url = "https://files.pythonhosted.org/packages/e2/80/bc4244ec79fba4185fd3a29d79f77f79b3b0dc12ee426687501b0b077e2a/regex-2025.8.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4561aeb36b0bf3bb44826e4b61a80c6ace0d8839bf4914d78f061f9ba61444b4", size = 801968 }, + { url = "https://files.pythonhosted.org/packages/ef/bd/a2d75042bb1d3c9997e22bc0051cb9791a405589d6293c874f7c2ba487e7/regex-2025.8.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:93e077d1fbd24033fa427eab43d80ad47e449d25700cda78e8cac821a30090bf", size = 786626 }, + { url = "https://files.pythonhosted.org/packages/24/ab/19cec75bf7d335cc7595d4857591455de118f6bfb563e6731c31f4fe33c3/regex-2025.8.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d92379e53d782bdb773988687300e3bccb91ad38157b754b04b1857aaeea16a3", size = 856532 }, + { url = "https://files.pythonhosted.org/packages/b6/3d/517cd0b0f4b8330164d03ef0eafdd61ee839f82b891fcd8c571d5c727117/regex-2025.8.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d41726de2040c2a487bbac70fdd6e3ff2f1aa47dc91f0a29f6955a6dfa0f06b6", size = 848977 }, + { url = "https://files.pythonhosted.org/packages/ae/fc/b57e2644d87d038d7302f359f4042bf7092bd8259a3ae999adf236e6fbc0/regex-2025.8.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1915dfda52bd4d466f3a66b66988db1f647ee1d9c605858640ceeb779cffd908", size = 788112 }, + { url = "https://files.pythonhosted.org/packages/a9/2f/70737feddbd33ec9f3f0cb8b38e7fc89304eccc80fd693d79a6f336e2282/regex-2025.8.29-cp312-cp312-win32.whl", hash = "sha256:e2ef0087ad6949918836f215480a9331f6c59ad54912a9a412f08ab1c9ccbc98", size = 264487 }, + { url = "https://files.pythonhosted.org/packages/2f/f5/8832d05ecc5a7f80043e7521ea55adfa2d9b9ac0e646474153e7e13722c2/regex-2025.8.29-cp312-cp312-win_amd64.whl", hash = "sha256:c15d361fe9800bf38ef69c2e0c4b8b961ae4ce2f076fcf4f28e1fc9ea127f55a", size = 275455 }, + { url = "https://files.pythonhosted.org/packages/a5/f9/f10ae0c4e5e22db75dda155d83056e2b70c4e87b04ad9838723ff5057e90/regex-2025.8.29-cp312-cp312-win_arm64.whl", hash = "sha256:305577fab545e64fb84d9a24269aa3132dbe05e1d7fa74b3614e93ec598fe6e6", size = 268558 }, + { url = "https://files.pythonhosted.org/packages/42/db/2f0e1fbca855f3c519f3f8198817d14a9569ca939bc0cc86efd4da196d3e/regex-2025.8.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:eed02e5c39f91268ea4ddf68ee19eed189d57c605530b7d32960f54325c52e7a", size = 485405 }, + { url = "https://files.pythonhosted.org/packages/15/ed/52afe839607719750acc87d144ec3db699adb9c1f40ecb6fa9f3700437b6/regex-2025.8.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:630d5c7e0a490db2fee3c7b282c8db973abcbb036a6e4e6dc06c4270965852be", size = 290014 }, + { url = "https://files.pythonhosted.org/packages/da/84/beb3becb129e41ae3e6bacd737aa751228ec0c17c707b9999648f050968c/regex-2025.8.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2206d3a30469e8fc8848139884168127f456efbaca8ae14809c26b98d2be15c6", size = 286059 }, + { url = "https://files.pythonhosted.org/packages/44/31/74476ac68cd5ed46634683cba634ab0885e917624d620c5959f67835554b/regex-2025.8.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394c492c398a9f9e17545e19f770c58b97e65963eedaa25bb879e80a03e2b327", size = 797490 }, + { url = "https://files.pythonhosted.org/packages/3f/97/1a8d109f891c4af31f43295304a51b76bc7aef4ce6d7953e4832f86c85f0/regex-2025.8.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db8b0e05af08ff38d78544950e844b5f159032b66dedda19b3f9b17297248be7", size = 862562 }, + { url = "https://files.pythonhosted.org/packages/1b/a8/13d6ea4b8a0c7eed0e528dcb25cbdc3bc53e26b0928dc48d6c0381516c4a/regex-2025.8.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd7c1821eff911917c476d41030b422791ce282c23ee9e1b8f7681fd0993f1e4", size = 910790 }, + { url = "https://files.pythonhosted.org/packages/10/b3/1c7320c1fdc6569a086949d2c5b7b742696098c28a6c83ca909b8d36d17b/regex-2025.8.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b4d8a7f75da748a2d0c045600259f1899c9dd8dd9d3da1daa50bf534c3fa5ba", size = 802016 }, + { url = "https://files.pythonhosted.org/packages/7a/b5/f3613b70a569b6309cd2a61ae869407b45cff25c9734f5ff179b416e9615/regex-2025.8.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5cd74545c32e0da0d489c2293101a82f4a1b88050c235e45509e4123017673b2", size = 786740 }, + { url = "https://files.pythonhosted.org/packages/e0/8a/9f16babae23011acbd27f886c4817159508f4f3209bcfce4bc2b8f12f2ba/regex-2025.8.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:97b98ea38fc3c1034f3d7bd30288d2c5b3be8cdcd69e2061d1c86cb14644a27b", size = 856533 }, + { url = "https://files.pythonhosted.org/packages/4d/d0/adca6eec8ed79541edadecf8b512d7a3960c2ba983d2e5baf68dbddd7a90/regex-2025.8.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8decb26f271b989d612c5d99db5f8f741dcd63ece51c59029840070f5f9778bf", size = 849083 }, + { url = "https://files.pythonhosted.org/packages/46/cc/37fddb2a17cefffb43b9dfd5f585a6cd6f90ee5b32c821886d0c0c3bc243/regex-2025.8.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62141843d1ec079cd66604424af566e542e7e072b2d9e37165d414d2e6e271dd", size = 788177 }, + { url = "https://files.pythonhosted.org/packages/f5/ea/413fe88ce5ac2418223434aa1603d92134b74deed6007dc6e4c37d83bbcd/regex-2025.8.29-cp313-cp313-win32.whl", hash = "sha256:dd23006c90d9ff0c2e4e5f3eaf8233dcefe45684f2acb330869ec5c2aa02b1fb", size = 264473 }, + { url = "https://files.pythonhosted.org/packages/5a/73/d07bc1d1969e41bf1637a8aad4228da506747f4c94415ef03c534c7d68d6/regex-2025.8.29-cp313-cp313-win_amd64.whl", hash = "sha256:d41a71342819bdfe87c701f073a14ea4bd3f847333d696c7344e9ff3412b7f70", size = 275438 }, + { url = "https://files.pythonhosted.org/packages/86/cd/2e05fc85ebee6fe6c5073c9b0c737a473c226422d75e93903810b247a9fe/regex-2025.8.29-cp313-cp313-win_arm64.whl", hash = "sha256:54018e66344d60b214f4aa151c046e0fa528221656f4f7eba5a787ccc7057312", size = 268553 }, + { url = "https://files.pythonhosted.org/packages/2e/2d/2aa4b98231017994ea52d05c13997778af415f5d7faa7f90988a640dac44/regex-2025.8.29-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c03308757831a8d89e7c007abb75d1d4c9fbca003b5fb32755d4475914535f08", size = 485447 }, + { url = "https://files.pythonhosted.org/packages/b7/b4/ed3241bb99a0783fe650d8511924c7c43f704b720fab3e353393bea8c96a/regex-2025.8.29-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0d4b71791975fc203e0e6c50db974abb23a8df30729c1ac4fd68c9f2bb8c9358", size = 289862 }, + { url = "https://files.pythonhosted.org/packages/ba/f6/5237a7d0b2bd64bb216d06470549bc4cc33de57033772e3018708636a027/regex-2025.8.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:284fcd2dcb613e8b89b22a30cf42998c9a73ee360b8a24db8457d24f5c42282e", size = 286211 }, + { url = "https://files.pythonhosted.org/packages/58/eb/05568fdc4028d1b339fb950fe6b92ade2613edd6423291939c8e29b21e8a/regex-2025.8.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b394b5157701b22cf63699c792bfeed65fbfeacbd94fea717a9e2036a51148ab", size = 797826 }, + { url = "https://files.pythonhosted.org/packages/3d/2a/a3c1c209faa1f6a218e64c5a235e06f6f36c45b5aa924c6bf75241a996f7/regex-2025.8.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea197ac22396faf5e70c87836bb89f94ed5b500e1b407646a4e5f393239611f1", size = 863338 }, + { url = "https://files.pythonhosted.org/packages/dd/66/5e96f217662387742c0d9732e97129850bd3243e019309c1fbdcd62b5421/regex-2025.8.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:decd84f195c08b3d9d0297a7e310379aae13ca7e166473534508c81b95c74bba", size = 910176 }, + { url = "https://files.pythonhosted.org/packages/fc/f2/975e77333267f9652bc2cc926382d8c9d86683eb84d1989459e644ac818b/regex-2025.8.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebaf81f7344dbf1a2b383e35923648de8f78fb262cf04154c82853887ac3e684", size = 801784 }, + { url = "https://files.pythonhosted.org/packages/75/d9/b25dbf9729b5a5958a804e91b376fe8e829ec10c0d7edb4b1ad91070132b/regex-2025.8.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d82fb8a97e5ed8f1d3ed7f8e0e7fe1760faa95846c0d38b314284dfdbe86b229", size = 786799 }, + { url = "https://files.pythonhosted.org/packages/1d/0a/7f8de7ea41d7a3a21dfcb9dcea7b727fdde9e35d74a23e16ef5edcd68005/regex-2025.8.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1dcec2448ed0062f63e82ca02d1d05f74d4127cb6a9d76a73df60e81298d380b", size = 857380 }, + { url = "https://files.pythonhosted.org/packages/f8/40/494600424c394a507070b41fc0666ceaa7dccf62c3220a76833eb11de647/regex-2025.8.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d0ffe4a3257a235f9d39b99c6f1bc53c7a4b11f28565726b1aa00a5787950d60", size = 848570 }, + { url = "https://files.pythonhosted.org/packages/be/d0/6988feb7c15bb3df7b944a10b3b58fb238c94987c70a991ba87e3685e1cd/regex-2025.8.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5421a2d2026e8189500f12375cfd80a9a1914466d446edd28b37eb33c1953b39", size = 787926 }, + { url = "https://files.pythonhosted.org/packages/98/16/d719b131b0577a2a975376b3e673fc7f89b9998d54753f0419d59d33b3a1/regex-2025.8.29-cp314-cp314-win32.whl", hash = "sha256:ceeeaab602978c8eac3b25b8707f21a69c0bcd179d9af72519da93ef3966158f", size = 269805 }, + { url = "https://files.pythonhosted.org/packages/a5/b7/50d3bb5df25ae73e7aee186a2f1e4f1ed5e4d54006bdf5abd558c1ce9e7a/regex-2025.8.29-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f8b0d5b88c33fe4060e6def58001fd8334b03c7ce2126964fa8851ab5d1b", size = 278710 }, + { url = "https://files.pythonhosted.org/packages/0f/34/c723ebe214c33000b53e0eebdc63ad3697d5611c7fa9b388eef2113a5e82/regex-2025.8.29-cp314-cp314-win_arm64.whl", hash = "sha256:7b4a3dc155984f09a55c64b90923cb136cd0dad21ca0168aba2382d90ea4c546", size = 271832 }, ] [[package]] @@ -1234,11 +1248,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] [[package]] @@ -1367,11 +1381,11 @@ wheels = [ [[package]] name = "yt-dlp" -version = "2025.8.22" +version = "2025.8.27" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/98/b077bebdc5c759a3f7af3ed3a2a5345ad1145c61963b476469b840ac84ce/yt_dlp-2025.8.22.tar.gz", hash = "sha256:d1846bbb7edbcd2a0d4a2d76c7a2124868de9ea3b3959a8cb8219e3f7cb5c335", size = 3037631 } +sdist = { url = "https://files.pythonhosted.org/packages/f4/d4/d9dd231b03f09fdfb5f0fe70f30de0b5f59454aa54fa6b2b2aea49404988/yt_dlp-2025.8.27.tar.gz", hash = "sha256:ed74768d2a93b29933ab14099da19497ef571637f7aa375140dd3d882b9c1854", size = 3038374 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/06/e9e5e85969bd85142b004577915f33356ee0d5e20b79af8dd40b8bbfc96f/yt_dlp-2025.8.22-py3-none-any.whl", hash = "sha256:b8c71fe4516170dea60c6e5c54e2d45654693b8dc273cad060f22199476ec979", size = 3266783 }, + { url = "https://files.pythonhosted.org/packages/cb/9c/b69fc0c800f80b94ea2f8eff1d1f473fecee6aa337681d297ba7c7c5d3fd/yt_dlp-2025.8.27-py3-none-any.whl", hash = "sha256:0b8fd3bb7c54bc2e7ecb5cdac7d64c30e2503ea4d3dd9ae24d4f09e22aaa95f4", size = 3267059 }, ] [[package]] @@ -1394,6 +1408,7 @@ dependencies = [ { name = "debugpy" }, { name = "defusedxml" }, { name = "httpx" }, + { name = "httpx-curl-cffi" }, { name = "multidict" }, { name = "mutagen" }, { name = "platformdirs" }, @@ -1432,6 +1447,7 @@ requires-dist = [ { name = "debugpy", specifier = ">=1.8.1" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "httpx" }, + { name = "httpx-curl-cffi", specifier = ">=0.1.4" }, { name = "multidict", specifier = "==6.5.1" }, { name = "mutagen" }, { name = "platformdirs" }, @@ -1452,9 +1468,9 @@ provides-extras = ["installer"] [[package]] name = "zipstream-ng" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/16/5d9224baf640214255c34a0a0e9528c8403d2b89e2ba7df9d7cada58beb1/zipstream_ng-1.8.0.tar.gz", hash = "sha256:b7129d2c15d26934b3e1cb22256593b6bdbd03c553c26f4199a5bf05110642bc", size = 35887 } +sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/81/11ecdfd5370d6c383f0188a6f2fa2842499e1be617e678d1845f972c6821/zipstream_ng-1.8.0-py3-none-any.whl", hash = "sha256:e7196cb845cf924ed12e7a3b38404ef9e82a5a699801295f5f4cf601449e2bf6", size = 23082 }, + { url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852 }, ]