Merge pull request #423 from arabcoders/dev

Use local downloaded thumbnail if available
This commit is contained in:
Abdulmohsen 2025-09-22 00:38:51 +03:00 committed by GitHub
commit dd3059d01e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 5623 additions and 1048 deletions

12
.vscode/settings.json vendored
View file

@ -34,12 +34,14 @@
"Cfmrc",
"choco",
"cifs",
"connectionpool",
"consoletitle",
"continuedl",
"cookiesfrombrowser",
"copyts",
"creationflags",
"cronsim",
"currsize",
"dailymotion",
"datas",
"dateparser",
@ -189,5 +191,13 @@
"app/tests"
],
"python.testing.unittestEnabled": true,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,
"json.schemas": [
{
"fileMatch": [
"**/var/config/tasks/*.json"
],
"url": "./app/library/task_handlers/task_definition.schema.json"
}
]
}

57
API.md
View file

@ -32,6 +32,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [DELETE /api/archiver](#delete-apiarchiver)
- [GET /api/tasks](#get-apitasks)
- [PUT /api/tasks](#put-apitasks)
- [POST /api/tasks/inspect](#post-apitasksinspect)
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
@ -551,6 +552,62 @@ or on error
---
### POST /api/tasks/inspect
**Purpose**: Preview which handler matches a URL and review the items it would queue without dispatching downloads.
**Body**:
```json
{
"url": "https://example.com/feed", // required, validated URL
"preset": "news-preset", // optional preset override - In real scenarios, the preset come from the task.
"handler": "GenericTaskHandler" // optional explicit handler class name, in real scenarios, the handler is resolved automatically.
}
```
**Response (success)**:
```json
{
"matched": true,
"handler": "GenericTaskHandler",
"supported": true,
"items": [
{
"url": "https://example.com/video/1",
"title": "Example title",
"archive_id": "generic 1",
"metadata": {
"source_task": "...",
"source_handler": "GenericTaskHandler"
}
}
],
"metadata": {
"raw_count": 3
}
}
```
- Returns `200 OK` when inspection succeeds.
- The `metadata` object is optional and may contain handler-specific keys.
**Response (failure)**:
```json
{
"matched": false,
"handler": null,
"error": "No handler matched the supplied URL.",
"message": "No handler matched the supplied URL.",
"metadata": {
"supported": true
}
}
```
- Returns `400 Bad Request` when the handler cannot process the URL or inspection fails.
- When a handler is specified but does not support manual inspection, `supported` is `false` and the `message` explains the limitation.
---
### POST /api/tasks/{id}/mark
**Purpose**: Mark all entries associated with a scheduled task as downloaded.

128
FAQ.md
View file

@ -85,10 +85,6 @@ the `yt-dlp` output and attempt to download the media directly to your iOS devic
starting point for those who want to download media directly to their iOS device. We provide no support for this use case
other than the shortcut itself. this shortcut missing support for parsing the http_headers, it's only parse the cookies.
# Authentication
To enable basic authentication, set the `YTP_AUTH_USERNAME` and `YTP_AUTH_PASSWORD` environment variables. And restart
@ -97,11 +93,6 @@ As this is a simple basic authentication, if your browser doesn't show the promp
`http://username:password@your_ytptube_url:port`
# Basic mode
What does the basic mode do? it basically strips down the interface to the bare minimum,
to make it easier for non-technical users to use it.
# I cant download anything
If you are receiving errors like:
@ -154,6 +145,125 @@ YTP_YTDLP_VERSION=2025.07.21 or master or nightly
Then restart the container to apply the changes.
# How can I monitor sites without RSS feeds?
YTPTube includes a **generic task handler** that turns JSON definition files into site-specific scrapers. You can use it
to watch pages that do not expose RSS or public APIs and automatically enqueue new links into the download queue.
1. Create definition files under `/config/tasks/*.json` (for Docker this is the mounted `config/tasks/` folder).
2. Keep your scheduled task in `tasks.json` pointing at the page you want to monitor and make sure it uses a preset that
enables a download archive (`--download-archive`).
3. When the task runs, the handler scans the JSON files, picks the first definition whose `match` rule covers the task
URL, fetches the page, extracts items, and queues the unseen ones.
### Definition schema
Each file must contain a single JSON object with the following keys:
```json5
{
"name": "example", // Friendly identifier shown in logs
"match": [
"https://example.com/articles/*", // Glob strings, or objects with {"regex": "..."} or {"glob": "..."}
{ "regex": "https://example.com/post/[0-9]+" }
],
"engine": { // Optional, defaults to HTTPX
"type": "httpx", // "httpx" (default) or "selenium"
"options": {
"url": "http://selenium:4444/wd/hub", // Selenium-only: remote hub URL
"arguments": ["--headless", "--disable-gpu"],
"wait_for": { "type": "css", "expression": ".article" },
"wait_timeout": 15,
"page_load_timeout": 60
}
},
"request": { // Optional HTTP settings
"method": "GET", // GET or POST
"url": "https://example.com/articles/latest", // Override the task URL if needed
"headers": { "User-Agent": "MyAgent/1.0" },
"params": { "page": 1 },
"data": null,
"json": null,
"timeout": 30
},
"response": { // Optional: how to interpret the body
"type": "html" // "html" (default) or "json"
},
"parse": {
"items": { // Optional container for per-item extraction
"selector": ".columns .card", // Defaults to CSS; set "type": "xpath" to use XPath
"fields": {
"link": { // Required inside fields: the per-item URL
"type": "css",
"expression": ".card-header a",
"attribute": "href"
},
"title": {
"type": "css",
"expression": ".card-header a",
"attribute": "text"
},
"poet": {
"type": "css",
"expression": "footer .card-footer-item:first-child a",
"attribute": "text"
}
}
},
"page_title": { // Optional global field outside the container
"type": "css",
"expression": "title",
"attribute": "text"
}
}
}
```
For JSON endpoints, switch the response format and use `jsonpath` selectors:
```json5
{
"response": { "type": "json" },
"parse": {
"items": {
"type": "jsonpath",
"selector": "items",
"fields": {
"link": { "type": "jsonpath", "expression": "url" },
"title": { "type": "jsonpath", "expression": "title" }
}
}
}
}
```
### Parsing rules
- Every definition must provide a `link` field either at the top level or inside `parse.items.fields`. Other fields are optional metadata attached to the queued item.
- CSS and XPath rules may specify `attribute`:
- `text` / `inner_text` applies `normalize-space()`.
- `html` / `outer_html` returns the raw HTML fragment.
- Any other value reads that attribute from the element. When omitted, the handler uses text and, for `link`, falls
back to `href` automatically.
- Regex rules scan the HTML fragment associated with the current scope (page-level or container). Set `attribute` to a named/numbered capture group or rely on the first group.
- `post_filter` lets you run a final regex on the extracted value and pick a named (`value`) group.
- When you declare `parse.items`, each matching container is processed independently so missing fields in one card do not shift values for the rest.
- For JSON responses (`response.type = "json"`), set the container `type` and field `type` to `jsonpath` and supply [JMESPath](https://jmespath.org/) expressions. Relative values are resolved against each container object and converted to strings automatically.
### Fetch engines
- **httpx (default)**: supports custom headers, query params, JSON/body payloads, proxy inherited from the task preset,
and optional timeout.
- **selenium**: uses a remote Chrome session. Provide the hub URL under `engine.options.url`; only Chrome is supported at
the moment. Optional keys: `arguments` (list or string), `wait_for` (type `css`/`xpath` + `expression`), `wait_timeout`,
and `page_load_timeout`.
Definitions are reloaded automatically when files change, so you can tweak them without restarting YTPTube. Check
`var/config/tasks/01-*.json` for sample files.
> [!NOTE]
> A machine-readable schema is available at `app/library/task_handlers/task_definition.schema.json` if you want to validate your JSON with editors or CI tools.
# How to generate POT tokens?
You need a pot provider server we already have the extractor `bgutil-ytdlp-pot-provider` pre-installed in the container.

View file

@ -5,8 +5,8 @@
![Docker pull](https://ghcr-badge.elias.eu.org/shield/arabcoders/ytptube/ytptube)
**YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from
YouTube and other video platforms easier and more user-friendly. It supports downloading playlists, channels, and
live streams, and includes features like scheduling downloads, sending notifications, and a built-in video player.
video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and
includes features like scheduling downloads, sending notifications, and built-in video player.
![Short screenshot](https://raw.githubusercontent.com/ArabCoders/ytptube/master/sc_short.png)
@ -16,6 +16,7 @@ live streams, and includes features like scheduling downloads, sending notificat
* Random beautiful background.
* Handles live and upcoming streams.
* Schedule channels or playlists to be downloaded automatically.
* Create your own custom task handler feeds for downloads, See [Feeds documentation](FAQ.md#how-can-i-monitor-sites-without-rss-feeds).
* Send notification to targets based on selected events. includes [Apprise](https://github.com/caronc/apprise?tab=readme-ov-file#readme) support.
* Support per link options.
* Support for limits per extractor and overall global limit.
@ -38,7 +39,7 @@ Please read the [FAQ](FAQ.md) for more information.
## Run using docker command
```bash
mkdir -p ./{config,downloads} && docker run -d --rm --user "$UID:${GID-$UID}" --name ytptube \
mkdir -p ./{config,downloads} && docker run -d --rm --user "${UID}:${UID}" --name ytptube \
-p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
ghcr.io/arabcoders/ytptube:latest
```
@ -93,7 +94,7 @@ For simple API documentation, you can refer to the [API documentation](API.md).
# Disclaimer
This project is not affiliated with YouTube, yt-dlp, or any other service. It's a personal project that was created to
This project is not affiliated with yt-dlp, or any other service. It's a personal project that was created to
make downloading videos from the internet easier. It's not intended to be used for piracy or any other illegal activities.
# Social contact

View file

@ -465,6 +465,7 @@ class DownloadQueue(metaclass=Singleton):
dl = ItemDTO(
id=str(entry.get("id")),
title=str(entry.get("title")),
description=str(entry.get("description", "")),
url=str(entry.get("webpage_url") or entry.get("url")),
preset=item.preset,
folder=item.folder,
@ -982,6 +983,7 @@ class DownloadQueue(metaclass=Singleton):
items["queue"][k] = self._active[k].info if k in self._active else v
for k, v in self.done.saved_items():
v.get_file_sidecar()
items["done"][k] = v
for k, v in self.queue.items():
@ -989,8 +991,11 @@ class DownloadQueue(metaclass=Singleton):
items["queue"][k] = self._active[k].info if k in self._active else v
for k, v in self.done.items():
if k not in items["done"]:
items["done"][k] = v.info
if k in items["done"]:
continue
v.info.get_file_sidecar()
items["done"][k] = v.info
return items
@ -1251,6 +1256,9 @@ class DownloadQueue(metaclass=Singleton):
if task.cancelled():
return
if exc := task.exception():
task_name: str = task.get_name() if task.get_name() else "unknown_task"
LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {traceback.format_exc()}")
if not (exc := task.exception()):
return
task_name: str = task.get_name() if task.get_name() else "unknown_task"
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {tb}")

View file

@ -1,13 +1,22 @@
import json
import logging
import re
import time
import uuid
from dataclasses import dataclass, field
from email.utils import formatdate
from pathlib import Path
from typing import Any
from app.library.Utils import archive_add, archive_delete, archive_read, clean_item, get_archive_id
from app.library.encoder import Encoder
from app.library.Utils import (
archive_add,
archive_delete,
archive_read,
clean_item,
get_archive_id,
get_file,
get_file_sidecar,
)
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger("ItemDTO")
@ -47,12 +56,37 @@ class Item:
"""If the item should be started automatically."""
def serialize(self) -> dict:
"""
Serialize the item to a dictionary.
Returns:
dict: The serialized item.
"""
return self.__dict__.copy()
def json(self) -> str:
return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)
"""
Convert the item to a JSON string.
Returns:
str: The JSON string representation of the item.
"""
return Encoder(sort_keys=True, indent=4).encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
"""
Get a value from the item by key.
Args:
key (str): The key to get the value for.
default (Any): The default value to return if the key is not found.
Returns:
Any: The value for the key, or the default value if the key is not found
"""
return self.__dict__.get(key, default)
def has_extras(self) -> bool:
@ -77,6 +111,13 @@ class Item:
@staticmethod
def _default_preset() -> str:
"""
Get the default preset from the configuration.
Returns:
str: The default preset name.
"""
from .config import Config
return Config.get_instance().default_preset
@ -167,12 +208,33 @@ class Item:
return Item(**data)
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.
"""
return get_archive_id(self.url).get("archive_id") if self.url else None
def get_extractor(self) -> str | None:
"""
Get the extractor key for the download URL.
Returns:
str | None: The extractor key if available, None otherwise.
"""
return get_archive_id(self.url).get("ie_key") if self.url else None
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:
@ -184,14 +246,35 @@ class Item:
return params
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.
"""
return self.get_ytdlp_opts().get_all().get("download_archive")
def is_archived(self) -> bool:
"""
Check if the item has been archived.
Returns:
bool: True if the item has been archived, False otherwise.
"""
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:
"""
Get a short string representation of the item.
Returns:
str: A short string representation of the item.
"""
from .config import Config
from .Utils import calc_download_path, strip_newline
@ -230,6 +313,8 @@ class ItemDTO:
""" The ID of the item yt-dlp """
title: str
""" The title of the item. """
description: str = ""
""" The description of the item. """
url: str
""" The URL of the item. """
preset: str = "default"
@ -272,6 +357,8 @@ class ItemDTO:
""" If the item has been archived. """
archive_id: str | None = None
""" The archive ID of the item. """
sidecar: dict = field(default_factory=dict)
""" Sidecar data associated with the item. """
# yt-dlp injected fields.
tmpfilename: str | None = None
@ -288,24 +375,101 @@ class ItemDTO:
_archive_file: str | None = None
def serialize(self) -> dict:
if "finished" == self.status and not self._recomputed:
self.archive_status()
"""
Serialize the item to a dictionary.
Returns:
dict: The serialized item.
"""
if "finished" == self.status:
if not self._recomputed:
self.archive_status()
self.get_file_sidecar()
item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields())
return item
def json(self) -> str:
return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)
"""
Convert the item to a JSON string.
Returns:
str: The JSON string representation of the item.
"""
return Encoder(sort_keys=True, indent=4).encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
"""
Get a value from the item by key.
Args:
key (str): The key to get the value for.
default (Any): The default value to return if the key is not found.
Returns:
Any: The value for the key, or the default value if the key is not found
"""
return self.__dict__.get(key, default)
def get_id(self) -> str:
"""
Get the unique identifier for the item.
Returns:
str: The unique identifier for the item.
"""
return self._id
def name(self) -> str:
"""
Get a short string representation of the item.
Returns:
str: A short string representation of the item.
"""
return f'id="{self.id}", title="{self.title}"'
def get_file(self, download_path: Path | None = None) -> Path | None:
"""
Get the file path of the downloaded item.
Args:
download_path (Path | None): The base download path. If None, it will be taken from the config.
Returns:
Path | None: The file path of the downloaded item, or None if not available.
"""
if not self.filename:
return None
if not download_path:
from .config import Config
base_path = Path(Config.get_instance().download_path)
else:
base_path = download_path if isinstance(download_path, Path) else Path(download_path)
filename = base_path
if self.folder:
filename: Path = filename / self.folder
filename = filename / self.filename
try:
realFile, status = get_file(download_path=base_path, file=str(filename.relative_to(base_path)))
except Exception:
return None
return Path(realFile) if status in (200, 302) else None
def get_archive_id(self) -> str | None:
"""
Get the archive ID for the download URL.
@ -326,6 +490,13 @@ class ItemDTO:
return self.archive_id
def get_extractor(self) -> str | None:
"""
Get the extractor key for the download URL.
Returns:
str | None: The extractor key if available, None otherwise.
"""
if self.archive_id:
return self.archive_id.split(" ")[0]
@ -353,6 +524,13 @@ class ItemDTO:
return params
def archive_status(self, force: bool = False) -> None:
"""
Recompute the archive status of the item.
Args:
force (bool): If True, force recomputation even if already computed.
"""
if not force and (self._recomputed or not self.archive_id):
return
@ -413,9 +591,28 @@ class ItemDTO:
return True
def get_file_sidecar(self) -> dict:
"""
Get sidecar files associated with the item.
Returns:
dict: A dictionary with sidecar files categorized by type.
"""
if filename := self.get_file():
self.sidecar = get_file_sidecar(filename)
return self.sidecar
@staticmethod
def removed_fields() -> tuple:
"""Fields that once existed but are no longer used."""
"""
Fields that once existed but are no longer used.
Returns:
tuple: A tuple of field names that are no longer used.
"""
return (
"thumbnail",
"quality",
@ -431,6 +628,10 @@ class ItemDTO:
)
def __post_init__(self):
"""
Post-initialization to compute archive status if applicable.
"""
self.get_archive_id()
self.get_archive_file()
self.archive_status()
self.get_file_sidecar()

View file

@ -1,9 +1,9 @@
import asyncio
import json
import logging
import traceback
from collections.abc import Awaitable
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
@ -464,36 +464,36 @@ class Notification(metaclass=Singleton):
try:
LOG.info(f"Sending Notification event '{ev.event}: {ev.id}' to '{target.name}'.")
reqBody: dict[str, Any] = {
"method": target.request.method.upper(),
"url": target.request.url,
"headers": {
"User-Agent": f"YTPTube/{self._version}",
"X-Event-Id": ev.id,
"X-Event": ev.event,
"Content-Type": "application/json"
if "json" == target.request.type.lower()
else "application/x-www-form-urlencoded",
},
headers: dict[str, str] = {
"User-Agent": f"YTPTube/{self._version}",
"X-Event-Id": ev.id,
"X-Event": ev.event,
"Content-Type": "application/json"
if "json" == target.request.type.lower()
else "application/x-www-form-urlencoded",
}
if len(target.request.headers) > 0:
for h in target.request.headers:
reqBody["headers"][h.key] = h.value
headers.update({h.key: h.value for h in target.request.headers if h.key and h.value})
body_key: str = "json" if "json" == target.request.type.lower() else "data"
reqBody[body_key] = self._deep_unpack(ev.serialize())
payload: dict = ev.serialize()
if "data" != target.request.data_key:
reqBody[body_key][target.request.data_key] = reqBody[body_key]["data"]
reqBody[body_key].pop("data", None)
payload[target.request.data_key] = payload["data"]
payload.pop("data", None)
if "form" == target.request.type.lower():
reqBody[body_key][target.request.data_key] = self._encoder.encode(
reqBody[body_key][target.request.data_key]
)
payload[target.request.data_key] = self._encoder.encode(payload[target.request.data_key])
else:
payload = self._encoder.encode(payload)
response = await self._client.request(**reqBody)
response = await self._client.request(
method=target.request.method.upper(),
url=target.request.url,
headers=headers,
data=payload if "form" == target.request.type.lower() else None,
content=payload if "json" == target.request.type.lower() else None,
)
respData: dict[str, Any] = {
"url": target.request.url,
@ -512,7 +512,9 @@ class Notification(metaclass=Singleton):
err_msg = str(e)
if not err_msg:
err_msg: str = type(e).__name__
LOG.error(f"Error sending Notification event '{ev.event}: {ev.id}' to '{target.name}'. '{err_msg!s}'.")
tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
LOG.error(f"Error sending Notification event '{ev.event}: {ev.id}' to '{target.name}'. '{err_msg!s}'. {tb}")
return {"url": target.request.url, "status": 500, "text": str(ev)}
def emit(self, e: Event, _, **__) -> None:
@ -522,18 +524,5 @@ class Notification(metaclass=Singleton):
self._offload.submit(self.send, e)
return
def _deep_unpack(self, data: dict) -> dict:
for k, v in data.items():
if isinstance(v, dict):
data[k] = self._deep_unpack(v)
if isinstance(v, list):
data[k] = [self._deep_unpack(i) for i in v]
if isinstance(v, datetime):
data[k] = v.isoformat()
if isinstance(v, object) and hasattr(v, "serialize"):
data[k] = v.serialize()
return data
async def noop(self) -> None:
return None

View file

@ -7,9 +7,6 @@ import sys
from pathlib import Path
from typing import Any, Protocol
SUPPORTED_CODECS: tuple[str] = ("h264_qsv", "h264_nvenc", "h264_amf", "h264_videotoolbox", "h264_vaapi", "libx264")
"Supported encoder names in order of preference."
def has_dri_devices() -> bool:
"""
@ -40,6 +37,7 @@ def ffmpeg_encoders() -> set[str]:
set[str]: A set of available ffmpeg encoder names.
"""
from .config import SUPPORTED_CODECS
try:
result: subprocess.CompletedProcess[str] = subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], # noqa: S607
@ -75,6 +73,7 @@ def select_encoder(configured: str) -> str:
str: The selected concrete encoder name.
"""
from .config import SUPPORTED_CODECS
configured = (configured or "").strip()
avail: set[str] = ffmpeg_encoders()

View file

@ -10,15 +10,9 @@ from typing import TYPE_CHECKING, Any, ClassVar
from aiohttp import web
from .config import Config
from .config import SUPPORTED_CODECS, Config
from .ffprobe import ffprobe
from .SegmentEncoders import (
SUPPORTED_CODECS,
encoder_fallback_chain,
get_builder_for_codec,
has_dri_devices,
select_encoder,
)
from .SegmentEncoders import encoder_fallback_chain, get_builder_for_codec, has_dri_devices, select_encoder
if TYPE_CHECKING:
from asyncio.subprocess import Process

View file

@ -4,7 +4,8 @@ import json
import logging
import pkgutil
import time
from dataclasses import dataclass
import uuid
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@ -15,11 +16,11 @@ from .config import Config
from .DownloadQueue import DownloadQueue
from .encoder import Encoder
from .Events import EventBus, Events
from .ItemDTO import Item
from .ItemDTO import Item, ItemDTO
from .Scheduler import Scheduler
from .Services import Services
from .Singleton import Singleton
from .Utils import archive_add, archive_delete, extract_info, init_class, validate_url
from .Utils import archive_add, archive_delete, archive_read, extract_info, init_class, validate_url
from .YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger("tasks")
@ -127,6 +128,95 @@ class Task:
return {"file": archive_file, "items": items}
def _split_inspect_metadata(metadata: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Split commonly consumed metadata keys from the rest.
Args:
metadata (dict[str, Any]|None): The metadata to split.
Returns:
tuple[dict[str, Any], dict[str, Any]]: The primary and extra metadata.
"""
metadata = dict(metadata or {})
primary: dict[str, Any] = {}
for key in ("matched", "handler", "supported"):
if key in metadata:
primary[key] = metadata.pop(key)
return primary, metadata
@dataclass(slots=True)
class TaskItem:
url: str
"The URL of the item."
title: str | None = None
"The title of the item."
archive_id: str | None = None
"The archive ID of the item."
metadata: dict[str, Any] = field(default_factory=dict)
"Additional metadata related to the item."
@dataclass(slots=True)
class TaskResult:
items: list[TaskItem] = field(default_factory=list)
"The list of items."
metadata: dict[str, Any] = field(default_factory=dict)
"Additional metadata related to the result."
def serialize(self) -> dict[str, Any]:
"""
Serialize the task result.
Returns:
dict[str, Any]: The serialized task result.
"""
primary, extra = _split_inspect_metadata(self.metadata)
payload: dict[str, Any] = {**primary, "items": [asdict(item) for item in self.items]}
if extra:
payload["metadata"] = extra
return payload
@dataclass(slots=True)
class TaskFailure:
message: str
"A human-readable message describing the failure."
error: str | None = None
"An optional error code or string."
metadata: dict[str, Any] = field(default_factory=dict)
"Additional metadata related to the failure."
def serialize(self) -> dict[str, Any]:
"""
Serialize the task failure.
Returns:
dict[str, Any]: The serialized task failure.
"""
primary, extra = _split_inspect_metadata(self.metadata)
payload: dict[str, Any] = dict(primary)
if self.error:
payload["error"] = self.error
if self.message and (not self.error or self.message != self.error):
payload["message"] = self.message
if extra:
payload["metadata"] = extra
return payload
class Tasks(metaclass=Singleton):
"""
This class is used to manage the tasks.
@ -199,6 +289,7 @@ class Tasks(metaclass=Singleton):
"""
self.load()
Services.get_instance().add("tasks", self)
async def event_handler(data, _):
if data and data.data:
@ -211,6 +302,10 @@ class Tasks(metaclass=Singleton):
"""Return the tasks."""
return self._tasks
def get_handler(self) -> "HandleTask":
"""Expose the handle task helper."""
return self._task_handler
def get(self, task_id: str) -> Task | None:
"""
Get a task by its ID.
@ -471,8 +566,21 @@ class HandleTask:
"The configuration."
self._task_name: str = f"{__class__.__name__}._dispatcher"
"The task name for the scheduler."
self._queued: dict[str, set[str]] = {}
"Queued archive IDs per handler."
self._failure_count: dict[str, dict[str, int]] = {}
"Failure counts per handler and archive ID."
EventBus.get_instance().subscribe(
Events.ITEM_ERROR,
self._handle_item_error,
f"{__class__.__name__}.item_error",
)
def load(self) -> None:
"""
Load the available handlers and schedule the dispatcher.
"""
self._handlers: list[type] = self._discover()
timer: str = self._config.tasks_handler_timer
@ -551,7 +659,7 @@ class HandleTask:
return None
async def dispatch(self, task: Task, handler: type | None = None, **kwargs) -> Any | None:
async def dispatch(self, task: Task, handler: type | None = None, **kwargs) -> TaskResult | TaskFailure | None: # noqa: ARG002
"""
Dispatch a task to the appropriate handler.
@ -561,7 +669,7 @@ class HandleTask:
**kwargs: Additional context to pass to the handler.
Returns:
Any|None: The result of the handler's execution, or None if no handler found.
TaskResult|TaskFailure|None: The extraction outcome, or None if no handler matched.
"""
if not handler:
@ -569,12 +677,225 @@ class HandleTask:
if handler is None:
return None
services: Services = Services.get_instance()
try:
return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs)
except Exception as e:
LOG.exception(e)
extraction: TaskResult | TaskFailure = await services.handle_async(
handler=handler.extract, task=task, config=self._config
)
except NotImplementedError:
LOG.error(f"Handler '{handler.__name__}' does not implement extract().")
return TaskFailure(message="Handler does not support extraction.")
except Exception as exc:
LOG.exception(exc)
raise
if isinstance(extraction, TaskFailure):
LOG.error(f"Handler '{handler.__name__}' failed to extract items: {extraction.message}")
return extraction
if not isinstance(extraction, TaskResult):
LOG.error(
f"Handler '{handler.__name__}' returned unexpected result type '{type(extraction).__name__}'.",
)
return TaskFailure(
message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__}
)
raw_items: list[TaskItem] = extraction.items or []
metadata: dict[str, Any] = extraction.metadata or {}
handler_name: str = handler.__name__
queued: set[str] = self._queued.setdefault(handler_name, set())
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
params: dict = task.get_ytdlp_opts().get_all()
archive_file: str | None = params.get("download_archive")
download_queue: DownloadQueue = services.get("queue") or DownloadQueue.get_instance()
notify: EventBus = services.get("notify") or EventBus.get_instance()
archive_ids: list[str] = [
item.archive_id for item in raw_items if isinstance(item, TaskItem) and item.archive_id
]
downloaded: list[str] = archive_read(archive_file, archive_ids) if archive_file else []
filtered: list[TaskItem] = []
for item in raw_items:
if not isinstance(item, TaskItem):
LOG.warning("Handler '{handler.__name__}' produced non-TaskItem entry: {item!r}")
continue
url: str = item.url
if not url:
continue
archive_id: str | None = item.archive_id
if not archive_id:
LOG.warning(f"'{task.name}': Item with URL '{url}' is missing an archive ID. Skipping.")
continue
if archive_id in queued:
continue
queued.add(archive_id)
if archive_file and archive_id in downloaded:
continue
if download_queue.queue.exists(url=url):
continue
try:
done = download_queue.done.get(url=url)
if "error" != done.info.status:
continue
except KeyError:
pass
if archive_id not in failures:
failures[archive_id] = 0
filtered.append(item)
if not filtered:
if raw_items:
LOG.debug(
f"Handler '{handler.__name__}' produced '{len(raw_items)}' for '{task.name}' items, none queued after filtering."
)
return TaskResult(items=[], metadata=metadata)
LOG.info(
f"Handler '{handler.__name__}' Found '{len(filtered)}' new items for '{task.name}' (raw={len(raw_items)})."
)
base_item = Item.format(
{
"url": task.url,
"preset": task.preset or self._config.default_preset,
"folder": task.folder or "",
"template": task.template or "",
"cli": task.cli or "",
"auto_start": task.auto_start,
"extras": {"source_task": task.id, "source_handler": handler.__name__},
}
)
for item in filtered:
metadata_entry: dict[str, Any] = item.metadata if isinstance(item.metadata, dict) else {}
extras: dict[str, Any] = base_item.extras.copy()
if metadata_entry:
extras["metadata"] = metadata_entry
notify.emit(
Events.ADD_URL,
data=base_item.new_with(url=item.url, extras=extras).serialize(),
)
return TaskResult(items=filtered, metadata=metadata)
async def inspect(
self,
url: str,
preset: str | None = None,
handler_name: str | None = None,
) -> TaskResult | TaskFailure:
if not self._handlers:
self._handlers = self._discover()
task = Task(
id=str(uuid.uuid4()),
name="Inspector",
url=url,
preset=preset or self._config.default_preset,
auto_start=False,
)
services = Services.get_instance()
handler_cls: type | None
if handler_name:
handler_cls = next((cls for cls in self._handlers if cls.__name__.lower() == handler_name.lower()), None)
if handler_cls is None:
message: str = f"Handler '{handler_name}' not found."
return TaskFailure(
message=message,
error=message,
metadata={"matched": False, "handler": handler_name},
)
try:
matched = services.handle_sync(handler=handler_cls.can_handle, task=task)
except Exception as exc: # pragma: no cover - defensive
LOG.exception(exc)
message = str(exc)
return TaskFailure(
message=message,
error=message,
metadata={"matched": False, "handler": handler_cls.__name__},
)
if not matched:
return TaskFailure(
message="Handler cannot process the supplied URL.",
metadata={"matched": False, "handler": handler_cls.__name__},
)
else:
handler_cls = self._find_handler(task)
if handler_cls is None:
message = "No handler matched the supplied URL."
return TaskFailure(
message=message,
error=message,
metadata={"matched": False, "handler": None},
)
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
try:
extraction: TaskResult | TaskFailure = await services.handle_async(
handler=handler_cls.extract, task=task, config=self._config
)
except NotImplementedError:
return TaskFailure(
message="Handler does not support manual inspection.",
metadata={**base_metadata, "supported": False},
)
except Exception as exc:
LOG.exception(exc)
message = str(exc)
return TaskFailure(
message=message,
error=message,
metadata={**base_metadata, "supported": True},
)
if isinstance(extraction, TaskFailure):
combined_failure_metadata: dict[str, Any] = {**base_metadata, "supported": True}
if extraction.metadata:
combined_failure_metadata.update(extraction.metadata)
failure_error = extraction.error if extraction.error else extraction.message
return TaskFailure(
message=extraction.message,
error=failure_error,
metadata=combined_failure_metadata,
)
if not isinstance(extraction, TaskResult):
LOG.error(
f"Handler '{handler_cls.__name__}' returned unexpected result type '{type(extraction).__name__}' during inspection.",
)
extraction = TaskResult()
combined_metadata: dict[str, Any] = {**base_metadata, "supported": True}
if extraction.metadata:
combined_metadata.update(extraction.metadata)
return TaskResult(items=list(extraction.items), metadata=combined_metadata)
def _discover(self) -> list[type]:
import importlib
@ -591,7 +912,28 @@ class HandleTask:
if cls.__module__ != module.__name__:
continue
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "handle", None)):
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "extract", None)):
handlers.append(cls)
return handlers
async def _handle_item_error(self, event, _name, **_kwargs):
item = getattr(event, "data", None)
if not isinstance(item, ItemDTO):
return
extras = getattr(item, "extras", {}) or {}
handler_name = extras.get("source_handler")
if not handler_name:
return
archive_id = item.archive_id
if not archive_id:
return
queued = self._queued.get(handler_name)
if queued:
queued.discard(archive_id)
failures = self._failure_count.setdefault(handler_name, {})
failures[archive_id] = failures.get(archive_id, 0) + 1

View file

@ -8,9 +8,10 @@ import os
import re
import shlex
import socket
import time
import uuid
from datetime import UTC, datetime, timedelta
from functools import lru_cache
from functools import lru_cache, wraps
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from typing import Any, TypeVar
@ -87,8 +88,6 @@ DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}
T = TypeVar("T")
"Generic type variable."
ARCHIVE_IDS_CACHE: dict[str, dict] = {}
"Cache for archive IDs."
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
@ -99,6 +98,112 @@ class FileLogFormatter(logging.Formatter):
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
"""
Decorator that applies an LRU cache with a time-to-live (TTL) to a function.
Supports both synchronous and asynchronous functions.
Args:
ttl_seconds (int): Time-to-live in seconds.
max_size (int): Maximum size of the cache.
Returns:
Decorated function with caching capabilities.
"""
import inspect
def decorator(func):
is_async = inspect.iscoroutinefunction(func)
if is_async:
# For async functions, we need to cache the actual result, not the coroutine
cache = {}
cache_expiry = {}
@wraps(func)
async def async_wrapper(*args, **kwargs):
key = str(args) + str(sorted(kwargs.items()))
current_time = time.monotonic()
# Check if we have a cached result that hasn't expired
if key in cache and key in cache_expiry and current_time < cache_expiry[key]:
cached_result = cache[key]
try:
return copy.deepcopy(cached_result)
except Exception:
return cached_result
# Call the function and cache the result
result = await func(*args, **kwargs)
try:
cached_value = copy.deepcopy(result)
except Exception:
cached_value = result
cache[key] = cached_value
cache_expiry[key] = current_time + ttl_seconds
# Limit cache size
if len(cache) > max_size:
# Remove oldest entries
oldest_keys = sorted(cache_expiry.keys(), key=lambda k: cache_expiry[k])[: len(cache) - max_size]
for old_key in oldest_keys:
cache.pop(old_key, None)
cache_expiry.pop(old_key, None)
try:
return copy.deepcopy(cached_value)
except Exception:
return cached_value
# Expose cache management methods
def cache_clear():
cache.clear()
cache_expiry.clear()
def cache_info():
# Use functools._CacheInfo for compatibility with standard lru_cache
from functools import _CacheInfo
return _CacheInfo(hits=0, misses=len(cache), maxsize=max_size, currsize=len(cache))
async_wrapper.cache_clear = cache_clear
async_wrapper.cache_info = cache_info
return async_wrapper
# For sync functions, use the original implementation
@lru_cache(maxsize=max_size)
def cached(*args, **kwargs):
result = func(*args, **kwargs)
try:
return copy.deepcopy(result)
except Exception:
return result
cached_expiry = time.monotonic() + ttl_seconds
@wraps(func)
def sync_wrapper(*args, **kwargs):
nonlocal cached_expiry
if time.monotonic() >= cached_expiry:
cached.cache_clear()
cached_expiry = time.monotonic() + ttl_seconds
result = cached(*args, **kwargs)
try:
return copy.deepcopy(result)
except Exception:
return result
# expose cache_clear, cache_info
sync_wrapper.cache_clear = cached.cache_clear
sync_wrapper.cache_info = cached.cache_info
return sync_wrapper
return decorator
def calc_download_path(base_path: str | Path, folder: str | None = None, create_path: bool = True) -> str:
"""
Calculates download path and prevents folder traversal.
@ -387,7 +492,7 @@ def check_id(file: Path) -> bool | str:
return False
@lru_cache(maxsize=512)
@timed_lru_cache(ttl_seconds=300, max_size=256)
def is_private_address(hostname: str) -> bool:
ip: str = socket.gethostbyname(hostname)
ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(ip)
@ -545,19 +650,23 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
return False
def get_file_sidecar(file: Path) -> list[dict]:
@timed_lru_cache(ttl_seconds=60, max_size=256)
def get_file_sidecar(file: Path | None = None) -> dict[dict]:
"""
Get sidecar files for the given file.
Args:
file (Path): The video file.
file (Path|None): The video file.
Returns:
list: List of sidecar files.
dict: A dictionary with sidecar files categorized by type.
"""
files: dict = {}
if not file:
return files
for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
if f == file or f.is_file() is False or f.stem.startswith("."):
continue
@ -595,7 +704,6 @@ def get_file_sidecar(file: Path) -> list[dict]:
return files
@lru_cache(maxsize=512)
def get_possible_images(dir: str) -> list[dict]:
images: list = []
@ -1093,6 +1201,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
raise ValueError(msg) from e
@timed_lru_cache(ttl_seconds=300, max_size=256)
def get_archive_id(url: str) -> dict[str, str | None]:
"""
Get the archive ID for a given URL.
@ -1116,9 +1225,6 @@ def get_archive_id(url: str) -> dict[str, str | None]:
"archive_id": None,
}
if url in ARCHIVE_IDS_CACHE:
return ARCHIVE_IDS_CACHE[url]
if YTDLP_INFO_CLS is None:
YTDLP_INFO_CLS = YTDLP(
params={
@ -1151,7 +1257,6 @@ def get_archive_id(url: str) -> dict[str, str | None]:
LOG.exception(e)
LOG.error(f"Error getting archive ID: {e}")
ARCHIVE_IDS_CACHE.update({url: idDict})
return idDict

View file

@ -19,6 +19,9 @@ from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
if TYPE_CHECKING:
from subprocess import CompletedProcess
SUPPORTED_CODECS: tuple[str] = ("h264_qsv", "h264_nvenc", "h264_amf", "h264_videotoolbox", "h264_vaapi", "libx264")
"Supported encoder names in order of preference."
class Config(metaclass=Singleton):
app_env: str = "production"
@ -417,6 +420,11 @@ class Config(metaclass=Singleton):
msg: str = f"Invalid app environment '{self.app_env}' specified. Must be 'production' or 'development'."
raise ValueError(msg)
if self.streamer_vcodec and self.streamer_vcodec not in SUPPORTED_CODECS:
supported = ", ".join(SUPPORTED_CODECS)
msg: str = f"Invalid video codec '{self.streamer_vcodec}' specified. Supported: '{supported}'."
raise ValueError(msg)
if "dev-master" == self.app_version:
self._version_via_git()

View file

@ -10,12 +10,10 @@ import operator
import os
import subprocess # qa: ignore
from pathlib import Path
from typing import TYPE_CHECKING
import anyio
if TYPE_CHECKING:
from app.library.cache import Cache
from app.library.Utils import timed_lru_cache
LOG: logging.Logger = logging.getLogger(__name__)
@ -238,7 +236,8 @@ class FFProbeResult:
}
async def ffprobe(file: str) -> FFProbeResult:
@timed_lru_cache(ttl_seconds=300, max_size=128)
async def ffprobe(file: Path|str) -> FFProbeResult:
"""
Run ffprobe on a file and return the parsed data as a dictionary.
@ -249,21 +248,12 @@ async def ffprobe(file: str) -> FFProbeResult:
dict: A dictionary containing the parsed data.
"""
from app.library.Services import Services
f = Path(file)
f = Path(file) if isinstance(file, str) else file
if not f.exists():
msg = f"No such media file '{file}'."
raise OSError(msg)
cache: Cache | None = Services.get_instance().get("cache")
cache_key: str = f"ffprobe:{f!s}:{f.stat().st_size}"
if cache and (cached := cache.get(cache_key)):
LOG.debug(f"ffprobe cache hit for '{cache_key}'")
return cached
try:
async with await anyio.open_file(os.devnull, "w") as tempf:
await asyncio.create_subprocess_exec(
@ -310,7 +300,4 @@ async def ffprobe(file: str) -> FFProbeResult:
elif stream.is_attachment():
result.attachment.append(stream)
if cache:
cache.set(cache_key, result, ttl=300)
return result

View file

@ -1,73 +1,30 @@
# flake8: noqa: ARG004
import logging
from typing import Any
import httpx
from yt_dlp.utils.networking import random_user_agent
from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.ItemDTO import ItemDTO
from app.library.Tasks import Task
LOG: logging.Logger = logging.getLogger(__name__)
from app.library.Tasks import Task, TaskFailure, TaskResult
class BaseHandler:
queued: set[str] = set()
failure_count: dict[str, int] = {}
def __init_subclass__(cls, **kwargs):
"""Ensure each subclass has its own state containers."""
super().__init_subclass__(**kwargs)
if "queued" not in cls.__dict__:
cls.queued = set()
if "failure_count" not in cls.__dict__:
cls.failure_count = {}
async def event_handler(data, _):
if data and data.data:
await cls.on_error(data.data)
EventBus.get_instance().subscribe(Events.ITEM_ERROR, event_handler, f"{cls.__name__}.on_error")
@staticmethod
def can_handle(task: Task) -> bool:
return False
@staticmethod
async def handle(task: Task, notify: EventBus, config: Config, queue: DownloadQueue):
pass
async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
raise NotImplementedError
@classmethod
async def inspect(cls, task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
return await cls.extract(task=task, config=config)
@staticmethod
def parse(url: str) -> Any | None:
return None
@classmethod
async def on_error(cls, item: ItemDTO) -> None:
"""
Handle errors by logging them and removing the queued ID if it exists.
Args:
item (ItemDTO): The error data containing the URL and other information.
"""
if not item or not isinstance(item, ItemDTO):
return
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
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: '{failCount + 1}'.")
if item.archive_id in cls.queued:
cls.queued.remove(item.archive_id)
cls.failure_count[item.archive_id] = 1 + failCount
@staticmethod
def tests() -> list[tuple[str, bool]]:
return []

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,456 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ytptube.app/schemas/task-definition.json",
"title": "YTPTube Generic Task Definition",
"type": "object",
"required": [
"name",
"match",
"parse"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "Human-readable identifier for this definition."
},
"match": {
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{
"type": "string",
"minLength": 1,
"description": "Glob pattern matched against task URLs."
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"regex": {
"type": "string",
"minLength": 1,
"description": "Regular expression applied to task URLs."
},
"glob": {
"type": "string",
"minLength": 1,
"description": "Glob pattern applied to task URLs."
}
},
"anyOf": [
{
"required": [
"regex"
]
},
{
"required": [
"glob"
]
}
]
}
]
},
"description": "Patterns that determine which tasks use this definition."
},
"engine": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"httpx",
"selenium"
],
"default": "httpx",
"description": "Fetch engine to use (HTTPX or Selenium)."
},
"options": {
"type": "object",
"description": "Engine-specific configuration.",
"additionalProperties": true,
"properties": {
"url": {
"type": "string",
"format": "uri",
"description": "Remote Selenium hub URL."
},
"browser": {
"type": "string",
"enum": [
"chrome"
],
"description": "Selenium browser name (currently only chrome)."
},
"arguments": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string"
}
],
"description": "Browser launch arguments for Selenium."
},
"wait_for": {
"$ref": "#/definitions/waitFor"
},
"wait_timeout": {
"type": "number",
"minimum": 0,
"description": "Seconds to wait for the wait_for selector."
},
"page_load_timeout": {
"type": "number",
"minimum": 0,
"description": "Seconds to allow page load to complete."
}
}
}
},
"description": "Optional engine configuration (defaults to HTTPX)."
},
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"method": {
"type": "string",
"enum": [
"GET",
"POST"
],
"default": "GET",
"description": "HTTP method to use when fetching the page."
},
"url": {
"type": "string",
"format": "uri",
"description": "Override URL to fetch instead of the task URL."
},
"headers": {
"$ref": "#/definitions/stringMap",
"description": "Additional request headers."
},
"params": {
"$ref": "#/definitions/stringMap",
"description": "Query string parameters."
},
"data": {
"description": "Form payload for POST requests.",
"oneOf": [
{
"$ref": "#/definitions/stringMap"
},
{
"type": "string"
},
{
"type": "null"
}
]
},
"json": {
"description": "JSON payload for POST requests.",
"type": [
"object",
"array",
"string",
"number",
"boolean",
"null"
]
},
"timeout": {
"type": "number",
"minimum": 0,
"description": "Timeout in seconds for the request."
}
},
"description": "Optional HTTP request overrides."
},
"response": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"html",
"json"
],
"default": "html",
"description": "Body format returned by the target URL."
}
}
},
"parse": {
"type": "object",
"minProperties": 1,
"description": "Field extraction rules and optional container definition.",
"additionalProperties": false,
"properties": {
"items": {
"$ref": "#/definitions/container"
}
},
"patternProperties": {
"^(?!items$).+$": {
"$ref": "#/definitions/extractionRule"
}
},
"allOf": [
{
"if": {
"not": {
"required": [
"items"
]
}
},
"then": {
"required": [
"link"
]
}
}
]
}
},
"definitions": {
"stringMap": {
"type": "object",
"additionalProperties": {
"type": [
"string",
"number",
"boolean",
"null"
]
}
},
"waitFor": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"css",
"xpath"
],
"description": "Selector type to wait for."
},
"expression": {
"type": "string",
"minLength": 1,
"description": "Selector expression."
},
"value": {
"type": "string",
"minLength": 1,
"description": "Alias for expression for backwards compatibility."
}
},
"anyOf": [
{
"required": [
"expression"
]
},
{
"required": [
"value"
]
}
]
},
"postFilter": {
"type": "object",
"additionalProperties": false,
"required": [
"filter"
],
"properties": {
"filter": {
"type": "string",
"minLength": 1,
"description": "Regular expression applied after extraction."
},
"value": {
"type": "string",
"minLength": 1,
"description": "Named or numbered capture group to return."
}
}
},
"containerFields": {
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"patternProperties": {
"^.+$": {
"$ref": "#/definitions/extractionRule"
}
},
"required": [
"link"
]
},
"container": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {
"type": "string",
"enum": [
"css",
"xpath",
"jsonpath"
],
"default": "css",
"description": "Selector type to use for locating repeatable elements."
},
"selector": {
"type": "string",
"minLength": 1,
"description": "CSS/XPath selector identifying each item container."
},
"expression": {
"type": "string",
"minLength": 1,
"description": "Alias for selector."
},
"fields": {
"$ref": "#/definitions/containerFields"
}
},
"required": [
"fields"
],
"anyOf": [
{
"required": [
"selector"
]
},
{
"required": [
"expression"
]
}
]
},
"extractionRule": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"expression"
],
"properties": {
"type": {
"type": "string",
"enum": [
"css",
"xpath",
"regex",
"jsonpath"
],
"description": "Extraction strategy."
},
"expression": {
"type": "string",
"minLength": 1,
"description": "Selector or pattern used to extract values."
},
"attribute": {
"type": "string",
"minLength": 1,
"description": "Optional attribute or group to return."
},
"post_filter": {
"$ref": "#/definitions/postFilter"
}
}
}
},
"examples": [
{
"name": "example",
"match": [
"https://example.com/articles/*",
{
"regex": "https://example.com/post/[0-9]+"
}
],
"engine": {
"type": "httpx"
},
"request": {
"method": "GET",
"headers": {
"User-Agent": "MyCustomAgent/1.0"
}
},
"parse": {
"link": {
"type": "css",
"expression": ".article a.link",
"attribute": "href"
},
"title": {
"type": "css",
"expression": ".article .title",
"attribute": "text"
},
"id": {
"type": "regex",
"expression": "id=(?P<id>[0-9]+)",
"post_filter": {
"filter": "(?P<id>[0-9]+)",
"value": "id"
}
}
}
},
{
"name": "container-example",
"match": [
"https://example.com/list"
],
"parse": {
"items": {
"type": "css",
"selector": ".cards .card",
"fields": {
"link": {
"type": "css",
"expression": ".card-header a",
"attribute": "href"
},
"title": {
"type": "css",
"expression": ".card-header a",
"attribute": "text"
},
"poet": {
"type": "css",
"expression": "footer .card-footer-item:first-child a",
"attribute": "text"
}
}
}
}
}
]
}

View file

@ -3,19 +3,14 @@ import re
from typing import TYPE_CHECKING
from xml.etree.ElementTree import Element
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item
from app.library.Tasks import Task
from app.library.Utils import archive_read, get_archive_id
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler
if TYPE_CHECKING:
from xml.etree.ElementTree import Element
from app.library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__)
@ -30,40 +25,23 @@ class TwitchHandler(BaseHandler):
return TwitchHandler.parse(task.url) is not None
@staticmethod
async def handle(task: Task, notify: EventBus, queue: DownloadQueue):
"""
Fetch the RSS feed for a Twitch channel VODs, parse entries,
and enqueue new items that are not in the archive/queue already.
Args:
task (Task): The task containing the Twitch channel URL.
notify (EventBus): The event bus for notifications.
queue (DownloadQueue): The download queue instance.
"""
async def _collect_feed(
task: Task,
params: dict,
handle_name: str,
) -> tuple[str, list[dict[str, str]], bool]:
from defusedxml.ElementTree import fromstring
handleName: str | None = TwitchHandler.parse(task.url)
if not handleName:
LOG.error(f"Cannot parse '{task.name}' URL: {task.url}")
return
params: dict = task.get_ytdlp_opts().get_all()
archive_file: str | None = params.get("download_archive")
if not archive_file:
LOG.error(f"Task '{task.name}' does not have an archive file.")
return
feed_url: str = TwitchHandler.FEED.format(handle=handleName)
feed_url: str = TwitchHandler.FEED.format(handle=handle_name)
LOG.debug(f"Fetching '{task.name}' feed.")
response = await TwitchHandler.request(url=feed_url, ytdlp_opts=params)
response.raise_for_status()
items: list = []
root: Element[str] = fromstring(response.text)
items: list[dict[str, str]] = []
has_items = False
root: Element[str] = fromstring(response.text)
for entry in root.findall("channel/item"):
link_elem: Element[str] | None = entry.find("link")
url: str = link_elem.text.strip() if link_elem is not None and link_elem.text else ""
@ -71,12 +49,14 @@ class TwitchHandler(BaseHandler):
LOG.warning(f"Entry in '{task.name}' feed is missing URL. Skipping entry.")
continue
m: re.Match[str] | None = re.search(r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url)
if not m:
match: re.Match[str] | None = re.search(
r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url
)
if not match:
LOG.warning(f"URL in '{task.name}' feed does not look like a VOD link: {url}")
continue
vid: str = m.group("id")
vid: str = match.group("id")
title_elem: Element[str] | None = entry.find("title")
title: str = title_elem.text.strip() if title_elem is not None and title_elem.text else ""
@ -89,68 +69,34 @@ class TwitchHandler(BaseHandler):
LOG.warning(f"Could not compute archive ID for video '{vid}' in '{task.name}' feed. Skipping entry.")
continue
if archive_id in TwitchHandler.queued:
continue
items.append({"id": vid, "url": url, "title": title, "archive_id": archive_id})
if len(items) < 1:
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
return feed_url, items, has_items
filtered: list = []
@staticmethod
async def extract(task: Task) -> TaskResult | TaskFailure:
handle_name: str | None = TwitchHandler.parse(task.url)
if not handle_name:
return TaskFailure(message="Unrecognized Twitch channel URL.")
downloaded: list[str] = archive_read(archive_file, [item["archive_id"] for item in items])
for item in items:
TwitchHandler.queued.add(item["archive_id"])
if item["archive_id"] in downloaded:
continue
if queue.queue.exists(url=item["url"]):
continue
try:
done: Download = queue.done.get(url=item["url"])
if "error" != done.info.status:
continue
except KeyError:
pass
if item["archive_id"] not in TwitchHandler.failure_count:
TwitchHandler.failure_count[item["archive_id"]] = 0
filtered.append(item)
if len(filtered) < 1:
LOG.debug(f"No new items found in '{task.name}' feed.")
return
LOG.info(f"Found '{len(filtered)}' new items from '{task.name}' feed.")
rItem: Item = Item.format(
{
"url": feed_url,
"preset": task.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},
}
)
params: dict = task.get_ytdlp_opts().get_all()
try:
for item in filtered:
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
except Exception as e:
LOG.exception(e)
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")
return
feed_url, items, has_items = await TwitchHandler._collect_feed(task, params, handle_name)
except Exception as exc:
LOG.exception(exc)
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
task_items: list[TaskItem] = []
for entry in items:
if not (url := entry.get("url")):
continue
archive_id: str = entry.get("archive_id")
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id))
return TaskResult( items=task_items, metadata={ "feed_url": feed_url, "has_entries": has_items } )
@staticmethod
def parse(url: str) -> str | None:

View file

@ -1,21 +1,16 @@
import logging
import re
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item
from app.library.Tasks import Task
from app.library.Utils import archive_read, get_archive_id
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler
if TYPE_CHECKING:
from xml.etree.ElementTree import Element
from app.library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__)
@ -32,39 +27,28 @@ class YoutubeHandler(BaseHandler):
@staticmethod
def can_handle(task: Task) -> bool:
if not task.get_ytdlp_opts().get_all().get("download_archive"):
LOG.debug(f"'{task.name}': Task does not have an archive file configured.")
return False
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
return YoutubeHandler.parse(task.url) is not None
@staticmethod
async def handle(task: Task, notify: EventBus, queue: DownloadQueue):
async def _get(task: Task, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
"""
Fetch the Atom feed for a YouTube channel or playlist, parse entries,
and return a list of videos with metadata.
Fetch the feed and return raw entries.
Args:
task (Task): The task containing the YouTube URL.
notify (EventBus): The event bus for notifications.
queue (DownloadQueue): The download queue instance.
params (dict): The ytdlp options.
parsed (dict): The parsed URL components.
Returns:
tuple[str, list[dict[str, str]], int]: The feed URL, list of
"""
from defusedxml.ElementTree import fromstring
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
if not parsed:
LOG.error(f"'{task.name}': Cannot parse task URL: {task.url}")
return
params: dict = task.get_ytdlp_opts().get_all()
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
LOG.debug(f"'{task.name}': Fetching feed.")
items: list = []
response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params)
response.raise_for_status()
@ -74,10 +58,12 @@ class YoutubeHandler(BaseHandler):
"yt": "http://www.youtube.com/xml/schemas/2015",
}
real_count: int = 0
items: list[dict[str, str]] = []
real_count = 0
for entry in root.findall("atom:entry", ns):
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
vid: str | None = vid_elem.text if vid_elem is not None else ""
vid: str = vid_elem.text if vid_elem is not None and vid_elem.text else ""
if not vid:
LOG.warning(f"'{task.name}': Entry in the feed is missing a video ID. Skipping.")
continue
@ -91,73 +77,43 @@ class YoutubeHandler(BaseHandler):
continue
title_elem: Element[str] | None = entry.find("atom:title", ns)
title: str | None = title_elem.text if title_elem is not None else ""
title: str = title_elem.text if title_elem is not None and title_elem.text else ""
pub_elem: Element[str] | None = entry.find("atom:published", ns)
published: str | None = pub_elem.text if pub_elem is not None else ""
real_count += 1
published: str = pub_elem.text if pub_elem is not None and pub_elem.text else ""
if archive_id in YoutubeHandler.queued:
continue
real_count += 1
items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id})
if len(items) < 1:
if real_count < 1:
LOG.warning(f"'{task.name}': No entries found the RSS feed. URL: {feed_url}")
else:
LOG.debug(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
return
return feed_url, items, real_count
filtered: list = []
@staticmethod
async def extract(task: Task) -> TaskResult | TaskFailure:
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
if not parsed:
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
downloaded: list[str] = archive_read(params.get("download_archive"), [item["archive_id"] for item in items])
for item in items:
YoutubeHandler.queued.add(item["archive_id"])
if item["archive_id"] in downloaded:
continue
if queue.queue.exists(url=item["url"]):
continue
try:
done: Download = queue.done.get(url=item["url"])
if "error" != done.info.status:
continue
except KeyError:
pass
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"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
return
LOG.info(f"'{task.name}': Found '{len(filtered)}/{real_count}' new items from feed.")
rItem: Item = Item.format(
{
"url": feed_url,
"preset": task.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},
}
)
params: dict = task.get_ytdlp_opts().get_all()
try:
for item in filtered:
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
except Exception as e:
LOG.exception(e)
LOG.error(f"'{task.name}': Error while adding items from task feed. {e!s}")
return
feed_url, items, real_count = await YoutubeHandler._get(task, params, parsed)
except Exception as exc:
LOG.exception(exc)
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
task_items: list[TaskItem] = []
for entry in items:
if not (url := entry.get("url")):
continue
archive_id: str = entry.get("archive_id")
metadata: dict[str, Any] = {"published": entry.get("published")}
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata))
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "entry_count": real_count})
@staticmethod
def parse(url: str) -> dict[str, str] | None:

View file

@ -1,12 +1,10 @@
import asyncio
import logging
from pathlib import Path
from typing import TYPE_CHECKING
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
@ -73,7 +71,7 @@ async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
@route("GET", "api/history/{id}", "item_view")
async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response:
async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Update an item in the history.
@ -82,7 +80,6 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, co
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
config (Config): The configuration instance.
Returns:
Response: The response object.
@ -104,23 +101,11 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, co
"ffprobe": {},
}
if item.info.filename:
if "finished" == item.info.status and (filename := item.info.get_file()):
from app.library.ffprobe import ffprobe
try:
from app.library.ffprobe import ffprobe
from app.library.Utils import get_file
filename = Path(config.download_path)
if item.info.folder:
filename: Path = filename / item.info.folder
filename: Path = filename / item.info.filename
if filename.exists():
realFile, status = get_file(
download_path=config.download_path, file=str(filename.relative_to(config.download_path))
)
if status in (web.HTTPOk.status_code, web.HTTPFound.status_code):
info["ffprobe"] = await ffprobe(str(realFile))
info["ffprobe"] = await ffprobe(filename)
except Exception:
pass

View file

@ -6,12 +6,45 @@ from aiohttp.web import Request, Response
from app.library.encoder import Encoder
from app.library.router import route
from app.library.Tasks import Task, Tasks
from app.library.Utils import init_class, validate_uuid
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
from app.library.Utils import init_class, validate_url, validate_uuid
LOG: logging.Logger = logging.getLogger(__name__)
@route("POST", "api/tasks/inspect", "task_handler_inspect")
async def task_handler_inspect(request: Request, tasks: Tasks, encoder: Encoder) -> Response:
data = await request.json()
url: str | None = data.get("url") if isinstance(data, dict) else None
if not url:
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
try:
validate_url(url)
except ValueError as e:
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
try:
result: TaskResult | TaskFailure = await tasks.get_handler().inspect(
url=url, preset=preset, handler_name=handler_name
)
except Exception as e:
LOG.exception(e)
return web.json_response(
{"error": "Failed to inspect handler.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
return web.json_response(
data=result,
status=web.HTTPBadRequest.status_code if isinstance(result, TaskFailure) else web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/tasks/", "tasks")
async def tasks(encoder: Encoder) -> Response:
"""

207
app/tests/test_ffprobe.py Normal file
View file

@ -0,0 +1,207 @@
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
class TestFFProbe:
"""Test the ffprobe module functionality."""
def setup_method(self):
"""Set up test files."""
self.temp_dir = tempfile.mkdtemp()
self.test_file = Path(self.temp_dir) / "test_video.mp4"
self.test_file.touch()
def teardown_method(self):
"""Clean up test files."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
@pytest.mark.asyncio
async def test_ffprobe_with_existing_file(self):
"""Test ffprobe with an existing file."""
from app.library.ffprobe import FFProbeResult, ffprobe
# Mock subprocess to avoid actual ffprobe execution
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
# Mock the subprocess result
mock_process = AsyncMock()
mock_process.wait.return_value = 0
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}}', b"")
mock_subprocess.return_value = mock_process
with patch("anyio.open_file") as mock_open_file:
mock_file = AsyncMock()
mock_open_file.return_value.__aenter__.return_value = mock_file
result = await ffprobe(str(self.test_file))
assert isinstance(result, FFProbeResult)
@pytest.mark.asyncio
async def test_ffprobe_with_nonexistent_file(self):
"""Test ffprobe with a non-existent file."""
from app.library.ffprobe import ffprobe
nonexistent_file = Path(self.temp_dir) / "does_not_exist.mp4"
with pytest.raises(OSError, match="No such media file"):
await ffprobe(str(nonexistent_file))
@pytest.mark.asyncio
async def test_ffprobe_caching_behavior(self):
"""Test that ffprobe results are cached with enhanced async timed_lru_cache."""
from app.library.ffprobe import ffprobe
# Test that the function has been decorated with caching
assert hasattr(ffprobe, "cache_clear"), "ffprobe should have cache_clear method from timed_lru_cache"
assert hasattr(ffprobe, "cache_info"), "ffprobe should have cache_info method from timed_lru_cache"
# Clear cache to start fresh
ffprobe.cache_clear()
# Mock subprocess to avoid actual ffprobe execution
call_count = 0
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
def create_mock_process(*_args, **_kwargs):
nonlocal call_count
call_count += 1
mock_process = AsyncMock()
mock_process.wait.return_value = 0
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}, "streams": []}', b"")
return mock_process
mock_subprocess.side_effect = create_mock_process
with patch("anyio.open_file") as mock_open_file:
mock_file = AsyncMock()
mock_open_file.return_value.__aenter__.return_value = mock_file
# First call should execute the function
result1 = await ffprobe(str(self.test_file))
assert result1 is not None
assert isinstance(result1.metadata, dict)
first_call_count = call_count
# Second call with same argument should use cached result
result2 = await ffprobe(str(self.test_file))
assert result2 is not None
assert isinstance(result2.metadata, dict)
# The subprocess should not be called again for the actual ffprobe execution
# (it may be called for the -h check, but the main execution should be cached)
assert call_count == first_call_count, "Second call should use cached result"
# Results should be equivalent (same data, may not be same object due to async nature)
assert result1.metadata == result2.metadata
@pytest.mark.asyncio
async def test_ffprobe_with_path_object(self):
"""Test ffprobe with Path object input."""
from app.library.ffprobe import ffprobe
# Mock subprocess to avoid actual ffprobe execution
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
mock_process = AsyncMock()
mock_process.wait.return_value = 0
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}}', b"")
mock_subprocess.return_value = mock_process
with patch("anyio.open_file") as mock_open_file:
mock_file = AsyncMock()
mock_open_file.return_value.__aenter__.return_value = mock_file
# Test with Path object
result = await ffprobe(self.test_file)
assert result is not None
def test_ffprobe_result_properties(self):
"""Test FFProbeResult object properties."""
from app.library.ffprobe import FFProbeResult, FFStream
result = FFProbeResult()
# Test empty result
assert result.video == []
assert result.audio == []
assert result.subtitle == []
assert result.attachment == []
assert result.metadata == {}
# Test adding streams
video_stream = FFStream({
"index": 0,
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080
})
audio_stream = FFStream({
"index": 1,
"codec_type": "audio",
"codec_name": "aac",
"channels": 2
})
result.video.append(video_stream)
result.audio.append(audio_stream)
assert len(result.video) == 1
assert len(result.audio) == 1
assert result.video[0].is_video()
assert result.audio[0].is_audio()
def test_stream_object_methods(self):
"""Test Stream object methods."""
from app.library.ffprobe import FFStream
# Test video stream
video_stream = FFStream({
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080,
"duration": "10.5"
})
assert video_stream.is_video()
assert not video_stream.is_audio()
assert not video_stream.is_subtitle()
assert not video_stream.is_attachment()
# Test audio stream
audio_stream = FFStream({
"codec_type": "audio",
"codec_name": "aac",
"channels": 2,
"sample_rate": "44100"
})
assert not audio_stream.is_video()
assert audio_stream.is_audio()
assert not audio_stream.is_subtitle()
assert not audio_stream.is_attachment()
# Test subtitle stream
subtitle_stream = FFStream({
"codec_type": "subtitle",
"codec_name": "subrip"
})
assert not subtitle_stream.is_video()
assert not subtitle_stream.is_audio()
assert subtitle_stream.is_subtitle()
assert not subtitle_stream.is_attachment()
# Test attachment stream
attachment_stream = FFStream({
"codec_type": "attachment",
"codec_name": "ttf"
})
assert not attachment_stream.is_video()
assert not attachment_stream.is_audio()
assert not attachment_stream.is_subtitle()
assert attachment_stream.is_attachment()

View file

@ -0,0 +1,356 @@
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.library.task_handlers.generic import (
ContainerDefinition,
EngineConfig,
ExtractionRule,
GenericTaskHandler,
RequestConfig,
ResponseConfig,
TaskDefinition,
load_task_definitions,
)
from app.library.Tasks import Task, TaskFailure, TaskResult
@pytest.fixture(autouse=True)
def reset_generic_handler(monkeypatch):
monkeypatch.setattr(GenericTaskHandler, "_definitions", [])
monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {})
def test_load_task_definitions_parses_valid_file(tmp_path: Path):
tasks_dir = tmp_path / "tasks"
tasks_dir.mkdir()
definition_content = {
"name": "example",
"match": ["https://example.com/articles/*"],
"parse": {
"link": {"type": "css", "expression": ".article a.link::attr(href)"},
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
},
}
(tasks_dir / "01-example.json").write_text(json.dumps(definition_content), encoding="utf-8")
config = SimpleNamespace(config_path=str(tmp_path))
definitions = load_task_definitions(config=config)
assert len(definitions) == 1
definition = definitions[0]
assert definition.name == "example"
assert definition.matchers[0].matches("https://example.com/articles/123")
assert definition.parsers["link"].expression == ".article a.link::attr(href)"
def test_load_task_definitions_handles_container(tmp_path: Path):
tasks_dir = tmp_path / "tasks"
tasks_dir.mkdir()
definition_content = {
"name": "container",
"match": ["https://example.com/cards"],
"parse": {
"items": {
"selector": ".cards .card",
"fields": {
"link": {"type": "css", "expression": ".card-header a", "attribute": "href"},
"title": {"type": "css", "expression": ".card-header a", "attribute": "text"},
},
}
},
}
(tasks_dir / "02-container.json").write_text(json.dumps(definition_content), encoding="utf-8")
config = SimpleNamespace(config_path=str(tmp_path))
definitions = load_task_definitions(config=config)
assert len(definitions) == 1
definition = definitions[0]
assert definition.container is not None
assert definition.container.selector == ".cards .card"
assert "link" in definition.container.fields
assert definition.parsers == {}
def test_load_task_definitions_handles_json(tmp_path: Path):
tasks_dir = tmp_path / "tasks"
tasks_dir.mkdir()
definition_content = {
"name": "json-def",
"match": ["https://example.com/api"],
"response": {"type": "json"},
"parse": {
"items": {
"type": "jsonpath",
"selector": "items",
"fields": {
"link": {"type": "jsonpath", "expression": "url"},
"title": {"type": "jsonpath", "expression": "title"},
},
}
},
}
(tasks_dir / "03-json.json").write_text(json.dumps(definition_content), encoding="utf-8")
config = SimpleNamespace(config_path=str(tmp_path))
definitions = load_task_definitions(config=config)
assert len(definitions) == 1
definition = definitions[0]
assert definition.response.format == "json"
assert definition.container is not None
assert definition.container.selector_type == "jsonpath"
assert definition.container.fields["link"].type == "jsonpath"
def test_parse_items_extracts_values():
definition = TaskDefinition(
name="example",
source=Path("example.json"),
matchers=[],
engine=EngineConfig(),
request=RequestConfig(),
parsers={
"link": ExtractionRule(type="css", expression=".article a.link::attr(href)", attribute=None),
"title": ExtractionRule(type="css", expression=".article .title", attribute="text"),
"id": ExtractionRule(type="css", expression=".article", attribute="data-id"),
},
)
html = """
<div class="article" data-id="101">
<a class="link" href="/article-101">First</a>
<span class="title">First Title</span>
</div>
<div class="article" data-id="102">
<a class="link" href="https://example.com/article-102">Second</a>
<span class="title">Second Title</span>
</div>
"""
items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/")
assert len(items) == 2
assert items[0]["link"] == "https://example.com/article-101"
assert items[0]["title"] == "First Title"
assert items[0]["id"] == "101"
assert items[1]["link"] == "https://example.com/article-102"
def test_parse_items_handles_nested_card_layout():
definition = TaskDefinition(
name="nested",
source=Path("nested.json"),
matchers=[],
engine=EngineConfig(),
request=RequestConfig(),
parsers={},
container=ContainerDefinition(
selector_type="css",
selector=".columns .card",
fields={
"link": ExtractionRule(
type="css",
expression=".card-header a[href]",
attribute="href",
),
"title": ExtractionRule(
type="css",
expression=".card-header a[href]",
attribute="text",
),
"poet": ExtractionRule(
type="css",
expression="footer .card-footer-item:first-child a",
attribute="text",
),
"category": ExtractionRule(
type="css",
expression="footer .card-footer-item:nth-child(2) a",
attribute="text",
),
},
),
)
html = """
<div class="columns is-multiline">
<div class="column is-6">
<div class="card">
<div class="card-header">
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
<a href="/poems/view/111" title="First Poem">First Poem</a>
</p>
</div>
<footer class="card-footer has-text-centered">
<p class="card-footer-item text-truncate">
<span class="text-truncate"> By <a href="/poet/alpha">Poet Alpha</a></span>
</p>
<p class="card-footer-item text-truncate">
<span class="text-truncate"> In <a href="/category/one">Category One</a></span>
</p>
</footer>
</div>
</div>
<div class="column is-6">
<div class="card">
<div class="card-header">
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
<a href="/poems/view/222" title="Second Poem">Second Poem</a>
</p>
</div>
<footer class="card-footer has-text-centered">
<p class="card-footer-item text-truncate">
<span class="text-truncate"> By <a href="/poet/beta">Poet Beta</a></span>
</p>
</footer>
</div>
</div>
</div>
"""
items = GenericTaskHandler._parse_items(definition, html, "https://example.com")
assert len(items) == 2
assert items[0]["link"] == "https://example.com/poems/view/111"
assert items[0]["title"] == "First Poem"
assert items[0]["poet"] == "Poet Alpha"
assert items[0]["category"] == "Category One"
assert items[1]["link"] == "https://example.com/poems/view/222"
assert items[1]["title"] == "Second Poem"
assert items[1]["poet"] == "Poet Beta"
assert "category" not in items[1]
def test_parse_items_handles_json_container():
definition = TaskDefinition(
name="json",
source=Path("json.json"),
matchers=[],
engine=EngineConfig(),
request=RequestConfig(),
parsers={},
container=ContainerDefinition(
selector_type="jsonpath",
selector="entries",
fields={
"link": ExtractionRule(type="jsonpath", expression="url"),
"title": ExtractionRule(type="jsonpath", expression="title"),
"id": ExtractionRule(type="jsonpath", expression="id"),
},
),
response=ResponseConfig(format="json"),
)
payload = {
"entries": [
{"url": "/video/1", "title": "First", "id": 1},
{"url": "https://example.com/video/2", "title": "Second", "id": 2},
{"title": "Missing Link", "id": 3},
]
}
items = GenericTaskHandler._parse_items(
definition=definition,
html="",
base_url="https://example.com",
json_data=payload,
)
assert len(items) == 2
assert items[0]["link"] == "https://example.com/video/1"
assert items[0]["title"] == "First"
assert items[0]["id"] == "1"
assert items[1]["link"] == "https://example.com/video/2"
assert items[1]["title"] == "Second"
assert items[1]["id"] == "2"
@pytest.mark.asyncio
async def test_generic_task_handler_inspect(monkeypatch):
definition = TaskDefinition(
name="json-inspect",
source=Path("json-inspect.json"),
matchers=[],
engine=EngineConfig(),
request=RequestConfig(),
parsers={},
container=ContainerDefinition(
selector_type="jsonpath",
selector="items",
fields={
"link": ExtractionRule(type="jsonpath", expression="url"),
"title": ExtractionRule(type="jsonpath", expression="title"),
},
),
response=ResponseConfig(format="json"),
)
monkeypatch.setattr(
GenericTaskHandler,
"_find_definition",
classmethod(lambda cls, url: definition), # noqa: ARG005
)
async def fake_fetch_content(url, definition, ytdlp_opts): # noqa: ARG001
return "", {"items": [{"url": "/video/1", "title": "First"}]}
monkeypatch.setattr(GenericTaskHandler, "_fetch_content", staticmethod(fake_fetch_content))
task = Task(id="inspect", name="Inspect", url="https://example.com/api")
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 1
item = result.items[0]
assert item.url == "https://example.com/video/1"
assert item.title == "First"
def test_parse_items_handles_json_top_level_list():
definition = TaskDefinition(
name="json-list",
source=Path("json-list.json"),
matchers=[],
engine=EngineConfig(),
request=RequestConfig(),
parsers={},
container=ContainerDefinition(
selector_type="jsonpath",
selector="[]",
fields={
"link": ExtractionRule(type="jsonpath", expression="url"),
"title": ExtractionRule(type="jsonpath", expression="title"),
},
),
response=ResponseConfig(format="json"),
)
payload = [
{"url": "/video/1", "title": "First"},
{"url": "/video/2", "title": "Second"},
]
items = GenericTaskHandler._parse_items(
definition=definition,
html="",
base_url="https://example.com",
json_data=payload,
)
assert len(items) == 2
assert items[0]["link"] == "https://example.com/video/1"
assert items[0]["title"] == "First"
assert items[1]["link"] == "https://example.com/video/2"
assert items[1]["title"] == "Second"

View file

@ -1,4 +1,5 @@
import json
from pathlib import Path
from unittest.mock import patch
import pytest
@ -165,3 +166,116 @@ class TestItemDTO:
ok2 = dto.archive_delete()
assert ok2 is True
m_del.assert_called_once()
def test_get_file_method(self):
"""Test ItemDTO get_file method returns correct path."""
# Create ItemDTO with filename but no folder
dto = ItemDTO(
id="test-id",
title="Test Video",
url="https://youtube.com/watch?v=test123",
folder="",
status="finished",
filename="test_video.mp4",
)
# Test with no filename returns None
dto_no_file = ItemDTO(
id="test-id-2",
title="Test Video 2",
url="https://youtube.com/watch?v=test456",
folder="",
status="finished",
)
assert dto_no_file.get_file() is None
# Mock get_file function to return success (without custom download_path)
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
mock_get_file.return_value = ("/downloads/test_video.mp4", 200)
mock_config.get_instance.return_value.download_path = "/downloads"
result = dto.get_file()
assert result == Path("/downloads/test_video.mp4")
# Test with folder
dto_with_folder = ItemDTO(
id="test-id-3",
title="Test Video 3",
url="https://youtube.com/watch?v=test789",
folder="media",
status="finished",
filename="test_video.mp4",
)
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
mock_get_file.return_value = ("/downloads/media/test_video.mp4", 200)
mock_config.get_instance.return_value.download_path = "/downloads"
result = dto_with_folder.get_file()
assert result == Path("/downloads/media/test_video.mp4")
# Test with file not found
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
mock_get_file.return_value = ("/downloads/test_video.mp4", 404)
mock_config.get_instance.return_value.download_path = "/downloads"
result = dto.get_file()
assert result is None
# Test with exception during file access
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
mock_get_file.side_effect = ValueError("File path error")
mock_config.get_instance.return_value.download_path = "/downloads"
result = dto.get_file()
assert result is None
# Test with custom download_path parameter (Config not imported in this case)
with patch("app.library.ItemDTO.get_file") as mock_get_file:
mock_get_file.return_value = ("/custom/test_video.mp4", 200)
result = dto.get_file(download_path=Path("/custom"))
assert result == Path("/custom/test_video.mp4")
def test_get_file_sidecar_populates_from_utils(self):
with patch.object(ItemDTO, "__post_init__", lambda _: None):
dto = ItemDTO(id="sidecar", title="Title", url="u", folder="f")
expected_sidecar = {
"subtitle": [
{
"file": Path("/downloads/video.en.srt"),
"lang": "en",
"name": "SRT (0) - en",
}
]
}
with (
patch("app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=Path("/downloads/video.mp4")) as mock_get_file,
patch("app.library.ItemDTO.get_file_sidecar", return_value=expected_sidecar) as mock_utils_sidecar,
):
result = dto.get_file_sidecar()
mock_get_file.assert_called_once_with(dto)
mock_utils_sidecar.assert_called_once_with(Path("/downloads/video.mp4"))
assert result is expected_sidecar
assert dto.sidecar is expected_sidecar
def test_get_file_sidecar_returns_existing_when_no_file(self):
with patch.object(ItemDTO, "__post_init__", lambda _: None):
dto = ItemDTO(id="sidecar-none", title="Title", url="u", folder="f")
existing = {"existing": []}
dto.sidecar = existing
with (
patch("app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=None) as mock_get_file,
patch("app.library.ItemDTO.get_file_sidecar") as mock_utils_sidecar,
):
result = dto.get_file_sidecar()
mock_get_file.assert_called_once_with(dto)
mock_utils_sidecar.assert_not_called()
assert result is existing
assert dto.sidecar is existing

View file

@ -1003,87 +1003,6 @@ class TestNotification:
assert result is None
mock_worker_instance.submit.assert_called_once_with(notification.send, event)
def test_deep_unpack_simple_dict(self):
"""Test _deep_unpack method with simple dictionary."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
data = {"key1": "value1", "key2": 123}
result = notification._deep_unpack(data)
assert result == {"key1": "value1", "key2": 123}
def test_deep_unpack_nested_dict(self):
"""Test _deep_unpack method with nested dictionary."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
data = {
"level1": {
"level2": {
"value": "nested"
}
}
}
result = notification._deep_unpack(data)
assert result["level1"]["level2"]["value"] == "nested"
def test_deep_unpack_with_datetime(self):
"""Test _deep_unpack method with datetime objects."""
from datetime import UTC, datetime
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
test_datetime = datetime.now(tz=UTC)
data = {"timestamp": test_datetime}
result = notification._deep_unpack(data)
assert result["timestamp"] == test_datetime.isoformat()
def test_deep_unpack_with_serializable_object(self):
"""Test _deep_unpack method with object having serialize method."""
with patch("app.library.Notifications.Config") as mock_config, \
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
mock_config.get_instance.return_value = Mock(
debug=False, config_path="/tmp", app_version="1.0.0"
)
mock_worker.get_instance.return_value = Mock()
notification = Notification.get_instance()
# Mock object with serialize method
mock_obj = Mock()
mock_obj.serialize.return_value = {"serialized": "data"}
data = {"object": mock_obj}
result = notification._deep_unpack(data)
assert result["object"] == {"serialized": "data"}
@pytest.mark.asyncio
async def test_noop(self):
"""Test noop method."""

View file

@ -0,0 +1,57 @@
import pytest
from app.library.task_handlers.twitch import TwitchHandler
from app.library.Tasks import Task, TaskResult
class DummyResponse:
def __init__(self, text: str):
self.text = text
def raise_for_status(self) -> None:
return None
class DummyOpts:
def __init__(self, data):
self._data = data
def get_all(self):
return self._data
@pytest.mark.asyncio
async def test_twitch_handler_inspect(monkeypatch):
feed = """
<rss><channel>
<item>
<link>https://www.twitch.tv/videos/111</link>
<title>First VOD</title>
</item>
<item>
<link>https://www.twitch.tv/videos/222</link>
<title>Second VOD</title>
</item>
</channel></rss>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(feed)
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="inspect",
name="Inspect",
url="https://www.twitch.tv/testchannel",
preset="default",
)
result = await TwitchHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 2
assert result.items[0].url == "https://www.twitch.tv/videos/111"
assert result.items[0].title == "First VOD"
assert result.metadata.get("has_entries") is True

View file

@ -44,6 +44,7 @@ from app.library.Utils import (
str_to_dt,
strip_newline,
tail_log,
timed_lru_cache,
validate_url,
validate_uuid,
ytdlp_reject,
@ -65,6 +66,233 @@ class TestStreamingError:
assert isinstance(error, Exception)
class TestTimedLruCache:
"""Test the timed_lru_cache decorator."""
def test_timed_lru_cache_basic_functionality(self):
"""Test that timed_lru_cache caches function results."""
call_count = 0
@timed_lru_cache(ttl_seconds=60, max_size=10)
def test_function(x):
nonlocal call_count
call_count += 1
return x * 2
# First call should execute the function
result1 = test_function(5)
assert result1 == 10
assert call_count == 1
# Second call with same argument should return cached result
result2 = test_function(5)
assert result2 == 10
assert call_count == 1 # Should not increment
# Different argument should execute the function again
result3 = test_function(10)
assert result3 == 20
assert call_count == 2
def test_timed_lru_cache_expiration(self):
"""Test that cache expires after TTL."""
import time
call_count = 0
@timed_lru_cache(ttl_seconds=1, max_size=10) # 1 second TTL
def test_function(x):
nonlocal call_count
call_count += 1
return x * 2
# First call
result1 = test_function(5)
assert result1 == 10
assert call_count == 1
# Second call immediately should be cached
result2 = test_function(5)
assert result2 == 10
assert call_count == 1
# Wait for cache to expire
time.sleep(1.1)
# Call after expiration should execute function again
result3 = test_function(5)
assert result3 == 10
assert call_count == 2
def test_timed_lru_cache_methods_exposed(self):
"""Test that cache_clear and cache_info methods are exposed."""
from app.library.Utils import timed_lru_cache
@timed_lru_cache(ttl_seconds=60, max_size=10)
def test_function(x):
return x * 2
# Test that methods exist
assert hasattr(test_function, "cache_clear")
assert hasattr(test_function, "cache_info")
# Call function to populate cache
test_function(5)
# Get cache info
info = test_function.cache_info()
assert info.hits == 0
assert info.misses == 1
# Call again to test hit
test_function(5)
info = test_function.cache_info()
assert info.hits == 1
assert info.misses == 1
# Clear cache
test_function.cache_clear()
info = test_function.cache_info()
assert info.hits == 0
assert info.misses == 0
def test_timed_lru_cache_max_size(self):
"""Test that cache respects max_size limit."""
from app.library.Utils import timed_lru_cache
@timed_lru_cache(ttl_seconds=60, max_size=2)
def test_function(x):
return x * 2
# Fill cache to max size
test_function(1)
test_function(2)
info = test_function.cache_info()
assert info.misses == 2
# Adding another item should not exceed max size
test_function(3)
info = test_function.cache_info()
assert info.misses == 3
# Test that earlier entries may be evicted
test_function(1) # This might be a cache miss due to LRU eviction
class TestAsyncTimedLruCache:
"""Test async functionality of timed_lru_cache decorator."""
@pytest.mark.asyncio
async def test_async_timed_lru_cache_basic(self):
"""Test basic async caching functionality."""
from app.library.Utils import timed_lru_cache
call_count = 0
@timed_lru_cache(ttl_seconds=300, max_size=128)
async def async_test_func(x):
nonlocal call_count
call_count += 1
return x * 2
# First call should execute the function
result1 = await async_test_func(5)
assert result1 == 10
assert call_count == 1
# Second call should use cached result
result2 = await async_test_func(5)
assert result2 == 10
assert call_count == 1 # Should not increment
# Different argument should execute function again
result3 = await async_test_func(3)
assert result3 == 6
assert call_count == 2
@pytest.mark.asyncio
async def test_async_timed_lru_cache_expiry(self):
"""Test that async cache entries expire after TTL."""
from app.library.Utils import timed_lru_cache
call_count = 0
@timed_lru_cache(ttl_seconds=0.1, max_size=128) # 100ms TTL
async def async_expire_func(x):
nonlocal call_count
call_count += 1
return x * 3
# First call
result1 = await async_expire_func(2)
assert result1 == 6
assert call_count == 1
# Immediate second call should use cache
result2 = await async_expire_func(2)
assert result2 == 6
assert call_count == 1
# Wait for cache to expire
await asyncio.sleep(0.15)
# Third call should execute function again due to expiry
result3 = await async_expire_func(2)
assert result3 == 6
assert call_count == 2
@pytest.mark.asyncio
async def test_async_cache_methods(self):
"""Test that async cached functions expose cache management methods."""
from app.library.Utils import timed_lru_cache
@timed_lru_cache(ttl_seconds=300, max_size=128)
async def async_method_test(x):
return x + 1
# Test that cache methods exist
assert hasattr(async_method_test, "cache_clear")
assert hasattr(async_method_test, "cache_info")
# Test cache_info
info = async_method_test.cache_info()
assert hasattr(info, "hits")
assert hasattr(info, "misses")
assert hasattr(info, "maxsize")
assert hasattr(info, "currsize")
assert info.maxsize == 128
# Test cache_clear
await async_method_test(1)
async_method_test.cache_clear()
info_after_clear = async_method_test.cache_info()
assert info_after_clear.currsize == 0
@pytest.mark.asyncio
async def test_async_cache_max_size(self):
"""Test that async cache respects max_size parameter."""
from app.library.Utils import timed_lru_cache
@timed_lru_cache(ttl_seconds=300, max_size=2)
async def async_limited_func(x):
return x * 4
# Fill cache beyond max_size
result1 = await async_limited_func(1)
result2 = await async_limited_func(2)
result3 = await async_limited_func(3) # Should evict oldest entry
# Verify results
assert result1 == 4
assert result2 == 8
assert result3 == 12
# Check cache size is limited
info = async_limited_func.cache_info()
assert info.currsize <= 2
class TestFileLogFormatter:
"""Test the FileLogFormatter class."""

View file

@ -0,0 +1,61 @@
import pytest
from app.library.task_handlers.youtube import YoutubeHandler
from app.library.Tasks import Task, TaskResult
class DummyResponse:
def __init__(self, text: str):
self.text = text
def raise_for_status(self) -> None:
return None
class DummyOpts:
def __init__(self, data):
self._data = data
def get_all(self):
return self._data
@pytest.mark.asyncio
async def test_youtube_handler_inspect(monkeypatch):
feed = """
<feed xmlns='http://www.w3.org/2005/Atom' xmlns:yt='http://www.youtube.com/xml/schemas/2015'>
<entry>
<yt:videoId>abc123</yt:videoId>
<title>First Video</title>
<published>2024-01-01T00:00:00Z</published>
</entry>
<entry>
<yt:videoId>def456</yt:videoId>
<title>Second Video</title>
<published>2024-01-02T00:00:00Z</published>
</entry>
</feed>
""".strip()
async def fake_request(**kwargs): # noqa: ARG001
return DummyResponse(feed)
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="inspect",
name="Inspect",
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
preset="default",
)
result = await YoutubeHandler.extract(task)
assert isinstance(result, TaskResult)
assert len(result.items) == 2
first = result.items[0]
assert first.url == "https://www.youtube.com/watch?v=abc123"
assert first.title == "First Video"
assert first.metadata.get("published") == "2024-01-01T00:00:00Z"
assert result.metadata.get("entry_count") == 2

View file

@ -40,6 +40,9 @@ dependencies = [
"bgutil-ytdlp-pot-provider>=1.2.1",
"pycryptodome>=3.23.0",
"httpx-curl-cffi>=0.1.4",
"selenium>=4.35.0",
"parsel>=1.10.0",
"jmespath>=1.0.1",
]
[tool.ruff]

View file

@ -44,7 +44,7 @@
</p>
<!-- prompt input -->
<div v-if="state.current?.type === 'prompt'" class="field">
<div v-if="'prompt' === state.current?.type" class="field">
<div class="control">
<input ref="inputEl" class="input" type="text" v-model="localInput"
:placeholder="(state.current?.opts as any)?.placeholder ?? ''" @keyup.stop />
@ -61,8 +61,8 @@
</section>
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
<template v-if="state.current?.type === 'alert'">
<button class="button is-danger" @click="onEnter">
<template v-if="'alert' === state.current?.type">
<button id="primaryButton" class="button is-danger" @click="onEnter">
<span class="icon-text">
<span class="icon"><i class="fas fa-check" /></span>
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
@ -70,10 +70,11 @@
</button>
</template>
<template v-else-if="state.current?.type === 'confirm' || state.current?.type === 'prompt'">
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type">
<div class="field is-grouped">
<div class="control">
<button class="button" @click="onEnter" :class="state.current?.opts.confirmColor ?? 'is-primary'"
<button id="primaryButton" class="button" @click="onEnter"
:class="state.current?.opts.confirmColor ?? 'is-primary'"
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial">
<span class="icon-text">
<span class="icon"><i class="fas fa-check" /></span>
@ -114,7 +115,7 @@ watch(() => state.current, (cur) => {
enableOpacity()
}
localInput.value = cur?.type === 'prompt' ? (cur.opts as any).initial ?? '' : ''
localInput.value = 'prompt' === cur?.type ? (cur.opts as any).initial ?? '' : ''
}, { immediate: true })
const inputEl = ref<HTMLInputElement>()
@ -123,12 +124,12 @@ const focusPrimary = () => {
if (!root) {
return
}
const btn = root.querySelector<HTMLButtonElement>('.modal-card-foot .button.is-primary')
const btn = root.querySelector<HTMLButtonElement>('#primaryButton')
btn?.focus()
}
const focusInput = async () => {
await nextTick()
if (state.current?.type === 'prompt') {
if ('prompt' === state.current?.type) {
requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true }))
return
}

View file

@ -117,10 +117,8 @@
{{ formatTime(item.extras.duration) }}
</span>
</div>
<div v-if="showThumbnails && item.extras.thumbnail">
<FloatingImage
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
:title="`[${item.preset}] - ${item.title}`">
<div v-if="showThumbnails && getImage(item)">
<FloatingImage :image="getImage(item)" :title="`[${item.preset}] - ${item.title}`">
<div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
</div>
@ -272,22 +270,17 @@
<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: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
<img @load="(e: Event) => pImg(e)" :src="getImage(item)" v-if="getImage(item)" />
<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: Event) => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
<img @load="(e: Event) => pImg(e)" :src="getImage(item)" v-if="getImage(item)" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img @load="(e: Event) => pImg(e)" v-if="getImage(item)" :src="getImage(item)" />
<img v-else src="/images/placeholder.png" />
</template>
</figure>
@ -912,6 +905,18 @@ const makePath = (item: StoreItem) => {
return ''
}
const real_path = eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/')
return real_path.replace(config.app.download_path, '').replace(/^\//, '')
return stripPath(config.app.download_path, real_path)
}
const getImage = (item: StoreItem): string => {
if (item.sidecar?.image && item.sidecar.image.length > 0) {
return uri('/api/download/' + encodeURIComponent(stripPath(config.app.download_path, item.sidecar.image[0]?.file || '')))
}
if (!item?.extras?.thumbnail) {
return '/images/placeholder.png'
}
return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))
}
</script>

View file

@ -1,4 +1,14 @@
type ItemStatus = 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | 'skip' | null;
type SideCar = {
file: string
}
type sideCarSubtitle = SideCar & {
lang: string
name: string
}
type StoreItem = {
/** Unique identifier for the item */
_id: string
@ -42,6 +52,12 @@ type StoreItem = {
auto_start: boolean
/** Options for the item */
options: Record<string, unknown>
/** Sidecar associated with the item. */
sidecar: {
Unknown?: Array<SideCar>
subtitle?: Array<sideCarSubtitle>
image?: Array<SideCar>
},
/** Extras for the item */
extras: {
/** Which channel the item belongs to */

View file

@ -677,10 +677,18 @@ const enableOpacity = (): boolean => {
return true
}
const stripPath = (base_path: string, real_path: string): string => {
if (!base_path) {
return real_path
}
return real_path.replace(base_path, '').replace(/^\//, '')
}
export {
separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime,
sleep, awaiter, encode, decode, disableOpacity, enableOpacity
sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath
}

View file

@ -23,21 +23,21 @@
"@vueuse/nuxt": "^13.9.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.1",
"cron-parser": "^5.4.0",
"cronstrue": "^3.3.0",
"floating-vue": "^5.2.2",
"hls.js": "^1.6.12",
"marked": "^16.3.0",
"marked-alert": "^2.1.2",
"marked-base-url": "^1.1.7",
"marked-gfm-heading-id": "^4.1.2",
"moment": "^2.30.1",
"nuxt": "^4.1.2",
"pinia": "^3.0.3",
"socket.io-client": "^4.8.1",
"vue": "^3.5.21",
"vue-router": "^4.5.1",
"vue-toastification": "2.0.0-rc.5",
"marked": "^16.2.1",
"marked-alert": "^2.1.2",
"marked-base-url": "^1.1.7",
"marked-gfm-heading-id": "^4.1.2"
"vue-toastification": "2.0.0-rc.5"
},
"pnpm": {
"onlyBuiltDependencies": [
@ -54,7 +54,7 @@
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"@nuxt/eslint-config": "^1.9.0",
"@typescript-eslint/parser": "^8.43.0",
"@typescript-eslint/parser": "^8.44.0",
"eslint": "^9.35.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",

File diff suppressed because it is too large Load diff

1
ui/pnpm-workspace.yaml Normal file
View file

@ -0,0 +1 @@
minimumReleaseAge: 4320

View file

@ -0,0 +1,550 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import type { MockInstance } from 'vitest'
type StorageEntry = { value: unknown }
const notificationMock = {
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
error: vi.fn(),
notify: vi.fn(),
}
const runtimeConfig = {
app: {
baseURL: '/base-path',
},
}
vi.mock('#imports', () => ({
useRuntimeConfig: vi.fn(() => runtimeConfig),
useNotification: vi.fn(() => notificationMock),
}))
// Mock the global Nuxt composables since they auto-import
vi.stubGlobal('useRuntimeConfig', vi.fn(() => runtimeConfig))
vi.stubGlobal('useNotification', vi.fn(() => notificationMock))
const storageMap = new Map<string, StorageEntry | unknown>()
const useStorageFn = vi.fn(<T>(key: string, defaultValue: T) => {
if (!storageMap.has(key)) {
storageMap.set(key, { value: defaultValue })
}
return storageMap.get(key)
})
vi.mock('@vueuse/core', () => ({
useStorage: useStorageFn,
}))
const clipboardWriteMock = vi.fn(() => Promise.resolve())
const fetchMock = vi.fn()
const getRandomValuesMock = vi.fn((buffer: Uint8Array) => {
buffer.fill(1)
return buffer
})
const originalFetch = globalThis.fetch
const originalClipboard = globalThis.navigator?.clipboard
const originalCrypto = globalThis.crypto
let utils: Awaited<typeof import('../../app/utils/index')>
let fetchSpy: MockInstance | undefined
const resetStorage = () => {
storageMap.clear()
storageMap.set('random_bg', { value: true })
storageMap.set('random_bg_opacity', { value: 0.95 })
}
// Minimal DOM/window/custom event shims
type Listener = (evt: { type: string; detail?: any }) => void
const listeners = new Map<string, Listener[]>()
const win: any = {
addEventListener: (type: string, cb: Listener) => {
const arr = listeners.get(type) ?? []
arr.push(cb)
listeners.set(type, arr)
},
removeEventListener: (type: string, cb: Listener) => {
const arr = listeners.get(type) ?? []
listeners.set(type, arr.filter(x => x !== cb))
},
dispatchEvent: (evt: { type: string; detail?: any }) => {
const arr = listeners.get(evt.type) ?? []
arr.forEach(cb => cb(evt))
return true
},
focus: () => {},
navigator: {},
}
globalThis.window = win as unknown as Window & typeof globalThis
// Ensure global navigator is present
globalThis.navigator = win.navigator as Navigator
class MiniCustomEvent<T = any> {
type: string
detail?: T
constructor(type: string, init?: { detail?: T }) {
this.type = type
this.detail = init?.detail
}
}
globalThis.CustomEvent = MiniCustomEvent as unknown as typeof CustomEvent
class ClassList {
private set = new Set<string>()
contains = (c: string) => this.set.has(c)
add = (c: string) => { this.set.add(c) }
remove = (c: string) => { this.set.delete(c) }
}
class FakeElement {
id = ''
classList = new ClassList()
private attrs = new Map<string, string>()
innerHTML = ''
setAttribute(k: string, v: string) { this.attrs.set(k, v) }
getAttribute(k: string) { return this.attrs.has(k) ? (this.attrs.get(k) as string) : null }
removeAttribute(k: string) { this.attrs.delete(k) }
}
const registry = new Map<string, FakeElement>()
const body = new FakeElement()
;(body as any).appendChild = (el: FakeElement) => {
if (el.id) registry.set(el.id, el)
}
const doc: any = {
body,
createElement: (_tag: string) => new FakeElement(),
querySelector: (sel: string) => {
if (sel === 'body') return body
if (sel.startsWith('#')) return registry.get(sel.slice(1)) ?? null
return null
},
execCommand: () => true,
}
globalThis.document = doc as unknown as Document
globalThis.HTMLElement = FakeElement as unknown as typeof HTMLElement
globalThis.Node = FakeElement as unknown as typeof Node
// btoa/atob polyfills
globalThis.btoa = globalThis.btoa ?? ((str: string) => Buffer.from(str, 'binary').toString('base64'))
globalThis.atob = globalThis.atob ?? ((b64: string) => Buffer.from(b64, 'base64').toString('binary'))
beforeAll(async () => {
// Import utils after all mocks are set up
utils = await import('../../app/utils/index')
})
beforeEach(() => {
resetStorage()
runtimeConfig.app.baseURL = '/base-path'
notificationMock.info.mockClear()
notificationMock.success.mockClear()
notificationMock.warning.mockClear()
notificationMock.error.mockClear()
notificationMock.notify.mockClear()
useStorageFn.mockClear()
fetchMock.mockReset()
clipboardWriteMock.mockReset()
getRandomValuesMock.mockClear()
if (typeof originalFetch === 'function') {
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(fetchMock)
} else {
;(globalThis as any).fetch = fetchMock
fetchSpy = undefined
}
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteMock },
})
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: { getRandomValues: getRandomValuesMock },
})
Object.defineProperty(window as any, 'crypto', {
configurable: true,
value: { getRandomValues: getRandomValuesMock },
})
})
afterEach(() => {
if (fetchSpy) {
fetchSpy.mockRestore()
} else if (!originalFetch) {
delete (globalThis as any).fetch
} else {
globalThis.fetch = originalFetch
}
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
})
} else {
delete (navigator as any).clipboard
}
Object.defineProperty(globalThis, 'crypto', {
configurable: true,
value: originalCrypto,
})
Object.defineProperty(window as any, 'crypto', {
configurable: true,
value: originalCrypto,
})
if (document.body) {
document.body.innerHTML = ''
document.body.removeAttribute('style')
}
})
// no afterAll needed for lightweight stubs
describe('utils/index setup', () => {
it('exposes core utilities after mocks initialize', () => {
expect(Array.isArray(utils.separators)).toBe(true)
expect(typeof utils.getValue).toBe('function')
})
})
describe('object access helpers', () => {
it('getValue resolves direct values and callables', () => {
expect(utils.getValue(5)).toBe(5)
expect(utils.getValue(() => 7)).toBe(7)
})
it('ag returns nested value or default value', () => {
const payload = { a: { b: { c: 42 } } }
expect(utils.ag(payload, 'a.b.c')).toBe(42)
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback')
expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default')
})
it('ag_set sets nested path creating objects as needed', () => {
const payload: Record<string, unknown> = {}
utils.ag_set(payload, 'a.b.c', 99)
expect(payload).toEqual({ a: { b: { c: 99 } } })
})
it('cleanObject removes requested keys', () => {
const source = { id: 1, keep: true, drop: false }
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true })
expect(utils.cleanObject(source, [])).toEqual(source)
})
it('stripPath removes base prefix and leading slashes', () => {
expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4')
expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt')
})
})
describe('string manipulation helpers', () => {
it('r replaces tokens with context values', () => {
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } })
expect(result).toBe('Hello YTPTube!')
})
it('iTrim trims delimiters at requested positions', () => {
expect(utils.iTrim('--value--', '-', 'both')).toBe('value')
expect(utils.iTrim('::value', ':', 'start')).toBe('value')
expect(utils.iTrim('value::', ':', 'end')).toBe('value')
})
it('eTrim and sTrim delegate to iTrim ends', () => {
expect(utils.eTrim('##name##', '#')).toBe('##name')
expect(utils.sTrim('##name##', '#')).toBe('name##')
})
it('ucFirst capitalizes first character', () => {
expect(utils.ucFirst('ytp')).toBe('Ytp')
expect(utils.ucFirst('')).toBe('')
})
it('encodePath safely encodes components', () => {
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4')
})
it('removeANSIColors strips escape codes', () => {
const sample = '\u001b[31mError\u001b[0m'
expect(utils.removeANSIColors(sample)).toBe('Error')
})
it('dec2hex converts to two character hex strings', () => {
expect(utils.dec2hex(15)).toBe('0f')
expect(utils.dec2hex(255)).toBe('ff')
})
it('basename returns final segment optionally trimming extension', () => {
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4')
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video')
expect(utils.basename('', '.mp4')).toBe('')
})
it('dirname returns parent directory', () => {
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads')
expect(utils.dirname('video.mp4')).toBe('.')
expect(utils.dirname('/file')).toBe('/')
})
it('formatBytes returns human readable strings', () => {
expect(utils.formatBytes(0)).toBe('0 Bytes')
expect(utils.formatBytes(1024)).toBe('1 KiB')
})
it('formatTime renders hh:mm:ss or mm:ss', () => {
expect(utils.formatTime(59)).toBe('59')
expect(utils.formatTime(90)).toBe('01:30')
expect(utils.formatTime(3661)).toBe('01:01:01')
})
it('getSeparatorsName returns human readable label', () => {
expect(utils.getSeparatorsName(',')).toContain('Comma')
expect(utils.getSeparatorsName('*')).toBe('Unknown')
})
})
describe('data conversion helpers', () => {
it('has_data detects arrays, objects, and json strings', () => {
expect(utils.has_data({ key: 'value' })).toBe(true)
expect(utils.has_data('""')).toBe(false)
expect(utils.has_data('[1,2]')).toBe(true)
expect(utils.has_data('')).toBe(false)
})
it('encode and decode provide reversible transformation', () => {
const payload = { name: 'YTPTube', count: 2 }
const encoded = utils.encode(payload)
expect(typeof encoded).toBe('string')
expect(utils.decode(encoded)).toEqual(payload)
})
it('makePagination builds a ranged pagination list', () => {
const pages = utils.makePagination(5, 10, 1)
const selected = pages.find((page: any) => page.selected)
expect(selected?.page).toBe(5)
expect(pages.length).toBeGreaterThan(0)
expect(pages[0]?.page).toBe(1)
expect(pages[pages.length - 1]?.page).toBe(10)
})
it('getQueryParams parses query strings', () => {
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' })
})
it('uri prefixes runtime base path', () => {
runtimeConfig.app.baseURL = '/base-path'
expect(utils.uri('/api/test')).toBe('/base-path/api/test')
runtimeConfig.app.baseURL = '/'
expect(utils.uri('/api/test')).toBe('/api/test')
})
it('makeDownload builds expected url with folder and filename', () => {
runtimeConfig.app.baseURL = '/base-path'
const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' })
expect(url).toBe('/base-path/api/download/music/song.mp3')
})
it('makeDownload handles m3u8 base path', () => {
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8')
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8')
})
})
describe('dom and browser helpers', () => {
it('awaitElement waits until element appears', async () => {
vi.useFakeTimers()
const callback = vi.fn()
utils.awaitElement('#dynamic', callback)
setTimeout(() => {
const el = document.createElement('div')
el.id = 'dynamic'
document.body.appendChild(el)
}, 50)
await vi.advanceTimersByTimeAsync(250)
expect(callback).toHaveBeenCalledTimes(1)
const callArgs = callback.mock.calls[0]
expect(callArgs).toBeDefined()
expect(Array.isArray(callArgs)).toBe(true)
const [element, selector] = callArgs!
expect((element as Element).id).toBe('dynamic')
expect(selector).toBe('#dynamic')
vi.useRealTimers()
})
it('dEvent dispatches custom event with detail payload', () => {
const detail = { foo: 'bar' }
let received: unknown = null
const listener = (event: Event) => {
received = (event as CustomEvent).detail
}
window.addEventListener('custom-detail', listener)
const dispatched = utils.dEvent('custom-detail', detail)
window.removeEventListener('custom-detail', listener)
expect(dispatched).toBe(true)
expect(received).toEqual(detail)
})
it('toggleClass adds and removes classes', () => {
const el = document.createElement('div')
utils.toggleClass(el, 'active')
expect(el.classList.contains('active')).toBe(true)
utils.toggleClass(el, 'active')
expect(el.classList.contains('active')).toBe(false)
})
it('copyText uses clipboard API and notifies success', async () => {
utils.copyText('sample')
await Promise.resolve()
expect(clipboardWriteMock).toHaveBeenCalledWith('sample')
await Promise.resolve()
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.')
})
it('disableOpacity toggles body opacity when enabled', () => {
const result = utils.disableOpacity()
expect(result).toBe(true)
expect(document.body.getAttribute('style')).toBe('opacity: 1.0')
})
it('disableOpacity returns false when background disabled', () => {
// Reset any previous style first
document.body.removeAttribute('style')
// The implementation has `if (!bg_enable)` where bg_enable is from useStorage
// Since all objects are truthy in JavaScript, this suggests there's a bug in the implementation
// or VueUse refs have special behavior. Let's test what the implementation actually does.
// Clear the storage and set up specific mocks for this test
storageMap.clear()
// Try returning `null` instead of an object - null is falsy
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
if (key === 'random_bg') {
return null // null is falsy, so !null will be true
}
// For other keys, return default storage behavior
if (!storageMap.has(key)) {
storageMap.set(key, { value: defaultValue })
}
return storageMap.get(key)
})
const result = utils.disableOpacity()
expect(result).toBe(false)
expect(document.body.getAttribute('style')).toBeNull()
})
it('enableOpacity applies stored opacity value', () => {
// Reset the useStorage mock for this test
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
if (!storageMap.has(key)) {
storageMap.set(key, { value: defaultValue })
}
return storageMap.get(key)
})
storageMap.set('random_bg_opacity', { value: 0.75 })
const result = utils.enableOpacity()
expect(result).toBe(true)
expect(document.body.getAttribute('style')).toBe('opacity: 0.75')
})
})
describe('network and id helpers', () => {
it('request prefixes relative urls and sets defaults', async () => {
const responseMock = { status: 200 } as Response
fetchMock.mockResolvedValue(responseMock)
const response = await utils.request('/api/test')
expect(response).toBe(responseMock)
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0]!
expect(url).toBe('/base-path/api/test')
expect(options?.method).toBe('GET')
expect(options?.credentials).toBe('same-origin')
expect((options?.headers as Record<string, string>)['Content-Type']).toBe('application/json')
expect((options?.headers as Record<string, string>)['Accept']).toBe('application/json')
expect((options as Record<string, unknown>).withCredentials).toBe(true)
})
it('convertCliOptions posts payload and returns parsed json', async () => {
const jsonMock = vi.fn().mockResolvedValue({ success: true })
const responseMock = { status: 200, json: jsonMock }
fetchMock.mockResolvedValue(responseMock)
const result = await utils.convertCliOptions('--help')
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, options] = fetchMock.mock.calls[0]!
expect(url).toBe('/base-path/api/yt-dlp/convert')
expect(options?.method).toBe('POST')
expect(options?.body).toBe(JSON.stringify({ args: '--help' }))
expect(jsonMock).toHaveBeenCalled()
expect(result).toEqual({ success: true })
})
it('convertCliOptions throws on non-200 response', async () => {
const jsonMock = vi.fn().mockResolvedValue({ error: 'fail' })
const responseMock = { status: 400, json: jsonMock }
fetchMock.mockResolvedValue(responseMock)
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail')
})
it('makeId uses crypto random values for deterministic id', () => {
const id = utils.makeId(4)
expect(id).toBe('0101')
expect(getRandomValuesMock).toHaveBeenCalled()
const typedArray = getRandomValuesMock.mock.calls[0]?.[0] as Uint8Array
expect(typedArray).toBeInstanceOf(Uint8Array)
expect(typedArray.length).toBe(2)
})
})
describe('async helpers', () => {
it('sleep resolves after specified seconds', async () => {
vi.useFakeTimers()
const promise = utils.sleep(1)
const thenSpy = vi.fn()
promise.then(thenSpy)
await vi.advanceTimersByTimeAsync(1000)
await promise
expect(thenSpy).toHaveBeenCalled()
vi.useRealTimers()
})
it('awaiter resolves when test becomes truthy', async () => {
// The frequency parameter is passed to sleep() which expects seconds, not milliseconds
// So we use 0.01 (10ms) instead of 10 to avoid the bug in the implementation
const values = [false, false, 'done']
const result = await utils.awaiter(() => values.shift(), 500, 0.01)
expect(result).toBe('done')
})
it('awaiter returns false when timeout reached', async () => {
// Use a short timeout and small frequency (in seconds, not milliseconds due to implementation bug)
const result = await utils.awaiter(() => false, 50, 0.01)
expect(result).toBe(false)
})
})

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { MatchFilterParser } from './ytdlp'
import { MatchFilterParser } from '../../app/utils/ytdlp'
function normalize(filters: string[]): Set<string> {
return new Set(filters.map(f => f.split("&").map(x => x.trim()).sort().join("&")));

16
ui/vitest.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
// Test files location
include: ['tests/**/*.test.ts'],
},
resolve: {
alias: {
// Map ~ to the app directory for imports in tests
'~': path.resolve(__dirname, './app'),
'#imports': path.resolve(__dirname, './app'),
},
},
})

371
uv.lock
View file

@ -279,14 +279,14 @@ wheels = [
[[package]]
name = "click"
version = "8.2.1"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 },
]
[[package]]
@ -319,6 +319,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/dd/9c40c4e0f4d3cb6cf52eb335e9cc1fa140c1f3a87146fb6987f465b069da/cronsim-2.6-py3-none-any.whl", hash = "sha256:5e153ff8ed64da7ee8d5caac470dbeda8024ab052c3010b1be149772b4801835", size = 13500 },
]
[[package]]
name = "cssselect"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 },
]
[[package]]
name = "curl-cffi"
version = "0.13.0"
@ -357,15 +366,19 @@ wheels = [
[[package]]
name = "debugpy"
version = "1.8.16"
version = "1.8.17"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/d4/722d0bcc7986172ac2ef3c979ad56a1030e3afd44ced136d45f8142b1f4a/debugpy-1.8.16.tar.gz", hash = "sha256:31e69a1feb1cf6b51efbed3f6c9b0ef03bc46ff050679c4be7ea6d2e23540870", size = 1643809 }
sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/66/607ab45cc79e60624df386e233ab64a6d8d39ea02e7f80e19c1d451345bb/debugpy-1.8.16-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:85df3adb1de5258dca910ae0bb185e48c98801ec15018a263a92bb06be1c8787", size = 2496157 },
{ url = "https://files.pythonhosted.org/packages/4d/a0/c95baae08a75bceabb79868d663a0736655e427ab9c81fb848da29edaeac/debugpy-1.8.16-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee89e948bc236a5c43c4214ac62d28b29388453f5fd328d739035e205365f0b", size = 4222491 },
{ url = "https://files.pythonhosted.org/packages/5b/2f/1c8db6ddd8a257c3cd2c46413b267f1d5fa3df910401c899513ce30392d6/debugpy-1.8.16-cp313-cp313-win32.whl", hash = "sha256:cf358066650439847ec5ff3dae1da98b5461ea5da0173d93d5e10f477c94609a", size = 5281126 },
{ url = "https://files.pythonhosted.org/packages/d3/ba/c3e154ab307366d6c5a9c1b68de04914e2ce7fa2f50d578311d8cc5074b2/debugpy-1.8.16-cp313-cp313-win_amd64.whl", hash = "sha256:b5aea1083f6f50023e8509399d7dc6535a351cc9f2e8827d1e093175e4d9fa4c", size = 5323094 },
{ url = "https://files.pythonhosted.org/packages/52/57/ecc9ae29fa5b2d90107cd1d9bf8ed19aacb74b2264d986ae9d44fe9bdf87/debugpy-1.8.16-py2.py3-none-any.whl", hash = "sha256:19c9521962475b87da6f673514f7fd610328757ec993bf7ec0d8c96f9a325f9e", size = 5287700 },
{ url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386 },
{ url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100 },
{ url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002 },
{ url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047 },
{ url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899 },
{ url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254 },
{ url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203 },
{ url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493 },
{ url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210 },
]
[[package]]
@ -501,6 +514,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
]
[[package]]
name = "jmespath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
]
[[package]]
name = "lxml"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8f/bd/f9d01fd4132d81c6f43ab01983caea69ec9614b913c290a26738431a015d/lxml-6.0.1.tar.gz", hash = "sha256:2b3a882ebf27dd026df3801a87cf49ff791336e0f94b0fad195db77e01240690", size = 4070214 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/c4/cd757eeec4548e6652eff50b944079d18ce5f8182d2b2cf514e125e8fbcb/lxml-6.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:485eda5d81bb7358db96a83546949c5fe7474bec6c68ef3fa1fb61a584b00eea", size = 8405139 },
{ url = "https://files.pythonhosted.org/packages/ff/99/0290bb86a7403893f5e9658490c705fcea103b9191f2039752b071b4ef07/lxml-6.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d12160adea318ce3d118f0b4fbdff7d1225c75fb7749429541b4d217b85c3f76", size = 4585954 },
{ url = "https://files.pythonhosted.org/packages/88/a7/4bb54dd1e626342a0f7df6ec6ca44fdd5d0e100ace53acc00e9a689ead04/lxml-6.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48c8d335d8ab72f9265e7ba598ae5105a8272437403f4032107dbcb96d3f0b29", size = 4944052 },
{ url = "https://files.pythonhosted.org/packages/71/8d/20f51cd07a7cbef6214675a8a5c62b2559a36d9303fe511645108887c458/lxml-6.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:405e7cf9dbdbb52722c231e0f1257214202dfa192327fab3de45fd62e0554082", size = 5098885 },
{ url = "https://files.pythonhosted.org/packages/5a/63/efceeee7245d45f97d548e48132258a36244d3c13c6e3ddbd04db95ff496/lxml-6.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:299a790d403335a6a057ade46f92612ebab87b223e4e8c5308059f2dc36f45ed", size = 5017542 },
{ url = "https://files.pythonhosted.org/packages/57/5d/92cb3d3499f5caba17f7933e6be3b6c7de767b715081863337ced42eb5f2/lxml-6.0.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:48da704672f6f9c461e9a73250440c647638cc6ff9567ead4c3b1f189a604ee8", size = 5347303 },
{ url = "https://files.pythonhosted.org/packages/69/f8/606fa16a05d7ef5e916c6481c634f40870db605caffed9d08b1a4fb6b989/lxml-6.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21e364e1bb731489e3f4d51db416f991a5d5da5d88184728d80ecfb0904b1d68", size = 5641055 },
{ url = "https://files.pythonhosted.org/packages/b3/01/15d5fc74ebb49eac4e5df031fbc50713dcc081f4e0068ed963a510b7d457/lxml-6.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bce45a2c32032afddbd84ed8ab092130649acb935536ef7a9559636ce7ffd4a", size = 5242719 },
{ url = "https://files.pythonhosted.org/packages/42/a5/1b85e2aaaf8deaa67e04c33bddb41f8e73d07a077bf9db677cec7128bfb4/lxml-6.0.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:fa164387ff20ab0e575fa909b11b92ff1481e6876835014e70280769920c4433", size = 4717310 },
{ url = "https://files.pythonhosted.org/packages/42/23/f3bb1292f55a725814317172eeb296615db3becac8f1a059b53c51fc1da8/lxml-6.0.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7587ac5e000e1594e62278422c5783b34a82b22f27688b1074d71376424b73e8", size = 5254024 },
{ url = "https://files.pythonhosted.org/packages/b4/be/4d768f581ccd0386d424bac615d9002d805df7cc8482ae07d529f60a3c1e/lxml-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57478424ac4c9170eabf540237125e8d30fad1940648924c058e7bc9fb9cf6dd", size = 5055335 },
{ url = "https://files.pythonhosted.org/packages/40/07/ed61d1a3e77d1a9f856c4fab15ee5c09a2853fb7af13b866bb469a3a6d42/lxml-6.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:09c74afc7786c10dd6afaa0be2e4805866beadc18f1d843cf517a7851151b499", size = 4784864 },
{ url = "https://files.pythonhosted.org/packages/01/37/77e7971212e5c38a55431744f79dff27fd751771775165caea096d055ca4/lxml-6.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7fd70681aeed83b196482d42a9b0dc5b13bab55668d09ad75ed26dff3be5a2f5", size = 5657173 },
{ url = "https://files.pythonhosted.org/packages/32/a3/e98806d483941cd9061cc838b1169626acef7b2807261fbe5e382fcef881/lxml-6.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:10a72e456319b030b3dd900df6b1f19d89adf06ebb688821636dc406788cf6ac", size = 5245896 },
{ url = "https://files.pythonhosted.org/packages/07/de/9bb5a05e42e8623bf06b4638931ea8c8f5eb5a020fe31703abdbd2e83547/lxml-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0fa45fb5f55111ce75b56c703843b36baaf65908f8b8d2fbbc0e249dbc127ed", size = 5267417 },
{ url = "https://files.pythonhosted.org/packages/f2/43/c1cb2a7c67226266c463ef8a53b82d42607228beb763b5fbf4867e88a21f/lxml-6.0.1-cp313-cp313-win32.whl", hash = "sha256:01dab65641201e00c69338c9c2b8a0f2f484b6b3a22d10779bb417599fae32b5", size = 3610051 },
{ url = "https://files.pythonhosted.org/packages/34/96/6a6c3b8aa480639c1a0b9b6faf2a63fb73ab79ffcd2a91cf28745faa22de/lxml-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:bdf8f7c8502552d7bff9e4c98971910a0a59f60f88b5048f608d0a1a75e94d1c", size = 4009325 },
{ url = "https://files.pythonhosted.org/packages/8c/66/622e8515121e1fd773e3738dae71b8df14b12006d9fb554ce90886689fd0/lxml-6.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6aeca75959426b9fd8d4782c28723ba224fe07cfa9f26a141004210528dcbe2", size = 3670443 },
{ url = "https://files.pythonhosted.org/packages/38/e3/b7eb612ce07abe766918a7e581ec6a0e5212352194001fd287c3ace945f0/lxml-6.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:29b0e849ec7030e3ecb6112564c9f7ad6881e3b2375dd4a0c486c5c1f3a33859", size = 8426160 },
{ url = "https://files.pythonhosted.org/packages/35/8f/ab3639a33595cf284fe733c6526da2ca3afbc5fd7f244ae67f3303cec654/lxml-6.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:02a0f7e629f73cc0be598c8b0611bf28ec3b948c549578a26111b01307fd4051", size = 4589288 },
{ url = "https://files.pythonhosted.org/packages/2c/65/819d54f2e94d5c4458c1db8c1ccac9d05230b27c1038937d3d788eb406f9/lxml-6.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:beab5e54de016e730875f612ba51e54c331e2fa6dc78ecf9a5415fc90d619348", size = 4964523 },
{ url = "https://files.pythonhosted.org/packages/5b/4a/d4a74ce942e60025cdaa883c5a4478921a99ce8607fc3130f1e349a83b28/lxml-6.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a08aefecd19ecc4ebf053c27789dd92c87821df2583a4337131cf181a1dffa", size = 5101108 },
{ url = "https://files.pythonhosted.org/packages/cb/48/67f15461884074edd58af17b1827b983644d1fae83b3d909e9045a08b61e/lxml-6.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c8fa7e177649470bc3dcf7eae6bee1e4984aaee496b9ccbf30e97ac4127fa2", size = 5053498 },
{ url = "https://files.pythonhosted.org/packages/b6/d4/ec1bf1614828a5492f4af0b6a9ee2eb3e92440aea3ac4fa158e5228b772b/lxml-6.0.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5d08e0f1af6916267bb7eff21c09fa105620f07712424aaae09e8cb5dd4164d1", size = 5351057 },
{ url = "https://files.pythonhosted.org/packages/65/2b/c85929dacac08821f2100cea3eb258ce5c8804a4e32b774f50ebd7592850/lxml-6.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9705cdfc05142f8c38c97a61bd3a29581ceceb973a014e302ee4a73cc6632476", size = 5671579 },
{ url = "https://files.pythonhosted.org/packages/d0/36/cf544d75c269b9aad16752fd9f02d8e171c5a493ca225cb46bb7ba72868c/lxml-6.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74555e2da7c1636e30bff4e6e38d862a634cf020ffa591f1f63da96bf8b34772", size = 5250403 },
{ url = "https://files.pythonhosted.org/packages/c2/e8/83dbc946ee598fd75fdeae6151a725ddeaab39bb321354a9468d4c9f44f3/lxml-6.0.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e38b5f94c5a2a5dadaddd50084098dfd005e5a2a56cd200aaf5e0a20e8941782", size = 4696712 },
{ url = "https://files.pythonhosted.org/packages/f4/72/889c633b47c06205743ba935f4d1f5aa4eb7f0325d701ed2b0540df1b004/lxml-6.0.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5ec101a92ddacb4791977acfc86c1afd624c032974bfb6a21269d1083c9bc49", size = 5268177 },
{ url = "https://files.pythonhosted.org/packages/b0/b6/f42a21a1428479b66ea0da7bd13e370436aecaff0cfe93270c7e165bd2a4/lxml-6.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c17e70c82fd777df586c12114bbe56e4e6f823a971814fd40dec9c0de518772", size = 5094648 },
{ url = "https://files.pythonhosted.org/packages/51/b0/5f8c1e8890e2ee1c2053c2eadd1cb0e4b79e2304e2912385f6ca666f48b1/lxml-6.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:45fdd0415a0c3d91640b5d7a650a8f37410966a2e9afebb35979d06166fd010e", size = 4745220 },
{ url = "https://files.pythonhosted.org/packages/eb/f9/820b5125660dae489ca3a21a36d9da2e75dd6b5ffe922088f94bbff3b8a0/lxml-6.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d417eba28981e720a14fcb98f95e44e7a772fe25982e584db38e5d3b6ee02e79", size = 5692913 },
{ url = "https://files.pythonhosted.org/packages/23/8e/a557fae9eec236618aecf9ff35fec18df41b6556d825f3ad6017d9f6e878/lxml-6.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8e5d116b9e59be7934febb12c41cce2038491ec8fdb743aeacaaf36d6e7597e4", size = 5259816 },
{ url = "https://files.pythonhosted.org/packages/fa/fd/b266cfaab81d93a539040be699b5854dd24c84e523a1711ee5f615aa7000/lxml-6.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c238f0d0d40fdcb695c439fe5787fa69d40f45789326b3bb6ef0d61c4b588d6e", size = 5276162 },
{ url = "https://files.pythonhosted.org/packages/25/6c/6f9610fbf1de002048e80585ea4719591921a0316a8565968737d9f125ca/lxml-6.0.1-cp314-cp314-win32.whl", hash = "sha256:537b6cf1c5ab88cfd159195d412edb3e434fee880f206cbe68dff9c40e17a68a", size = 3669595 },
{ url = "https://files.pythonhosted.org/packages/72/a5/506775e3988677db24dc75a7b03e04038e0b3d114ccd4bccea4ce0116c15/lxml-6.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:911d0a2bb3ef3df55b3d97ab325a9ca7e438d5112c102b8495321105d25a441b", size = 4079818 },
{ url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901 },
]
[[package]]
name = "macholib"
version = "1.16.3"
@ -581,6 +647,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 },
]
[[package]]
name = "outcome"
version = "1.3.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 },
]
[[package]]
name = "packaging"
version = "25.0"
@ -590,6 +668,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
]
[[package]]
name = "parsel"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cssselect" },
{ name = "jmespath" },
{ name = "lxml" },
{ name = "packaging" },
{ name = "w3lib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/df/acd504c154c0b9028b0d8491a77fdd5f86e9c06ee04f986abf85e36d9a5f/parsel-1.10.0.tar.gz", hash = "sha256:14f17db9559f51b43357b9dfe43cec870a8efb5ea4857abb624ec6ff80d8a080", size = 51421 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/18/35d1d947553d24909dca37e2ff11720eecb601360d1bac8d7a9a1bc7eb08/parsel-1.10.0-py2.py3-none-any.whl", hash = "sha256:6a0c28bd81f9df34ba665884c88efa0b18b8d2c44c81f64e27f2f0cb37d46169", size = 17266 },
]
[[package]]
name = "pefile"
version = "2023.2.7"
@ -708,7 +802,7 @@ wheels = [
[[package]]
name = "pyinstaller"
version = "6.15.0"
version = "6.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
@ -719,19 +813,19 @@ dependencies = [
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/17/b2bb4de22650adbeef401fa82a1b43028976547a8728602e4d29735b455e/pyinstaller-6.15.0.tar.gz", hash = "sha256:a48fc4644ee4aa2aa2a35e7b51f496f8fbd7eecf6a2150646bbf1613ad07bc2d", size = 4331521 }
sdist = { url = "https://files.pythonhosted.org/packages/94/94/1f62e95e4a28b64cfbb5b922ef3046f968b47170d37a1e1a029f56ac9cb4/pyinstaller-6.16.0.tar.gz", hash = "sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef", size = 4008473 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/dd/d5c8a127446adda954f68ea7fac22772f7ab8656ad4b06df396d82574ca9/pyinstaller-6.15.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:9f00c71c40148cd1e61695b2c6f1e086693d3bcf9bfa22ab513aa4254c3b966f", size = 1016981 },
{ url = "https://files.pythonhosted.org/packages/2d/2a/7b50593b419db43e48d9bdeebaac0ff92a5fe035f3c30f87ca3e1650d7e2/pyinstaller-6.15.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cbcc8eb77320c60722030ac875883b564e00768fe3ff1721c7ba3ad0e0a277e9", size = 726337 },
{ url = "https://files.pythonhosted.org/packages/77/83/7f498fba0154c57eb5fc93eb9680a2dbadb9f780a3389fb85b8d79683378/pyinstaller-6.15.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c33e6302bc53db2df1104ed5566bd980b3e0ee7f18416a6e3caa908c12a54542", size = 737539 },
{ url = "https://files.pythonhosted.org/packages/09/d6/e4477feab7c8379fb49e7ec95c82d0a69ad88f6ccc247f76bef3cb0e3432/pyinstaller-6.15.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:eb902d0fed3bb1f8b7190dc4df5c11f3b59505767e0d56d1ed782b853938bbf3", size = 735426 },
{ url = "https://files.pythonhosted.org/packages/32/7e/ff25648276f15e2e77fc563d36d8cfcd917e077bf2a172420df3588601b4/pyinstaller-6.15.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b4df862adae7cf1f08eff53c43ace283822447f7f528f72e4f94749062712f15", size = 732210 },
{ url = "https://files.pythonhosted.org/packages/db/3d/267a7dddd0647de95d260780050ccd8228ab29d2b9edea54ed1f56800967/pyinstaller-6.15.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b9ebf16ed0f99016ae8ae5746dee4cb244848a12941539e62ce2eea1df5a3f95", size = 732194 },
{ url = "https://files.pythonhosted.org/packages/4d/61/962b2eb79ef225233e2d6e04600e998935328011dfb2fa775b1dd16b943a/pyinstaller-6.15.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:22193489e6a22435417103f61e7950363bba600ef36ec3ab1487303668c81092", size = 731256 },
{ url = "https://files.pythonhosted.org/packages/67/5e/4e20e1c0e5791b09b69bef3ac921fd0cd25551b56879324ad999b92fa045/pyinstaller-6.15.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:18f743069849dbaee3e10900385f35795a5743eabab55e99dcc42f204e40a0db", size = 731148 },
{ url = "https://files.pythonhosted.org/packages/88/31/28956c534991f289e2f981c715730b6241e75dc6295737a8cbd050a0cc8c/pyinstaller-6.15.0-py3-none-win32.whl", hash = "sha256:60da8f1b5071766b45c0f607d8bc3d7e59ba2c3b262d08f2e4066ba65f3544a2", size = 1312297 },
{ url = "https://files.pythonhosted.org/packages/09/ab/6a45186c7f8e34c422faecd72580116a67d068158c57faa2d2f6d01faa7f/pyinstaller-6.15.0-py3-none-win_amd64.whl", hash = "sha256:cbea297e16eeda30b41c300d6ec2fd2abea4dbd8d8a32650eeec36431c94fcd9", size = 1373091 },
{ url = "https://files.pythonhosted.org/packages/5b/86/72159af032b9db36f2470a3b085f79277ec1c38e7e48f8c5dc1ed16dc4e1/pyinstaller-6.15.0-py3-none-win_arm64.whl", hash = "sha256:f43c035621742cf2d19b84308c60e4e44e72c94786d176b8f6adcde351b5bd98", size = 1314305 },
{ url = "https://files.pythonhosted.org/packages/7b/0a/c42ce6e5d3de287f2e9432a074fb209f1fb72a86a72f3903849fdb5e4829/pyinstaller-6.16.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a", size = 1027899 },
{ url = "https://files.pythonhosted.org/packages/4e/d0/f18fedde32835d5a758f464c75924e2154065625f09d5456c3c303527654/pyinstaller-6.16.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0", size = 727990 },
{ url = "https://files.pythonhosted.org/packages/7a/db/c8bb47514ce857b24bf9294cf1ff74844b6a489fa0ab4ef6f923288c4e38/pyinstaller-6.16.0-py3-none-manylinux2014_i686.whl", hash = "sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058", size = 739238 },
{ url = "https://files.pythonhosted.org/packages/c6/3e/451dc784a8fcca0fe9f9b6b802d58555364a95b60f253613a2c83fc6b023/pyinstaller-6.16.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851", size = 737142 },
{ url = "https://files.pythonhosted.org/packages/71/37/2f457479ef8fa2821cdb448acee2421dfb19fbe908bf5499d1930c164084/pyinstaller-6.16.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3", size = 734133 },
{ url = "https://files.pythonhosted.org/packages/63/c4/0f7daac4d062a4d1ac2571d8a8b9b5d6812094fcd914d139af591ca5e1ba/pyinstaller-6.16.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0", size = 733817 },
{ url = "https://files.pythonhosted.org/packages/11/e4/b6127265b42bef883e8873d850becadf748bc5652e5a7029b059328f3c31/pyinstaller-6.16.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454", size = 732912 },
{ url = "https://files.pythonhosted.org/packages/2b/00/c6663107bdf814b2916e71563beabd09f693c47712213bc228994cb2cc65/pyinstaller-6.16.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5", size = 732773 },
{ url = "https://files.pythonhosted.org/packages/a3/14/cabe9bc5f60b95d2e70e7d045ab94b0015ff8f6c8b16e2142d3597e30749/pyinstaller-6.16.0-py3-none-win32.whl", hash = "sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8", size = 1313878 },
{ url = "https://files.pythonhosted.org/packages/aa/99/2005efbc297e7813c1d6f18484aa94a1a81ce87b6a5b497c563681f4c4ea/pyinstaller-6.16.0-py3-none-win_amd64.whl", hash = "sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41", size = 1374706 },
{ url = "https://files.pythonhosted.org/packages/ca/f4/4dfcf69b86d60fcaae05a42bbff1616d48a91e71726e5ed795d773dae9b3/pyinstaller-6.16.0-py3-none-win_arm64.whl", hash = "sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64", size = 1315923 },
]
[[package]]
@ -782,6 +876,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 },
]
[[package]]
name = "pysocks"
version = "1.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 },
]
[[package]]
name = "pysubs2"
version = "1.8.0"
@ -920,38 +1023,66 @@ wheels = [
[[package]]
name = "regex"
version = "2025.9.1"
version = "2025.9.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/5a/4c63457fbcaf19d138d72b2e9b39405954f98c0349b31c601bfcb151582c/regex-2025.9.1.tar.gz", hash = "sha256:88ac07b38d20b54d79e704e38aa3bd2c0f8027432164226bdee201a1c0c9c9ff", size = 400852 }
sdist = { url = "https://files.pythonhosted.org/packages/49/d3/eaa0d28aba6ad1827ad1e716d9a93e1ba963ada61887498297d3da715133/regex-2025.9.18.tar.gz", hash = "sha256:c5ba23274c61c6fef447ba6a39333297d0c247f53059dba0bca415cac511edc4", size = 400917 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/25/b2959ce90c6138c5142fe5264ee1f9b71a0c502ca4c7959302a749407c79/regex-2025.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bc6834727d1b98d710a63e6c823edf6ffbf5792eba35d3fa119531349d4142ef", size = 485932 },
{ url = "https://files.pythonhosted.org/packages/49/2e/6507a2a85f3f2be6643438b7bd976e67ad73223692d6988eb1ff444106d3/regex-2025.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c3dc05b6d579875719bccc5f3037b4dc80433d64e94681a0061845bd8863c025", size = 289568 },
{ url = "https://files.pythonhosted.org/packages/c7/d8/de4a4b57215d99868f1640e062a7907e185ec7476b4b689e2345487c1ff4/regex-2025.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22213527df4c985ec4a729b055a8306272d41d2f45908d7bacb79be0fa7a75ad", size = 286984 },
{ url = "https://files.pythonhosted.org/packages/03/15/e8cb403403a57ed316e80661db0e54d7aa2efcd85cb6156f33cc18746922/regex-2025.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3f6e3c5a5a1adc3f7ea1b5aec89abfc2f4fbfba55dafb4343cd1d084f715b2", size = 797514 },
{ url = "https://files.pythonhosted.org/packages/e4/26/2446f2b9585fed61faaa7e2bbce3aca7dd8df6554c32addee4c4caecf24a/regex-2025.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcb89c02a0d6c2bec9b0bb2d8c78782699afe8434493bfa6b4021cc51503f249", size = 862586 },
{ url = "https://files.pythonhosted.org/packages/fd/b8/82ffbe9c0992c31bbe6ae1c4b4e21269a5df2559102b90543c9b56724c3c/regex-2025.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0e2f95413eb0c651cd1516a670036315b91b71767af83bc8525350d4375ccba", size = 910815 },
{ url = "https://files.pythonhosted.org/packages/2f/d8/7303ea38911759c1ee30cc5bc623ee85d3196b733c51fd6703c34290a8d9/regex-2025.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a41dc039e1c97d3c2ed3e26523f748e58c4de3ea7a31f95e1cf9ff973fff5a", size = 802042 },
{ url = "https://files.pythonhosted.org/packages/fc/0e/6ad51a55ed4b5af512bb3299a05d33309bda1c1d1e1808fa869a0bed31bc/regex-2025.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f0b4258b161094f66857a26ee938d3fe7b8a5063861e44571215c44fbf0e5df", size = 786764 },
{ url = "https://files.pythonhosted.org/packages/8d/d5/394e3ffae6baa5a9217bbd14d96e0e5da47bb069d0dbb8278e2681a2b938/regex-2025.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bf70e18ac390e6977ea7e56f921768002cb0fa359c4199606c7219854ae332e0", size = 856557 },
{ url = "https://files.pythonhosted.org/packages/cd/80/b288d3910c41194ad081b9fb4b371b76b0bbfdce93e7709fc98df27b37dc/regex-2025.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b84036511e1d2bb0a4ff1aec26951caa2dea8772b223c9e8a19ed8885b32dbac", size = 849108 },
{ url = "https://files.pythonhosted.org/packages/d1/cd/5ec76bf626d0d5abdc277b7a1734696f5f3d14fbb4a3e2540665bc305d85/regex-2025.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c2e05dcdfe224047f2a59e70408274c325d019aad96227ab959403ba7d58d2d7", size = 788201 },
{ url = "https://files.pythonhosted.org/packages/b5/36/674672f3fdead107565a2499f3007788b878188acec6d42bc141c5366c2c/regex-2025.9.1-cp313-cp313-win32.whl", hash = "sha256:3b9a62107a7441b81ca98261808fed30ae36ba06c8b7ee435308806bd53c1ed8", size = 264508 },
{ url = "https://files.pythonhosted.org/packages/83/ad/931134539515eb64ce36c24457a98b83c1b2e2d45adf3254b94df3735a76/regex-2025.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:b38afecc10c177eb34cfae68d669d5161880849ba70c05cbfbe409f08cc939d7", size = 275469 },
{ url = "https://files.pythonhosted.org/packages/24/8c/96d34e61c0e4e9248836bf86d69cb224fd222f270fa9045b24e218b65604/regex-2025.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:ec329890ad5e7ed9fc292858554d28d58d56bf62cf964faf0aa57964b21155a0", size = 268586 },
{ url = "https://files.pythonhosted.org/packages/21/b1/453cbea5323b049181ec6344a803777914074b9726c9c5dc76749966d12d/regex-2025.9.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:72fb7a016467d364546f22b5ae86c45680a4e0de6b2a6f67441d22172ff641f1", size = 486111 },
{ url = "https://files.pythonhosted.org/packages/f6/0e/92577f197bd2f7652c5e2857f399936c1876978474ecc5b068c6d8a79c86/regex-2025.9.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c9527fa74eba53f98ad86be2ba003b3ebe97e94b6eb2b916b31b5f055622ef03", size = 289520 },
{ url = "https://files.pythonhosted.org/packages/af/c6/b472398116cca7ea5a6c4d5ccd0fc543f7fd2492cb0c48d2852a11972f73/regex-2025.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c905d925d194c83a63f92422af7544ec188301451b292c8b487f0543726107ca", size = 287215 },
{ url = "https://files.pythonhosted.org/packages/cf/11/f12ecb0cf9ca792a32bb92f758589a84149017467a544f2f6bfb45c0356d/regex-2025.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74df7c74a63adcad314426b1f4ea6054a5ab25d05b0244f0c07ff9ce640fa597", size = 797855 },
{ url = "https://files.pythonhosted.org/packages/46/88/bbb848f719a540fb5997e71310f16f0b33a92c5d4b4d72d4311487fff2a3/regex-2025.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f6e935e98ea48c7a2e8be44494de337b57a204470e7f9c9c42f912c414cd6f5", size = 863363 },
{ url = "https://files.pythonhosted.org/packages/54/a9/2321eb3e2838f575a78d48e03c1e83ea61bd08b74b7ebbdeca8abc50fc25/regex-2025.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4a62d033cd9ebefc7c5e466731a508dfabee827d80b13f455de68a50d3c2543d", size = 910202 },
{ url = "https://files.pythonhosted.org/packages/33/07/d1d70835d7d11b7e126181f316f7213c4572ecf5c5c97bdbb969fb1f38a2/regex-2025.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef971ebf2b93bdc88d8337238be4dfb851cc97ed6808eb04870ef67589415171", size = 801808 },
{ url = "https://files.pythonhosted.org/packages/13/d1/29e4d1bed514ef2bf3a4ead3cb8bb88ca8af94130239a4e68aa765c35b1c/regex-2025.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d936a1db208bdca0eca1f2bb2c1ba1d8370b226785c1e6db76e32a228ffd0ad5", size = 786824 },
{ url = "https://files.pythonhosted.org/packages/33/27/20d8ccb1bee460faaa851e6e7cc4cfe852a42b70caa1dca22721ba19f02f/regex-2025.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7e786d9e4469698fc63815b8de08a89165a0aa851720eb99f5e0ea9d51dd2b6a", size = 857406 },
{ url = "https://files.pythonhosted.org/packages/74/fe/60c6132262dc36430d51e0c46c49927d113d3a38c1aba6a26c7744c84cf3/regex-2025.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6b81d7dbc5466ad2c57ce3a0ddb717858fe1a29535c8866f8514d785fdb9fc5b", size = 848593 },
{ url = "https://files.pythonhosted.org/packages/cc/ae/2d4ff915622fabbef1af28387bf71e7f2f4944a348b8460d061e85e29bf0/regex-2025.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4890e184a6feb0ef195338a6ce68906a8903a0f2eb7e0ab727dbc0a3156273", size = 787951 },
{ url = "https://files.pythonhosted.org/packages/85/37/dc127703a9e715a284cc2f7dbdd8a9776fd813c85c126eddbcbdd1ca5fec/regex-2025.9.1-cp314-cp314-win32.whl", hash = "sha256:34679a86230e46164c9e0396b56cab13c0505972343880b9e705083cc5b8ec86", size = 269833 },
{ url = "https://files.pythonhosted.org/packages/83/bf/4bed4d3d0570e16771defd5f8f15f7ea2311edcbe91077436d6908956c4a/regex-2025.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:a1196e530a6bfa5f4bde029ac5b0295a6ecfaaffbfffede4bbaf4061d9455b70", size = 278742 },
{ url = "https://files.pythonhosted.org/packages/cf/3e/7d7ac6fd085023312421e0d69dfabdfb28e116e513fadbe9afe710c01893/regex-2025.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:f46d525934871ea772930e997d577d48c6983e50f206ff7b66d4ac5f8941e993", size = 271860 },
{ url = "https://files.pythonhosted.org/packages/d2/c7/5c48206a60ce33711cf7dcaeaed10dd737733a3569dc7e1dce324dd48f30/regex-2025.9.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a40f929cd907c7e8ac7566ac76225a77701a6221bca937bdb70d56cb61f57b2", size = 485955 },
{ url = "https://files.pythonhosted.org/packages/e9/be/74fc6bb19a3c491ec1ace943e622b5a8539068771e8705e469b2da2306a7/regex-2025.9.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c90471671c2cdf914e58b6af62420ea9ecd06d1554d7474d50133ff26ae88feb", size = 289583 },
{ url = "https://files.pythonhosted.org/packages/25/c4/9ceaa433cb5dc515765560f22a19578b95b92ff12526e5a259321c4fc1a0/regex-2025.9.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a351aff9e07a2dabb5022ead6380cff17a4f10e4feb15f9100ee56c4d6d06af", size = 287000 },
{ url = "https://files.pythonhosted.org/packages/7d/e6/68bc9393cb4dc68018456568c048ac035854b042bc7c33cb9b99b0680afa/regex-2025.9.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc4b8e9d16e20ddfe16430c23468a8707ccad3365b06d4536142e71823f3ca29", size = 797535 },
{ url = "https://files.pythonhosted.org/packages/6a/1c/ebae9032d34b78ecfe9bd4b5e6575b55351dc8513485bb92326613732b8c/regex-2025.9.18-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b8cdbddf2db1c5e80338ba2daa3cfa3dec73a46fff2a7dda087c8efbf12d62f", size = 862603 },
{ url = "https://files.pythonhosted.org/packages/3b/74/12332c54b3882557a4bcd2b99f8be581f5c6a43cf1660a85b460dd8ff468/regex-2025.9.18-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a276937d9d75085b2c91fb48244349c6954f05ee97bba0963ce24a9d915b8b68", size = 910829 },
{ url = "https://files.pythonhosted.org/packages/86/70/ba42d5ed606ee275f2465bfc0e2208755b06cdabd0f4c7c4b614d51b57ab/regex-2025.9.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92a8e375ccdc1256401c90e9dc02b8642894443d549ff5e25e36d7cf8a80c783", size = 802059 },
{ url = "https://files.pythonhosted.org/packages/da/c5/fcb017e56396a7f2f8357412638d7e2963440b131a3ca549be25774b3641/regex-2025.9.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dc6893b1f502d73037cf807a321cdc9be29ef3d6219f7970f842475873712ac", size = 786781 },
{ url = "https://files.pythonhosted.org/packages/c6/ee/21c4278b973f630adfb3bcb23d09d83625f3ab1ca6e40ebdffe69901c7a1/regex-2025.9.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a61e85bfc63d232ac14b015af1261f826260c8deb19401c0597dbb87a864361e", size = 856578 },
{ url = "https://files.pythonhosted.org/packages/87/0b/de51550dc7274324435c8f1539373ac63019b0525ad720132866fff4a16a/regex-2025.9.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ef86a9ebc53f379d921fb9a7e42b92059ad3ee800fcd9e0fe6181090e9f6c23", size = 849119 },
{ url = "https://files.pythonhosted.org/packages/60/52/383d3044fc5154d9ffe4321696ee5b2ee4833a28c29b137c22c33f41885b/regex-2025.9.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d3bc882119764ba3a119fbf2bd4f1b47bc56c1da5d42df4ed54ae1e8e66fdf8f", size = 788219 },
{ url = "https://files.pythonhosted.org/packages/20/bd/2614fc302671b7359972ea212f0e3a92df4414aaeacab054a8ce80a86073/regex-2025.9.18-cp313-cp313-win32.whl", hash = "sha256:3810a65675845c3bdfa58c3c7d88624356dd6ee2fc186628295e0969005f928d", size = 264517 },
{ url = "https://files.pythonhosted.org/packages/07/0f/ab5c1581e6563a7bffdc1974fb2d25f05689b88e2d416525271f232b1946/regex-2025.9.18-cp313-cp313-win_amd64.whl", hash = "sha256:16eaf74b3c4180ede88f620f299e474913ab6924d5c4b89b3833bc2345d83b3d", size = 275481 },
{ url = "https://files.pythonhosted.org/packages/49/22/ee47672bc7958f8c5667a587c2600a4fba8b6bab6e86bd6d3e2b5f7cac42/regex-2025.9.18-cp313-cp313-win_arm64.whl", hash = "sha256:4dc98ba7dd66bd1261927a9f49bd5ee2bcb3660f7962f1ec02617280fc00f5eb", size = 268598 },
{ url = "https://files.pythonhosted.org/packages/e8/83/6887e16a187c6226cb85d8301e47d3b73ecc4505a3a13d8da2096b44fd76/regex-2025.9.18-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fe5d50572bc885a0a799410a717c42b1a6b50e2f45872e2b40f4f288f9bce8a2", size = 489765 },
{ url = "https://files.pythonhosted.org/packages/51/c5/e2f7325301ea2916ff301c8d963ba66b1b2c1b06694191df80a9c4fea5d0/regex-2025.9.18-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b9d9a2d6cda6621551ca8cf7a06f103adf72831153f3c0d982386110870c4d3", size = 291228 },
{ url = "https://files.pythonhosted.org/packages/91/60/7d229d2bc6961289e864a3a3cfebf7d0d250e2e65323a8952cbb7e22d824/regex-2025.9.18-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:13202e4c4ac0ef9a317fff817674b293c8f7e8c68d3190377d8d8b749f566e12", size = 289270 },
{ url = "https://files.pythonhosted.org/packages/3c/d7/b4f06868ee2958ff6430df89857fbf3d43014bbf35538b6ec96c2704e15d/regex-2025.9.18-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874ff523b0fecffb090f80ae53dc93538f8db954c8bb5505f05b7787ab3402a0", size = 806326 },
{ url = "https://files.pythonhosted.org/packages/d6/e4/bca99034a8f1b9b62ccf337402a8e5b959dd5ba0e5e5b2ead70273df3277/regex-2025.9.18-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d13ab0490128f2bb45d596f754148cd750411afc97e813e4b3a61cf278a23bb6", size = 871556 },
{ url = "https://files.pythonhosted.org/packages/6d/df/e06ffaf078a162f6dd6b101a5ea9b44696dca860a48136b3ae4a9caf25e2/regex-2025.9.18-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:05440bc172bc4b4b37fb9667e796597419404dbba62e171e1f826d7d2a9ebcef", size = 913817 },
{ url = "https://files.pythonhosted.org/packages/9e/05/25b05480b63292fd8e84800b1648e160ca778127b8d2367a0a258fa2e225/regex-2025.9.18-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5514b8e4031fdfaa3d27e92c75719cbe7f379e28cacd939807289bce76d0e35a", size = 811055 },
{ url = "https://files.pythonhosted.org/packages/70/97/7bc7574655eb651ba3a916ed4b1be6798ae97af30104f655d8efd0cab24b/regex-2025.9.18-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:65d3c38c39efce73e0d9dc019697b39903ba25b1ad45ebbd730d2cf32741f40d", size = 794534 },
{ url = "https://files.pythonhosted.org/packages/b4/c2/d5da49166a52dda879855ecdba0117f073583db2b39bb47ce9a3378a8e9e/regex-2025.9.18-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae77e447ebc144d5a26d50055c6ddba1d6ad4a865a560ec7200b8b06bc529368", size = 866684 },
{ url = "https://files.pythonhosted.org/packages/bd/2d/0a5c4e6ec417de56b89ff4418ecc72f7e3feca806824c75ad0bbdae0516b/regex-2025.9.18-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3ef8cf53dc8df49d7e28a356cf824e3623764e9833348b655cfed4524ab8a90", size = 853282 },
{ url = "https://files.pythonhosted.org/packages/f4/8e/d656af63e31a86572ec829665d6fa06eae7e144771e0330650a8bb865635/regex-2025.9.18-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9feb29817df349c976da9a0debf775c5c33fc1c8ad7b9f025825da99374770b7", size = 797830 },
{ url = "https://files.pythonhosted.org/packages/db/ce/06edc89df8f7b83ffd321b6071be4c54dc7332c0f77860edc40ce57d757b/regex-2025.9.18-cp313-cp313t-win32.whl", hash = "sha256:168be0d2f9b9d13076940b1ed774f98595b4e3c7fc54584bba81b3cc4181742e", size = 267281 },
{ url = "https://files.pythonhosted.org/packages/83/9a/2b5d9c8b307a451fd17068719d971d3634ca29864b89ed5c18e499446d4a/regex-2025.9.18-cp313-cp313t-win_amd64.whl", hash = "sha256:d59ecf3bb549e491c8104fea7313f3563c7b048e01287db0a90485734a70a730", size = 278724 },
{ url = "https://files.pythonhosted.org/packages/3d/70/177d31e8089a278a764f8ec9a3faac8d14a312d622a47385d4b43905806f/regex-2025.9.18-cp313-cp313t-win_arm64.whl", hash = "sha256:dbef80defe9fb21310948a2595420b36c6d641d9bea4c991175829b2cc4bc06a", size = 269771 },
{ url = "https://files.pythonhosted.org/packages/44/b7/3b4663aa3b4af16819f2ab6a78c4111c7e9b066725d8107753c2257448a5/regex-2025.9.18-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c6db75b51acf277997f3adcd0ad89045d856190d13359f15ab5dda21581d9129", size = 486130 },
{ url = "https://files.pythonhosted.org/packages/80/5b/4533f5d7ac9c6a02a4725fe8883de2aebc713e67e842c04cf02626afb747/regex-2025.9.18-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8f9698b6f6895d6db810e0bda5364f9ceb9e5b11328700a90cae573574f61eea", size = 289539 },
{ url = "https://files.pythonhosted.org/packages/b8/8d/5ab6797c2750985f79e9995fad3254caa4520846580f266ae3b56d1cae58/regex-2025.9.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29cd86aa7cb13a37d0f0d7c21d8d949fe402ffa0ea697e635afedd97ab4b69f1", size = 287233 },
{ url = "https://files.pythonhosted.org/packages/cb/1e/95afcb02ba8d3a64e6ffeb801718ce73471ad6440c55d993f65a4a5e7a92/regex-2025.9.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c9f285a071ee55cd9583ba24dde006e53e17780bb309baa8e4289cd472bcc47", size = 797876 },
{ url = "https://files.pythonhosted.org/packages/c8/fb/720b1f49cec1f3b5a9fea5b34cd22b88b5ebccc8c1b5de9cc6f65eed165a/regex-2025.9.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5adf266f730431e3be9021d3e5b8d5ee65e563fec2883ea8093944d21863b379", size = 863385 },
{ url = "https://files.pythonhosted.org/packages/a9/ca/e0d07ecf701e1616f015a720dc13b84c582024cbfbb3fc5394ae204adbd7/regex-2025.9.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1137cabc0f38807de79e28d3f6e3e3f2cc8cfb26bead754d02e6d1de5f679203", size = 910220 },
{ url = "https://files.pythonhosted.org/packages/b6/45/bba86413b910b708eca705a5af62163d5d396d5f647ed9485580c7025209/regex-2025.9.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cc9e5525cada99699ca9223cce2d52e88c52a3d2a0e842bd53de5497c604164", size = 801827 },
{ url = "https://files.pythonhosted.org/packages/b8/a6/740fbd9fcac31a1305a8eed30b44bf0f7f1e042342be0a4722c0365ecfca/regex-2025.9.18-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bbb9246568f72dce29bcd433517c2be22c7791784b223a810225af3b50d1aafb", size = 786843 },
{ url = "https://files.pythonhosted.org/packages/80/a7/0579e8560682645906da640c9055506465d809cb0f5415d9976f417209a6/regex-2025.9.18-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6a52219a93dd3d92c675383efff6ae18c982e2d7651c792b1e6d121055808743", size = 857430 },
{ url = "https://files.pythonhosted.org/packages/8d/9b/4dc96b6c17b38900cc9fee254fc9271d0dde044e82c78c0811b58754fde5/regex-2025.9.18-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ae9b3840c5bd456780e3ddf2f737ab55a79b790f6409182012718a35c6d43282", size = 848612 },
{ url = "https://files.pythonhosted.org/packages/b3/6a/6f659f99bebb1775e5ac81a3fb837b85897c1a4ef5acffd0ff8ffe7e67fb/regex-2025.9.18-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d488c236ac497c46a5ac2005a952c1a0e22a07be9f10c3e735bc7d1209a34773", size = 787967 },
{ url = "https://files.pythonhosted.org/packages/61/35/9e35665f097c07cf384a6b90a1ac11b0b1693084a0b7a675b06f760496c6/regex-2025.9.18-cp314-cp314-win32.whl", hash = "sha256:0c3506682ea19beefe627a38872d8da65cc01ffa25ed3f2e422dffa1474f0788", size = 269847 },
{ url = "https://files.pythonhosted.org/packages/af/64/27594dbe0f1590b82de2821ebfe9a359b44dcb9b65524876cd12fabc447b/regex-2025.9.18-cp314-cp314-win_amd64.whl", hash = "sha256:57929d0f92bebb2d1a83af372cd0ffba2263f13f376e19b1e4fa32aec4efddc3", size = 278755 },
{ url = "https://files.pythonhosted.org/packages/30/a3/0cd8d0d342886bd7d7f252d701b20ae1a3c72dc7f34ef4b2d17790280a09/regex-2025.9.18-cp314-cp314-win_arm64.whl", hash = "sha256:6a4b44df31d34fa51aa5c995d3aa3c999cec4d69b9bd414a8be51984d859f06d", size = 271873 },
{ url = "https://files.pythonhosted.org/packages/99/cb/8a1ab05ecf404e18b54348e293d9b7a60ec2bd7aa59e637020c5eea852e8/regex-2025.9.18-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b176326bcd544b5e9b17d6943f807697c0cb7351f6cfb45bf5637c95ff7e6306", size = 489773 },
{ url = "https://files.pythonhosted.org/packages/93/3b/6543c9b7f7e734d2404fa2863d0d710c907bef99d4598760ed4563d634c3/regex-2025.9.18-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0ffd9e230b826b15b369391bec167baed57c7ce39efc35835448618860995946", size = 291221 },
{ url = "https://files.pythonhosted.org/packages/cd/91/e9fdee6ad6bf708d98c5d17fded423dcb0661795a49cba1b4ffb8358377a/regex-2025.9.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec46332c41add73f2b57e2f5b642f991f6b15e50e9f86285e08ffe3a512ac39f", size = 289268 },
{ url = "https://files.pythonhosted.org/packages/94/a6/bc3e8a918abe4741dadeaeb6c508e3a4ea847ff36030d820d89858f96a6c/regex-2025.9.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b80fa342ed1ea095168a3f116637bd1030d39c9ff38dc04e54ef7c521e01fc95", size = 806659 },
{ url = "https://files.pythonhosted.org/packages/2b/71/ea62dbeb55d9e6905c7b5a49f75615ea1373afcad95830047e4e310db979/regex-2025.9.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4d97071c0ba40f0cf2a93ed76e660654c399a0a04ab7d85472239460f3da84b", size = 871701 },
{ url = "https://files.pythonhosted.org/packages/6a/90/fbe9dedb7dad24a3a4399c0bae64bfa932ec8922a0a9acf7bc88db30b161/regex-2025.9.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0ac936537ad87cef9e0e66c5144484206c1354224ee811ab1519a32373e411f3", size = 913742 },
{ url = "https://files.pythonhosted.org/packages/f0/1c/47e4a8c0e73d41eb9eb9fdeba3b1b810110a5139a2526e82fd29c2d9f867/regex-2025.9.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec57f96d4def58c422d212d414efe28218d58537b5445cf0c33afb1b4768571", size = 811117 },
{ url = "https://files.pythonhosted.org/packages/2a/da/435f29fddfd015111523671e36d30af3342e8136a889159b05c1d9110480/regex-2025.9.18-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48317233294648bf7cd068857f248e3a57222259a5304d32c7552e2284a1b2ad", size = 794647 },
{ url = "https://files.pythonhosted.org/packages/23/66/df5e6dcca25c8bc57ce404eebc7342310a0d218db739d7882c9a2b5974a3/regex-2025.9.18-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:274687e62ea3cf54846a9b25fc48a04459de50af30a7bd0b61a9e38015983494", size = 866747 },
{ url = "https://files.pythonhosted.org/packages/82/42/94392b39b531f2e469b2daa40acf454863733b674481fda17462a5ffadac/regex-2025.9.18-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a78722c86a3e7e6aadf9579e3b0ad78d955f2d1f1a8ca4f67d7ca258e8719d4b", size = 853434 },
{ url = "https://files.pythonhosted.org/packages/a8/f8/dcc64c7f7bbe58842a8f89622b50c58c3598fbbf4aad0a488d6df2c699f1/regex-2025.9.18-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06104cd203cdef3ade989a1c45b6215bf42f8b9dd705ecc220c173233f7cba41", size = 798024 },
{ url = "https://files.pythonhosted.org/packages/20/8d/edf1c5d5aa98f99a692313db813ec487732946784f8f93145e0153d910e5/regex-2025.9.18-cp314-cp314t-win32.whl", hash = "sha256:2e1eddc06eeaffd249c0adb6fafc19e2118e6308c60df9db27919e96b5656096", size = 273029 },
{ url = "https://files.pythonhosted.org/packages/a7/24/02d4e4f88466f17b145f7ea2b2c11af3a942db6222429c2c146accf16054/regex-2025.9.18-cp314-cp314t-win_amd64.whl", hash = "sha256:8620d247fb8c0683ade51217b459cb4a1081c0405a3072235ba43a40d355c09a", size = 282680 },
{ url = "https://files.pythonhosted.org/packages/1f/a3/c64894858aaaa454caa7cc47e2f225b04d3ed08ad649eacf58d45817fad2/regex-2025.9.18-cp314-cp314t-win_arm64.whl", hash = "sha256:b7531a8ef61de2c647cdf68b3229b071e46ec326b3138b2180acb4275f470b01", size = 273034 },
]
[[package]]
@ -984,28 +1115,45 @@ wheels = [
[[package]]
name = "ruff"
version = "0.13.0"
version = "0.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863 }
sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826 },
{ url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428 },
{ url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543 },
{ url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489 },
{ url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631 },
{ url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602 },
{ url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751 },
{ url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317 },
{ url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418 },
{ url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843 },
{ url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891 },
{ url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119 },
{ url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594 },
{ url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377 },
{ url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555 },
{ url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613 },
{ url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250 },
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357 },
{ url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308 },
{ url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258 },
{ url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554 },
{ url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181 },
{ url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599 },
{ url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178 },
{ url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474 },
{ url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531 },
{ url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267 },
{ url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120 },
{ url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084 },
{ url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105 },
{ url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284 },
{ url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314 },
{ url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360 },
{ url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448 },
{ url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458 },
{ url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893 },
]
[[package]]
name = "selenium"
version = "4.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "trio" },
{ name = "trio-websocket" },
{ name = "typing-extensions" },
{ name = "urllib3", extra = ["socks"] },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/67/9016942b5781843cfea6f5bc1383cea852d9fa08f85f55a0547874525b5c/selenium-4.35.0.tar.gz", hash = "sha256:83937a538afb40ef01e384c1405c0863fa184c26c759d34a1ebbe7b925d3481c", size = 907991 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/ef/d0e033e1b3f19a0325ce03863b68d709780908381135fc0f9436dea76a7b/selenium-4.35.0-py3-none-any.whl", hash = "sha256:90bb6c6091fa55805785cf1660fa1e2176220475ccdb466190f654ef8eef6114", size = 9602106 },
]
[[package]]
@ -1048,12 +1196,52 @@ wheels = [
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 },
]
[[package]]
name = "trio"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
{ name = "idna" },
{ name = "outcome" },
{ name = "sniffio" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/01/c1/68d582b4d3a1c1f8118e18042464bb12a7c1b75d64d75111b297687041e3/trio-0.30.0.tar.gz", hash = "sha256:0781c857c0c81f8f51e0089929a26b5bb63d57f927728a5586f7e36171f064df", size = 593776 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/8e/3f6dfda475ecd940e786defe6df6c500734e686c9cd0a0f8ef6821e9b2f2/trio-0.30.0-py3-none-any.whl", hash = "sha256:3bf4f06b8decf8d3cf00af85f40a89824669e2d033bb32469d34840edcfc22a5", size = 499194 },
]
[[package]]
name = "trio-websocket"
version = "0.12.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "outcome" },
{ name = "trio" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221 },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
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 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 },
]
[[package]]
@ -1086,6 +1274,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
]
[package.optional-dependencies]
socks = [
{ name = "pysocks" },
]
[[package]]
name = "w3lib"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/7d/1172cfaa1e29beb9bf938e484c122b3bdc82e8e37b17a4f753ba6d6e009f/w3lib-2.3.1.tar.gz", hash = "sha256:5c8ac02a3027576174c2b61eb9a2170ba1b197cae767080771b6f1febda249a4", size = 49531 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/dd/56f0d8af71e475ed194d702f8b4cf9cea812c95e82ad823d239023c6558c/w3lib-2.3.1-py3-none-any.whl", hash = "sha256:9ccd2ae10c8c41c7279cd8ad4fe65f834be894fe7bfdd7304b991fd69325847b", size = 21751 },
]
[[package]]
name = "websocket-client"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 },
]
[[package]]
name = "wsproto"
version = "1.2.0"
@ -1176,8 +1387,10 @@ dependencies = [
{ name = "defusedxml" },
{ name = "httpx" },
{ name = "httpx-curl-cffi" },
{ name = "jmespath" },
{ name = "multidict" },
{ name = "mutagen" },
{ name = "parsel" },
{ name = "platformdirs" },
{ name = "pycryptodome" },
{ name = "pyjson5" },
@ -1187,6 +1400,7 @@ dependencies = [
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
{ name = "python-socketio" },
{ name = "regex" },
{ name = "selenium" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "yt-dlp" },
{ name = "zipstream-ng" },
@ -1223,8 +1437,10 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "httpx" },
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
{ name = "jmespath", specifier = ">=1.0.1" },
{ name = "multidict", specifier = "==6.5.1" },
{ name = "mutagen" },
{ name = "parsel", specifier = ">=1.10.0" },
{ name = "platformdirs" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "pyinstaller", marker = "extra == 'installer'" },
@ -1235,6 +1451,7 @@ requires-dist = [
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
{ name = "python-socketio", specifier = ">=5.11.1" },
{ name = "regex" },
{ name = "selenium", specifier = ">=4.35.0" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "yt-dlp" },
{ name = "zipstream-ng", specifier = ">=1.8.0" },