Merge pull request #424 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

Added Web UI for task definitions & inspect handler
This commit is contained in:
Abdulmohsen 2025-09-23 19:49:09 +03:00 committed by GitHub
commit b105749121
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 4108 additions and 171 deletions

38
.vscode/settings.json vendored
View file

@ -41,6 +41,7 @@
"copyts",
"creationflags",
"cronsim",
"crosshairs",
"currsize",
"dailymotion",
"datas",
@ -81,6 +82,8 @@
"hookspath",
"httpx",
"imagetools",
"jmespath",
"jsonschema",
"kibibytes",
"lastgroup",
"levelno",
@ -112,6 +115,7 @@
"noninteractive",
"noprogress",
"onefile",
"parsel",
"pathex",
"pickleable",
"platformdirs",
@ -195,9 +199,39 @@
"json.schemas": [
{
"fileMatch": [
"**/var/config/tasks/*.json"
"**/tasks/*.json"
],
"url": "./app/library/task_handlers/task_definition.schema.json"
"url": "./app/schema/task_definition.json"
},
{
"fileMatch": [
"**/config/conditions.json"
],
"url": "./app/schema/conditions.json"
},
{
"fileMatch": [
"**/config/dl_fields.json"
],
"url": "./app/schema/dl_fields.json"
},
{
"fileMatch": [
"**/config/notifications.json"
],
"url": "./app/schema/notifications.json"
},
{
"fileMatch": [
"**/config/presets.json"
],
"url": "./app/schema/presets.json"
},
{
"fileMatch": [
"**/config/tasks.json"
],
"url": "./app/schema/tasks.json"
}
]
}

10
FAQ.md
View file

@ -44,6 +44,7 @@ or the `environment:` section in `compose.yaml` file.
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
> [!NOTE]
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
@ -262,7 +263,7 @@ Definitions are reloaded automatically when files change, so you can tweak them
`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.
> A machine-readable schema is available at `app/schema/task_definition.json` if you want to validate your JSON with editors or CI tools.
# How to generate POT tokens?
@ -479,3 +480,10 @@ For more information about the supported codecs, please refer to the [SegmentEnc
If GPU encoding fails and software encoding is used, you will have to restart the container to try GPU encoding again.
as we only test for GPU encoding once on first video stream.
# Allowing internal URLs requests
By default, YTPTube prevents requests to internal resources, for security reasons. However, if you want to allow requests to internal URLs, you can set the `YTP_ALLOW_INTERNAL_URLS` environment variable to `true`. This will allow requests to internal URLs.
We do not recommend enabling this option unless you know what you are doing, as it can expose your internal network to
potential security risks. This should only be used if it's truly needed.

View file

@ -15,14 +15,13 @@ includes features like scheduling downloads, sending notifications, and built-in
* Multi-download support.
* 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).
* Schedule channels or playlists to be downloaded automatically with support for creating custom download feeds from non-supported sites. 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.
* Queue multiple URLs at once.
* Powerful presets system for applying `yt-dlp` options.
* File browser.
* Powerful presets system for applying `yt-dlp` options. with a pre-made preset for media servers users.
* A simple file browser.
* A built in video player **with support for sidecar external subtitles**.
* Basic authentication support.
* Supports `curl-cffi`. See [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation)

View file

@ -186,7 +186,6 @@ class DownloadQueue(metaclass=Singleton):
if item.info.auto_start:
status[item_id] = "already started"
LOG.debug(f"Item {item.info.name()} already started.")
continue
item.info.auto_start = True
@ -200,7 +199,6 @@ class DownloadQueue(metaclass=Singleton):
)
status[item_id] = "started"
started = True
LOG.debug(f"Item {item.info.name()} marked as started.")
if started:
self.event.set()
@ -231,12 +229,10 @@ class DownloadQueue(metaclass=Singleton):
if item.started() or item.is_cancelled():
status[item_id] = "already started"
LOG.debug(f"Item {item.info.name()} already started.")
continue
if item.info.auto_start is False:
status[item_id] = "not started"
LOG.debug(f"Item {item.info.name()} is not set to auto-start.")
continue
item.info.auto_start = False
@ -249,7 +245,6 @@ class DownloadQueue(metaclass=Singleton):
message=f"Download '{item.info.title}' has been paused.",
)
status[item_id] = "paused"
LOG.debug(f"Item {item.info.name()} marked as paused.")
return status
@ -1078,19 +1073,19 @@ class DownloadQueue(metaclass=Singleton):
# No items could be processed, back off a bit to avoid busy-waiting.
if 0 == items_processed:
adaptive_sleep: float = min(adaptive_sleep * 1.5, max_sleep)
LOG.info(f"No download slots available. Backing off for {adaptive_sleep:.2f}s before next attempt.")
LOG.debug(f"No download slots available. Backing off for {adaptive_sleep:.2f}s before next attempt.")
else:
adaptive_sleep = 0.2
await asyncio.sleep(adaptive_sleep)
async def _download_live(self, _id: str, entry: Download) -> None:
LOG.info(f"Creating temporary worker for entry '{entry.info.name()}'.")
LOG.debug(f"Creating temporary worker for entry '{entry.info.name()}'.")
try:
await self._download_file(_id, entry)
finally:
LOG.info(f"Temporary worker for '{entry.info.name()}' completed.")
LOG.debug(f"Temporary worker for '{entry.info.name()}' completed.")
async def _download_file(self, id: str, entry: Download) -> None:
"""
@ -1165,11 +1160,9 @@ class DownloadQueue(metaclass=Singleton):
if self.is_paused() or self.queue.empty():
return
LOG.debug("Checking for stale items in the download queue.")
for _id, item in list(self.queue.items()):
item_ref = f"{_id=} {item.info.id=} {item.info.title=}"
if not item.is_stale():
LOG.debug(f"Item '{item_ref}' is not stale.")
continue
try:

View file

@ -54,11 +54,18 @@ DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60",
"name": "Info Reader Plugin",
"description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary',
"folder": "youtube",
"template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s",
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s",
"cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
"default": True,
},
{
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f61",
"name": "NFO Maker TV",
"description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex.",
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(extractor)s-%(id)s].%(ext)s",
"cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log \n--windows-filenames --convert-thumbnails jpg --write-thumbnail \n--use-postprocessor NFOMakerTvPP",
"default": True,
},
]
@ -267,7 +274,7 @@ class Presets(metaclass=Singleton):
if not isinstance(item, dict):
if not isinstance(item, Preset):
msg = f"Unexpected '{type(item).__name__}' type was given."
raise ValueError(msg) # noqa: TRY004
raise ValueError(msg)
item = item.serialize()

View file

@ -0,0 +1,435 @@
import json
import logging
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from aiohttp import web
from jsonschema import Draft7Validator, SchemaError, ValidationError
from .config import Config
from .encoder import Encoder
from .Services import Services
from .Singleton import Singleton
LOG: logging.Logger = logging.getLogger("task_definitions")
@dataclass(slots=True)
class TaskDefinitionRecord:
identifier: str
"""UUID identifier of the task definition."""
filename: str
"""Filename of the task definition JSON file."""
name: str
"""Human-readable name of the task definition."""
priority: int
"""Priority of the task definition."""
path: Path
"""Path to the task definition JSON file."""
data: dict[str, Any]
"""The task definition data."""
updated_at: float
"""Last modified timestamp of the task definition file."""
def serialize(self, *, include_definition: bool = False) -> dict[str, Any]:
"""
Serialize the task definition record to a dictionary.
Args:
include_definition (bool): Whether to include the full task definition data.
Returns:
dict[str, Any]: The serialized task definition record.
"""
payload: dict[str, Any] = {
"id": self.identifier,
"name": self.name,
"priority": self.priority,
"updated_at": self.updated_at,
}
if include_definition:
payload["definition"] = self.data
return payload
def json(self, *, include_definition: bool = False) -> str:
"""
Serialize the task definition record to a JSON string.
Args:
include_definition (bool): Whether to include the full task definition data.
Returns:
str: The JSON string representation of the task definition record.
"""
return Encoder().encode(self.serialize(include_definition=include_definition))
class TaskDefinitions(metaclass=Singleton):
def __init__(
self,
directory: str | Path | None = None,
config: Config | None = None,
validator: Draft7Validator | None = None,
):
self._config: Config = config or Config.get_instance()
"Instance of Config to use."
self._directory: Path = Path(directory) if directory else Path(self._config.config_path) / "tasks"
"Directory where task definition files are stored."
self._validator: Draft7Validator | None = validator
"JSON schema validator instance."
self._items: dict[str, TaskDefinitionRecord] = {}
"Mapping of task definition ID to TaskDefinitionRecord."
self._schema: Path = Path(self._config.app_path) / "schema" / "task_definition.json"
"Path to the JSON schema file for task definitions."
try:
self._directory.mkdir(parents=True, exist_ok=True)
except Exception as e:
LOG.error(f"Failed to create tasks directory '{self._directory}': {e}")
self.load()
@staticmethod
def get_instance(
directory: str | Path | None = None,
config: Config | None = None,
validator: Draft7Validator | None = None,
) -> "TaskDefinitions":
"""
Get the singleton instance of TaskDefinitions.
Args:
directory (str | Path | None): Optional directory to store task definitions.
config (Config | None): Optional Config instance to use.
validator (Draft7Validator | None): Optional JSON schema validator to use.
Returns:
TaskDefinitions: The singleton instance of TaskDefinitions.
"""
return TaskDefinitions(directory=directory, config=config, validator=validator)
def attach(self, _: web.Application) -> None:
"""
Attach the TaskDefinitions service to the application.
Args:
_ (web.Application): The aiohttp web application instance.
"""
Services.get_instance().add("task_definitions", self)
async def on_shutdown(self, _: web.Application) -> None:
"""
Handle application shutdown event.
Args:
_ (web.Application): The aiohttp web application instance.
"""
return
def _get_validator(self) -> Draft7Validator:
"""
Get or create the JSON schema validator for task definitions.
Returns:
Draft7Validator: The JSON schema validator instance.
"""
if self._validator:
return self._validator
try:
contents: str = self._schema.read_text(encoding="utf-8")
schema = json.loads(contents)
except Exception as e:
LOG.error(f"Failed to read task definition schema '{self._schema}': {e}")
raise
try:
self._validator = Draft7Validator(schema)
except SchemaError as e:
LOG.error(f"Invalid task definition schema '{self._schema}': {e}")
raise
return self._validator
def validate(self, definition: dict[str, Any]) -> None:
"""
Validate a task definition against the JSON schema.
Args:
definition (dict[str, Any]): The task definition to validate.
Raises:
ValueError: If the task definition is invalid.
"""
try:
self._get_validator().validate(definition)
except ValidationError as e:
path: str = " ".join(str(part) for part in e.path)
error_path: str = f" ({path})" if path else ""
message: str = f"Task definition validation failed{error_path}: {e.message}"
raise ValueError(message) from e
def load(self) -> "TaskDefinitions":
"""
Load all task definitions from the directory.
Returns:
TaskDefinitions: The current instance for chaining.
"""
self._items.clear()
if not self._directory.exists():
return self
for file_path in sorted(self._directory.glob("*.json")):
stem: str = file_path.stem
try:
identifier_uuid = uuid.UUID(stem)
except ValueError:
LOG.warning(f"Skipping task definition with invalid UUID filename '{file_path.name}'.")
continue
if 4 != identifier_uuid.version:
LOG.warning(f"Skipping task definition '{file_path.name}', Name is not UUIDv4.")
continue
try:
contents: str = file_path.read_text(encoding="utf-8")
except Exception as e:
LOG.error(f"Failed to load task definition '{file_path}': {e!s}")
continue
try:
parsed = json.loads(contents)
except Exception as e:
LOG.error(f"Failed to parse task definition '{file_path}': {e!s}")
continue
if not isinstance(parsed, dict):
LOG.error(f"Invalid task definition file '{file_path}': must be a JSON object.")
continue
data: dict[str, Any] = parsed
identifier: str = str(identifier_uuid)
name_value: str = str(data.get("name") or identifier)
priority: int = self._normalize_priority(data.get("priority", 0))
data["priority"] = priority
record = TaskDefinitionRecord(
identifier=identifier,
filename=file_path.name,
name=name_value,
priority=priority,
path=file_path,
data=data,
updated_at=file_path.stat().st_mtime,
)
self._items[record.identifier] = record
return self
def list(self) -> list[TaskDefinitionRecord]:
"""
List all task definitions, sorted by priority and name.
Returns:
list[TaskDefinitionRecord]: List of task definitions sorted by priority and name.
"""
return sorted(
self._items.values(),
key=lambda record: (record.priority, record.name.lower()),
)
def get(self, identifier: str) -> TaskDefinitionRecord | None:
"""
Get a task definition by its identifier.
Args:
identifier (str): The UUID identifier of the task definition.
Returns:
TaskDefinitionRecord | None: The task definition record, or None if not found.
"""
return self._items.get(identifier)
def _path_for(self, identifier: str) -> Path:
"""
Get the file path for a given task definition identifier.
Args:
identifier (str): The UUID identifier of the task definition.
Returns:
Path: The file path for the task definition JSON file.
"""
return self._directory / f"{identifier}.json"
def _write_file(self, path: Path, payload: dict[str, Any]) -> None:
"""
Write a task definition to a file.
Args:
path (Path): The file path to write to.
payload (dict[str, Any]): The task definition data to write.
Raises:
Exception: If writing to the file fails.
"""
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
try:
path.chmod(0o600)
except Exception:
pass
def _normalize_priority(self, value: Any) -> int:
"""
Normalize the priority value to an integer.
Args:
value (Any): The priority value to normalize.
Returns:
int: The normalized priority value.
"""
try:
priority = int(value)
except Exception:
priority = 0
return priority
def _refresh_generic_handler(self) -> None:
"""
Refresh the generic task handler definitions.
"""
try:
from .task_handlers.generic import GenericTaskHandler
GenericTaskHandler.refresh_definitions(force=True)
except Exception as e:
LOG.error(f"Failed to refresh generic task handler: {e}")
def create(self, definition: dict[str, Any]) -> TaskDefinitionRecord:
"""
Create a new task definition.
Args:
definition (dict[str, Any]): The task definition data.
Returns:
TaskDefinitionRecord: The created task definition record.
"""
self.validate(definition)
identifier: str = str(uuid.uuid4())
path: Path = self._path_for(identifier)
while path.exists():
identifier = str(uuid.uuid4())
path = self._path_for(identifier)
priority: int = self._normalize_priority(definition.get("priority", 0))
definition["priority"] = priority
self._write_file(path, definition)
record = TaskDefinitionRecord(
identifier=identifier,
filename=path.name,
name=str(definition.get("name", identifier)),
priority=priority,
path=path,
data=definition,
updated_at=path.stat().st_mtime,
)
self._items[identifier] = record
self._refresh_generic_handler()
return record
def update(self, identifier: str, definition: dict[str, Any]) -> TaskDefinitionRecord:
"""
Update an existing task definition.
Args:
identifier (str): The UUID identifier of the task definition to update.
definition (dict[str, Any]): The updated task definition data.
Returns:
TaskDefinitionRecord: The updated task definition record.
Raises:
ValueError: If the task definition does not exist or is invalid.
"""
record: TaskDefinitionRecord | None = self.get(identifier)
if not record:
message: str = f"Task definition '{identifier}' does not exist."
raise ValueError(message)
self.validate(definition)
priority: int = self._normalize_priority(definition.get("priority", record.priority))
definition["priority"] = priority
self._write_file(record.path, definition)
updated_record = TaskDefinitionRecord(
identifier=identifier,
filename=record.filename,
name=str(definition.get("name", identifier)),
priority=priority,
path=record.path,
data=definition,
updated_at=record.path.stat().st_mtime,
)
self._items[identifier] = updated_record
self._refresh_generic_handler()
return updated_record
def delete(self, identifier: str) -> None:
"""
Delete a task definition.
Args:
identifier (str): The UUID identifier of the task definition to delete.
Raises:
ValueError: If the task definition does not exist.
"""
record: TaskDefinitionRecord | None = self.get(identifier)
if not record:
message: str = f"Task definition '{identifier}' does not exist."
raise ValueError(message)
try:
record.path.unlink(missing_ok=False)
except FileNotFoundError:
LOG.warning(f"Task definition file '{record.path}' already removed.")
except Exception as exc:
LOG.error(f"Failed to delete task definition '{identifier}': {exc}")
raise
self._items.pop(identifier, None)
self._refresh_generic_handler()

View file

@ -417,7 +417,7 @@ class Tasks(metaclass=Singleton):
if not isinstance(task, dict):
if not isinstance(task, Task):
msg = "Invalid task type."
raise ValueError(msg) # noqa: TRY004
raise ValueError(msg)
task = task.serialize()

View file

@ -180,7 +180,7 @@ class Conditions(metaclass=Singleton):
if not isinstance(item, dict):
if not isinstance(item, Condition):
msg = f"Unexpected '{type(item).__name__}' item type."
raise ValueError(msg) # noqa: TRY004
raise ValueError(msg)
item = item.serialize()
@ -211,7 +211,7 @@ class Conditions(metaclass=Singleton):
if not isinstance(item.get("extras"), dict):
msg = "Extras must be a dictionary."
raise ValueError(msg) # noqa: TRY004
raise ValueError(msg)
return True

View file

@ -27,6 +27,9 @@ class Config(metaclass=Singleton):
app_env: str = "production"
"""The application environment, can be 'production' or 'development'."""
app_path: str = "../../"
"""The app path of the application."""
config_path: str = "."
"""The path to the configuration directory."""
@ -48,6 +51,9 @@ class Config(metaclass=Singleton):
temp_disabled: bool = False
"""Disable the temporary files feature."""
allow_internal_urls: bool = False
"""Allow requests to internal URLs."""
output_template: str = "%(title)s.%(ext)s"
"""The output template to use for the downloaded files."""
@ -194,6 +200,7 @@ class Config(metaclass=Singleton):
"temp_path",
"config_path",
"download_path",
"app_path",
)
"The variables that are set manually."
@ -236,6 +243,7 @@ class Config(metaclass=Singleton):
"ytdlp_auto_update",
"prevent_premiere_live",
"temp_disabled",
"allow_internal_urls",
)
"The variables that are booleans."
@ -289,6 +297,7 @@ class Config(metaclass=Singleton):
self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or str(
Path(baseDefaultPath) / "var" / "downloads"
)
self.app_path = Path(__file__).parent.parent.absolute()
envFile: str = Path(self.config_path) / ".env"
@ -428,6 +437,21 @@ class Config(metaclass=Singleton):
if "dev-master" == self.app_version:
self._version_via_git()
def set_app_path(self, path: Path | str) -> "Config":
"""
Set the root path of the application.
Args:
path (str): The root path to set.
Returns:
Config: The Config instance.
"""
Config.app_path = str(path)
return self
def _get_attributes(self) -> dict:
attrs: dict = {}
vClass: str = self.__class__

View file

@ -228,7 +228,7 @@ class DLFields(metaclass=Singleton):
if not isinstance(item, dict):
if not isinstance(item, DLField):
msg = f"Unexpected '{type(item).__name__}' type was given."
raise ValueError(msg) # noqa: TRY004
raise ValueError(msg)
item = item.serialize()
@ -255,7 +255,7 @@ class DLFields(metaclass=Singleton):
if not isinstance(item.get("extras", {}), dict):
msg = "Extras must be a dictionary."
raise ValueError(msg) # noqa: TRY004
raise ValueError(msg)
if re.match(r"^--[a-zA-Z0-9\-]+$", item.get("field", "").strip()) is None:
msg = "Invalid yt-dlp option field it must starts with '--' and contain only alphanumeric characters."

View file

@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
import fnmatch
import hashlib
import json
import logging
import re
@ -607,6 +606,11 @@ class GenericTaskHandler(BaseHandler):
cls._definitions = load_task_definitions()
cls._sources_mtime = current
@classmethod
def refresh_definitions(cls, force: bool = False) -> None:
"""Public helper to refresh cached task definitions."""
cls._refresh_definitions(force=force)
@classmethod
def _find_definition(cls, url: str) -> TaskDefinition | None:
"""
@ -680,6 +684,12 @@ class GenericTaskHandler(BaseHandler):
task_items: list[TaskItem] = []
def _generic_id(url):
import os
import urllib
return urllib.parse.unquote(os.path.splitext(url.rstrip("/").split("/")[-1])[0])
for entry in raw_items:
if not isinstance(entry, dict):
continue
@ -693,7 +703,8 @@ class GenericTaskHandler(BaseHandler):
LOG.warning(
f"[{definition.name}]: '{task.name}': Could not compute archive ID for video '{url}' in feed. generating one."
)
archive_id = f"generic {hashlib.sha256(url.encode()).hexdigest()[:16]}"
archive_id = f"generic {_generic_id(url)}"
metadata: dict[str, str] = {
k: v for k, v in entry.items() if k not in {"link", "url", "title", "published", "archive_id"}

View file

@ -89,6 +89,14 @@ class YTDLP(yt_dlp.YoutubeDL):
self.write_debug(f"Adding to archive: {archive_id}")
self.archive.add(archive_id)
old_archive_ids = info_dict.get("_old_archive_ids", [])
if old_archive_ids and isinstance(old_archive_ids, list) and len(old_archive_ids) > 0:
for old_id in old_archive_ids:
if old_id == archive_id or not old_id.startswith("generic "):
continue
self.write_debug(f"Adding to archive (old id): {old_id}")
self.archive.add(old_id)
def ytdlp_options() -> list[dict[str, Any]]:

View file

@ -28,6 +28,7 @@ from app.library.Notifications import Notification
from app.library.Presets import Presets
from app.library.Scheduler import Scheduler
from app.library.Services import Services
from app.library.TaskDefinitions import TaskDefinitions
from app.library.Tasks import Tasks
LOG = logging.getLogger("app")
@ -39,6 +40,7 @@ ROOT_PATH: Path = Path(__file__).parent.absolute()
class Main:
def __init__(self, is_native: bool = False):
self._config: Config = Config.get_instance(is_native=is_native)
self._config.set_app_path(str(ROOT_PATH))
self._app = web.Application()
self._app.on_shutdown.append(self.on_shutdown)
self._background_worker = BackgroundWorker()
@ -132,6 +134,7 @@ class Main:
Notification.get_instance().attach(self._app)
Conditions.get_instance().attach(self._app)
DLFields.get_instance().attach(self._app)
TaskDefinitions.get_instance().attach(self._app)
self._background_worker.attach(self._app)
EventBus.get_instance().emit(

View file

@ -1,24 +1,54 @@
from __future__ import annotations
import hashlib
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from yt_dlp.postprocessor.common import PostProcessor
from yt_dlp.utils import hyphenate_date
if TYPE_CHECKING:
from collections.abc import Iterable
class NFOMaker(PostProcessor):
"""
A Post-Processor that writes metadata to NFO file.
A Post-Processor that writes metadata to an NFO file using a simple
placeholder-based template engine.
Mapping value semantics:
- "field" -> use info["field"]
- ("field1", "field2", ...) -> first non-empty among fields
- ("date_field", "%Y") -> year from date_field
"""
_MAPPING: dict = {}
_MAPPING: dict[str, Any] = {}
_TEMPLATE: str | None = None
_date_fields: tuple = ("upload_date", "release_date")
_DATE_FIELDS: tuple[str, ...] = ("upload_date", "release_date", "aired", "premiered")
_URL_PAT = re.compile(
r"(?i)\b(?:https?://|ftp://|www\.)\S+|\b(?!@)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?){1,}\b(?:/[^\s<>()]*)?"
)
_MD_LINK = re.compile(r"\[([^\]]+)\]\((?:[^)]+)\)")
_TIME_LINE_PAT = re.compile(r"^\s*(?:\d+:)?\d{1,2}:\d{2}(?::\d{2})?(?:\s*[-–—•:]\s*.*)?$", re.IGNORECASE) # noqa: RUF001
_HASHTAGS_LINE = re.compile(r"^\s*(?:#[\w\-]+(?:\s+|$))+$")
_MENTION_LINE = re.compile(r"^\s*@[\w.\-]{2,}\s*$")
_PROMO_LINE_PAT = re.compile(
r"(?i)\b("
r"subscribe|follow|donate|patreon|paypal|sponsor|promo\s*code|coupon|discount|"
r"business\s*inquiries?|contact\s*me|email\s*me|merch|store|shop|join\s+my|"
r"discord|instagram|twitter|x\.com|facebook|twitch|tiktok|github|gitlab|linkedin|"
r"snapchat|reddit|telegram|t\.me|whatsapp|link\s+in\s+bio|bit\.ly|tinyurl|goo\.gl"
r")\b"
)
@classmethod
def pp_key(cls) -> str:
return "NFOMaker"
def run(self, info: dict = None) -> tuple[list, dict]:
def run(self, info: dict | None = None) -> tuple[list, dict]:
if not info:
self.to_screen("No info provided to NFO Maker.")
return [], {}
@ -27,124 +57,292 @@ class NFOMaker(PostProcessor):
self.to_screen("NFO template not set, skipping NFO creation.")
return [], info
nfo_file = Path(info.get("filename")).with_suffix(".nfo")
# prefer explicit final path if present, else fall back to filename
base_path = info.get("filename")
if not base_path:
self.to_screen("No 'filename' provided, skipping NFO creation.")
return [], info
base_path = Path(base_path)
nfo_file = base_path.with_suffix(".nfo")
if nfo_file.exists():
self.to_screen(f"NFO file '{nfo_file!s}' already exists, skipping creation.")
return [], info
nfo_data: dict = {}
try:
nfo_data = self._collect_nfo_data(info)
except Exception as e:
if self._downloader:
self._downloader.report_error(f"NFO data collection failed: {e}")
return [], info
for nfo_name, ytdlp_name in self._MAPPING.items():
try:
if isinstance(ytdlp_name, str):
ytdlp_name = (ytdlp_name,)
_key = None
_value = None
for name in ytdlp_name:
if name is None:
continue
_key = name
_value = info.get(name)
if _value is not None:
break
if _value:
if _key in self._date_fields:
_value = hyphenate_date(_value)
if "description" == _key:
_value = _value.replace("\n", " ").replace("\r", " ").strip()
if _value:
nfo_data[nfo_name] = _value
except Exception as e:
if self._downloader:
self._downloader.report_error(f"Error processing {nfo_name} -> {ytdlp_name}: {e}")
if len(nfo_data) < 1:
if 1 > len(nfo_data):
self.to_screen("No metadata found to write to NFO file.")
return [], info
if "year" not in nfo_data and (k in nfo_data for k in self._date_fields):
# derive year from any date if missing
if ("year" not in nfo_data) and any(k in nfo_data for k in self._DATE_FIELDS):
try:
_date = any(nfo_data.get(k) for k in self._date_fields if k in nfo_data)
if _date:
nfo_data["year"] = _date.split("-")[0]
first_date = next((str(nfo_data[k]) for k in self._DATE_FIELDS if nfo_data.get(k)), "")
if first_date:
nfo_data["year"] = first_date.split("-")[0]
except Exception as e:
self.to_screen(f"Error extracting year from date: {e}")
file_path = Path(info["filepath"])
status = self._write_episode_info(nfo_file, file_path, nfo_data)
if status and nfo_file.exists():
mtime = file_path.stat().st_mtime
self.try_utime(str(nfo_file), mtime, mtime)
status = self._write_episode_info(nfo_file, base_path, nfo_data)
if status and nfo_file.exists() and base_path.exists:
try:
mtime = base_path.stat().st_mtime
self.try_utime(str(nfo_file), mtime, mtime)
except Exception as e:
self.to_screen(f"Failed to sync NFO mtime: {e}")
return [], info
def _collect_nfo_data(self, info: dict) -> dict[str, Any]:
data: dict[str, Any] = {}
for nfo_name, spec in self._MAPPING.items():
try:
values = spec if isinstance(spec, tuple) else (spec,)
resolved_key = None
resolved_val: Any = None
for item in values:
# ("field", "%Y") -> extract/format from this field
if isinstance(item, tuple) and 2 == len(item) and isinstance(item[1], str):
field, fmt = item
if field is None:
continue
raw = info.get(field)
if raw is None:
continue
resolved_key = field
resolved_val = self._coerce_value(raw, fmt)
if resolved_val not in (None, ""):
break
continue
# "field" -> plain field
if isinstance(item, str):
resolved_key = item
resolved_val = info.get(item)
if resolved_val not in (None, ""):
break
if resolved_val in (None, ""):
continue
# normalize dates if source key is a known date
if resolved_key in self._DATE_FIELDS:
resolved_val = self._normalize_date(resolved_val)
# collapse multiline descriptions
if "description" == resolved_key and isinstance(resolved_val, str):
resolved_val = self._clean_description(resolved_val)
if resolved_val not in (None, ""):
data[nfo_name] = resolved_val
except Exception as e:
if self._downloader:
self._downloader.report_error(f"Error processing {nfo_name} -> {spec}: {e}")
return data
def _write_episode_info(self, nfo_file: Path, real_file: Path, data: dict) -> bool:
year, month, day = data.get("aired", "0000-00-00").split("-")
aired = str(
data.get("aired") or data.get("premiered") or data.get("release_date") or data.get("upload_date") or ""
)
aired = self._normalize_date(aired) if aired else ""
if not aired or 3 > len(aired.split("-")):
self.to_screen("Invalid aired/premiered date, skipping NFO creation.")
return False
year, month, day = aired.split("-")
if not (year and month and day):
self.to_screen("Invalid aired date format, skipping NFO creation.")
self.to_screen("Invalid aired date parts, skipping NFO creation.")
return False
self.to_screen(f"Creating NFO file at {nfo_file!s}")
nfo_file.parent.mkdir(parents=True, exist_ok=True)
nfo_file.touch(exist_ok=True)
dt = datetime(int(year), int(month), int(day), tzinfo=UTC)
data["unique_id"] = "1{:>02}{:>02}{:>04}".format(
dt.strftime("%m"), dt.strftime("%d"), self._extend_id(real_file)
)
data = dict(data) # do not mutate original
data["unique_id"] = self._build_unique_id(dt, real_file)
self._write(nfo_file=nfo_file, text=self._TEMPLATE, repl=data)
self._write(nfo_file=nfo_file, text=self._TEMPLATE or "", repl=data)
return True
@staticmethod
def _extend_id(file: Path) -> int:
def _build_unique_id(dt: datetime, file: Path) -> str:
# 1MMDD + 4-digit stable hash from lowercase stem
h = hashlib.sha256(file.stem.lower().encode("utf-8")).hexdigest()
ascii_stream = "".join(str(ord(c)) for c in h)
suffix = ascii_stream[:4] if 4 <= len(ascii_stream) else ascii_stream.ljust(4, "9")
return f"1{dt.strftime('%m')}{dt.strftime('%d')}{suffix}"
@staticmethod
def _normalize_date(val: Any) -> str:
"""
Parse date-like values into 'YYYY-MM-DD' format.
Args:
val: Any date-like value.
Accepts:
- 'YYYYMMDD'
- 'YYYY-MM-DD'
- datetime / date
Returns:
str: 'YYYY-MM-DD' or empty string if unparsable.
"""
try:
import hashlib
hash_object = hashlib.sha256(file.stem.lower().encode("utf-8"))
hash_hex: str = hash_object.hexdigest()
ascii_values: str = "".join([str(ord(c)) for c in hash_hex])
four_digit_string: str = ascii_values[:4] if len(ascii_values) >= 4 else ascii_values.ljust(4, "9")
return int(four_digit_string)
if isinstance(val, datetime):
return val.strftime("%Y-%m-%d")
s = str(val).strip()
if 8 == len(s) and s.isdigit(): # YYYYMMDD
return hyphenate_date(s)
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", s):
return s
# try yt-dlp helper even for odd inputs
return hyphenate_date(s)
except Exception:
return 1000
return ""
def _write(self, nfo_file: Path, text: str, repl: dict):
@staticmethod
def _coerce_value(raw: Any, fmt: str) -> Any:
"""
Support simple date-based formatting: ("%Y", "%m", "%d", etc.)
Args:
raw (Any): Raw value.
fmt (str): Date format string.
Returns:
Any: Formatted value.
"""
if raw in (None, ""):
return raw
if fmt and isinstance(fmt, str):
date_s = NFOMaker._normalize_date(raw)
if date_s:
try:
dt = datetime.now(tz=UTC).strptime(date_s, "%Y-%m-%d")
return dt.strftime(fmt)
except Exception:
return ""
return raw
def _write(self, nfo_file: Path, text: str, repl: dict[str, Any]) -> None:
"""
Write the NFO file, replacing placeholders in the template.
Args:
nfo_file (Path): Path to the NFO file.
text (str): Template text with placeholders.
repl (dict[str, Any]): Replacement dictionary.
"""
from xml.sax.saxutils import escape
for key, value in repl.items():
if isinstance(value, str):
repl[key] = escape(value)
# escape XML on a copy
safe_repl: dict[str, Any] = {}
for k, v in repl.items():
if isinstance(v, str):
safe_repl[k] = escape(v)
else:
safe_repl[k] = v
for key, value in repl.items():
# replace placeholders
rendered = text
for key, value in safe_repl.items():
if value is None:
continue
text = text.replace(f"{{{key}}}", str(value))
rendered = rendered.replace(f"{{{key}}}", str(value))
for key in {**self._MAPPING, **repl}:
if f"{{{key}}}" in text:
self.write_debug(f"Missing replacement for '{key}'.")
text = "\n".join(line for line in text.splitlines() if f"{{{key}}}" not in line)
# remove any unresolved placeholder lines
unresolved_keys: Iterable[str] = set({*self._MAPPING.keys(), *safe_repl.keys()})
pattern = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*")
rendered = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line))
try:
if not nfo_file.parent.exists():
nfo_file.parent.mkdir(parents=True, exist_ok=True)
nfo_file.write_text(text, encoding="utf-8")
nfo_file.write_text(rendered, encoding="utf-8")
self.to_screen(f"NFO file written successfully at {nfo_file!s}")
except Exception as e:
self.to_screen(f"Error writing NFO file: {e}")
def _clean_description(self, text: str) -> str:
"""
Strip links, chapters/timestamps, pure hashtags/mentions, and promo lines.
Return a compact single-line summary suitable for NFO <plot>.
"""
if not isinstance(text, str):
return ""
# normalize newlines
lines = [ln.strip() for ln in text.replace("\r", "\n").split("\n")]
cleaned: list[str] = []
for ln in lines:
if not ln:
continue
# remove markdown links, keep labels
ln = self._MD_LINK.sub(r"\1", ln)
# drop lines that are clearly noise
if self._TIME_LINE_PAT.match(ln):
continue
if self._HASHTAGS_LINE.match(ln):
continue
if self._MENTION_LINE.match(ln):
continue
if self._PROMO_LINE_PAT.search(ln):
continue
# strip raw/bare urls and domains
ln = self._URL_PAT.sub("", ln)
# collapse leftover multiple spaces and stray separators
ln = re.sub(r"\s{2,}", " ", ln)
ln = re.sub(r"\s*[-–—•·]+\s*$", "", ln) # noqa: RUF001
if ln:
cleaned.append(ln)
# prefer first meaningful paragraphs; cap final length
summary = " ".join(cleaned)
summary = re.sub(r"\s{2,}", " ", summary).strip()
# optional minimum signal: if too short, fall back to original first sentence without links
if 8 > len(summary.split()):
fallback = self._URL_PAT.sub("", text)
fallback = re.sub(r"\s{2,}", " ", fallback).strip()
if 8 <= len(fallback.split()):
summary = fallback
# hard cap to keep NFO tidy
if 1200 < len(summary):
# cut at sentence boundary if possible
cut = summary[:1200]
dot = cut.rfind(". ")
summary = cut[: dot + 1] if 0 < dot else cut.rstrip()
return summary
class NFOMakerTvPP(NFOMaker):
_MAPPING = {
"title": "title",
"season": ("season_number", "season", "year", None),
"episode": ("episode_number", "episode"),
"season": ("season_number", "season", "year", ("release_date", "%Y"), ("upload_date", "%Y")),
"episode": ("episode_number", "episode", ("release_date", "%m%d"), ("upload_date", "%m%d"), "unique_id"),
"aired": ("release_date", "upload_date"),
"author": "uploader",
"plot": "description",
@ -153,15 +351,15 @@ class NFOMakerTvPP(NFOMaker):
}
_TEMPLATE = """
<episodedetails>
<title>{title}</title>
<season>{season}</season>
<episode>{episode}</episode>
<aired>{aired}</aired>
<uniqueid type="cmdb">{unique_id}</uniqueid>
<uniqueid type="{extractor}">{id}</uniqueid>
<plot>{plot}</plot>
<title>{title}</title>
<season>{season}</season>
<episode>{episode}</episode>
<aired>{aired}</aired>
<uniqueid type="cmdb">{unique_id}</uniqueid>
<uniqueid type="{extractor}">{id}</uniqueid>
<plot>{plot}</plot>
</episodedetails>
"""
""".strip("\n")
class NFOMakerMoviePP(NFOMaker):
@ -192,4 +390,4 @@ class NFOMakerMoviePP(NFOMaker):
<year>{year}</year>
<trailer>{trailer}</trailer>
</movie>
"""
""".strip("\n")

View file

@ -40,7 +40,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
try:
validate_url(url)
validate_url(url, allow_internal=config.allow_internal_urls)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)

View file

@ -0,0 +1,145 @@
import logging
from typing import Any
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.encoder import Encoder
from app.library.router import route
from app.library.TaskDefinitions import TaskDefinitionRecord, TaskDefinitions
LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/task_definitions/", "task_definitions")
async def task_definitions_list(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
include: str | None = request.query.get("include")
include_definition: bool = "definition" == include
return web.json_response(
data=[item.serialize(include_definition=include_definition) for item in task_definitions.list()],
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("GET", "api/task_definitions/{identifier}", "task_definitions_get")
async def task_definitions_get(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
identifier: str = request.match_info.get("identifier", "").strip()
if not identifier:
return web.json_response(
data={"error": "Missing task definition identifier."},
status=web.HTTPBadRequest.status_code,
)
record: TaskDefinitionRecord | None = task_definitions.get(identifier)
if not record:
return web.json_response(
data={"error": f"Task definition '{identifier}' not found."},
status=web.HTTPNotFound.status_code,
)
return web.json_response(
data=record.serialize(include_definition=True),
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("POST", "api/task_definitions/", "task_definitions_create")
async def task_definitions_create(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
try:
payload: Any = await request.json()
if not isinstance(payload, dict):
return web.json_response(
data={"error": "Invalid request body; expected JSON object."},
status=web.HTTPBadRequest.status_code,
)
if "definition" in payload:
if not isinstance(payload["definition"], dict):
return web.json_response(
data={"error": "definition must be a JSON object when provided."},
status=web.HTTPBadRequest.status_code,
)
payload = payload["definition"]
record: TaskDefinitionRecord = task_definitions.create(payload)
except (ValueError, TypeError) as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": "Failed to create task definition."},
status=web.HTTPInternalServerError.status_code,
)
return web.json_response(
data=record.serialize(include_definition=True),
status=web.HTTPCreated.status_code,
dumps=encoder.encode,
)
@route("PUT", "api/task_definitions/{identifier}", "task_definitions_update")
async def task_definitions_update(request: Request, encoder: Encoder, task_definitions: TaskDefinitions) -> Response:
identifier: str = request.match_info.get("identifier", "").strip()
if not identifier:
return web.json_response(
data={"error": "Missing task definition identifier."},
status=web.HTTPBadRequest.status_code,
)
try:
payload: Any = await request.json()
if not isinstance(payload, dict):
return web.json_response(
data={"error": "Invalid request body; expected JSON object."},
status=web.HTTPBadRequest.status_code,
)
if "definition" in payload:
if not isinstance(payload["definition"], dict):
return web.json_response(
data={"error": "definition must be a JSON object when provided."},
status=web.HTTPBadRequest.status_code,
)
payload = payload["definition"]
record: TaskDefinitionRecord = task_definitions.update(identifier, payload)
except (ValueError, TypeError) as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": "Failed to update task definition."},
status=web.HTTPInternalServerError.status_code,
)
return web.json_response(
data=record.serialize(include_definition=True),
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("DELETE", "api/task_definitions/{identifier}", "task_definitions_delete")
async def task_definitions_delete(request: Request, task_definitions: TaskDefinitions) -> Response:
identifier: str = request.match_info.get("identifier", "").strip()
if not identifier:
return web.json_response(
data={"error": "Missing task definition identifier."},
status=web.HTTPBadRequest.status_code,
)
try:
task_definitions.delete(identifier)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": "Failed to delete task definition."}, status=web.HTTPInternalServerError.status_code
)
return web.json_response(data={"status": "deleted"}, status=web.HTTPOk.status_code)

View file

@ -4,6 +4,7 @@ import uuid
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.router import route
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
@ -13,14 +14,14 @@ 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:
async def task_handler_inspect(request: Request, tasks: Tasks, encoder: Encoder, config: Config) -> 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)
validate_url(url, allow_internal=config.allow_internal_urls)
except ValueError as e:
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)

View file

@ -102,7 +102,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
)
try:
validate_url(url)
validate_url(url, allow_internal=config.allow_internal_urls)
except ValueError as e:
return web.json_response(
data={"status": False, "message": str(e), "error": str(e)},
@ -242,7 +242,7 @@ async def get_options() -> Response:
@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids")
async def get_archive_ids(request: Request) -> Response:
async def get_archive_ids(request: Request, config: Config) -> Response:
"""
Get the yt-dlp CLI options.
@ -264,7 +264,7 @@ async def get_archive_ids(request: Request) -> Response:
for i, url in enumerate(data):
dct = {"index": i, "url": url}
try:
validate_url(url)
validate_url(url, allow_internal=config.allow_internal_urls)
dct.update(get_archive_id(url))
except ValueError as e:
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)})

View file

@ -0,0 +1,56 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ytptube.app/schemas/conditions.json",
"title": "YTPTube Download Conditions",
"description": "Schema describing conditional rules evaluated against yt-dlp info dicts.",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"filter"
],
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the condition (UUID)."
},
"name": {
"type": "string",
"description": "Human-readable name for the condition."
},
"filter": {
"type": "string",
"description": "Filter expression to evaluate against info dict."
},
"cli": {
"type": "string",
"description": "yt-dlp CLI fragment to append if matched.",
"default": ""
},
"extras": {
"type": "object",
"description": "Any extra data to store with the condition.",
"default": {}
}
},
"examples": [
{
"id": "c1e2d3f4-5678-1234-9abc-def012345678",
"name": "Audio Only",
"filter": "type=audio",
"cli": "--extract-audio",
"extras": {
"priority": "high"
}
},
{
"id": "a2b3c4d5-6789-2345-0bcd-ef1234567890",
"name": "Long Videos",
"filter": "duration>3600"
}
]
}
}

78
app/schema/dl_fields.json Normal file
View file

@ -0,0 +1,78 @@
{
"$id": "https://ytptube.app/schema/dl_fields.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Download Field Definition",
"description": "Schema for custom yt-dlp field definitions used in YTPTube.",
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"name",
"description",
"field",
"kind"
],
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the field (UUID)."
},
"name": {
"type": "string",
"description": "Human-readable name for the field."
},
"description": {
"type": "string",
"description": "Description of the field."
},
"field": {
"type": "string",
"description": "yt-dlp field or CLI argument."
},
"kind": {
"type": "string",
"description": "Type of field (string, text, bool).",
"enum": [
"string",
"text",
"bool"
]
},
"icon": {
"type": "string",
"description": "Font-awesome icon for the field.",
"default": ""
},
"order": {
"type": "integer",
"description": "Order for sorting fields in the UI.",
"default": 0
},
"value": {
"type": "string",
"description": "Default value for the field.",
"default": ""
},
"extras": {
"type": "object",
"description": "Additional options for the field.",
"default": {}
}
},
"additionalProperties": false,
"examples": [
{
"id": "acffe9f6-993b-42ad-94ff-4646f48a83a9",
"name": "Delete cache?",
"description": "Delete cache",
"field": "--no-continue",
"kind": "bool",
"icon": "fa-solid fa-trash-arrow-up",
"order": 1,
"value": "",
"extras": {}
}
]
}
}

View file

@ -0,0 +1,175 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ytptube.app/schema/notifications.json",
"title": "Notification Configuration",
"type": "array",
"description": "Schema describing webhook and Apprise notification targets stored in notifications.json.",
"items": {
"$ref": "#/definitions/target"
},
"definitions": {
"event": {
"type": "string",
"enum": [
"test",
"item_added",
"item_completed",
"item_cancelled",
"item_deleted",
"item_paused",
"item_resumed",
"item_moved",
"paused",
"resumed",
"log_info",
"log_success",
"log_warning",
"log_error",
"task_dispatched"
],
"description": "Event identifier emitted by the backend event bus."
},
"header": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"minLength": 1,
"description": "HTTP header name."
},
"value": {
"type": "string",
"minLength": 1,
"description": "HTTP header value."
}
}
},
"request": {
"type": "object",
"additionalProperties": false,
"required": [
"type",
"method",
"url"
],
"properties": {
"type": {
"type": "string",
"enum": [
"json",
"form"
],
"default": "json",
"description": "Payload encoding strategy for the outgoing request."
},
"method": {
"type": "string",
"enum": [
"POST",
"PUT"
],
"default": "POST",
"description": "HTTP method used when delivering the notification payload."
},
"url": {
"type": "string",
"format": "uri",
"description": "Destination endpoint or Apprise URL that receives the notification."
},
"data_key": {
"type": "string",
"minLength": 1,
"default": "data",
"description": "JSON/Form field name used to wrap the serialized event payload."
},
"headers": {
"type": "array",
"items": {
"$ref": "#/definitions/header"
},
"description": "Optional static headers appended to each HTTP request.",
"default": []
}
}
},
"target": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"request"
],
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "Unique identifier for the notification target (UUIDv4)."
},
"name": {
"type": "string",
"minLength": 1,
"description": "Label shown in the UI for this notification target."
},
"on": {
"type": "array",
"items": {
"$ref": "#/definitions/event"
},
"description": "Subset of events that trigger this target; leave empty to listen to every event.",
"default": []
},
"presets": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "Restrict notifications to downloads created by these presets.",
"default": []
},
"request": {
"$ref": "#/definitions/request"
}
},
"examples": [
{
"id": "d40d4b6e-45b8-47a1-b1b2-9cf7f8e6c67a",
"name": "Webhook: Completed items",
"on": [
"item_completed"
],
"presets": [
"default"
],
"request": {
"type": "json",
"method": "POST",
"url": "https://hooks.example.com/ytptube",
"headers": [
{
"key": "Authorization",
"value": "Bearer token123"
}
]
}
},
{
"id": "9f783d0a-6e8b-4c1d-9089-5bb4ae0f2c47",
"name": "Apprise Notifier",
"on": [],
"request": {
"type": "json",
"method": "POST",
"url": "discord://webhook/token"
}
}
]
}
}
}

67
app/schema/presets.json Normal file
View file

@ -0,0 +1,67 @@
{
"$id": "https://ytptube.app/schema/presets.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Preset Configuration",
"description": "Schema for yt-dlp preset configurations used in YTPTube.",
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the preset (UUID or slug)."
},
"name": {
"type": "string",
"description": "Human-readable name for the preset.",
"minLength": 1
},
"description": {
"type": "string",
"description": "Optional description of the preset.",
"default": ""
},
"folder": {
"type": "string",
"description": "Default download folder for this preset.",
"default": ""
},
"template": {
"type": "string",
"description": "Default output template for this preset.",
"default": ""
},
"cookies": {
"type": "string",
"description": "Default cookies for this preset.",
"default": ""
},
"cli": {
"type": "string",
"description": "yt-dlp command line options for this preset.",
"default": ""
},
"default": {
"type": "boolean",
"description": "Whether this preset is a default preset.",
"default": false
}
},
"required": [
"id",
"name"
],
"examples": [
{
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
"name": "default",
"default": true,
"cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log",
"description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio."
},
{
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
"name": "Audio Only",
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
"description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail."
}
]
}

View file

@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ytptube.app/schemas/task-definition.json",
"$id": "https://ytptube.app/schemas/task_definition.json",
"title": "YTPTube Generic Task Definition",
"type": "object",
"required": [
@ -15,6 +15,12 @@
"minLength": 1,
"description": "Human-readable identifier for this definition."
},
"priority": {
"type": "integer",
"minimum": 0,
"description": "Optional ordering priority. Lower numbers are listed first.",
"default": 0
},
"match": {
"type": "array",
"minItems": 1,
@ -226,6 +232,34 @@
]
}
},
"allOf": [
{
"if": {
"properties": {
"engine": {
"properties": {
"type": { "const": "selenium" }
},
"required": ["type"]
}
},
"required": ["engine"]
},
"then": {
"properties": {
"engine": {
"properties": {
"options": {
"required": ["url"]
}
},
"required": ["options"]
}
},
"required": ["engine"]
}
}
],
"definitions": {
"stringMap": {
"type": "object",

84
app/schema/tasks.json Normal file
View file

@ -0,0 +1,84 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://ytptube.app/schemas/tasks.json",
"title": "YTPTube Scheduled Tasks",
"description": "Schema describing recurring download tasks stored in tasks.json.",
"type": "array",
"items": {
"$ref": "#/definitions/task"
},
"definitions": {
"task": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name",
"url"
],
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "Unique identifier for the task (UUIDv4)."
},
"name": {
"type": "string",
"minLength": 1,
"description": "Human-friendly task label used throughout the UI."
},
"url": {
"type": "string",
"format": "uri",
"pattern": "^https?://",
"description": "Playlist or channel URL monitored by the task."
},
"folder": {
"type": "string",
"description": "Optional download folder override for this task.",
"default": ""
},
"preset": {
"type": "string",
"description": "Preset name to apply; falls back to the global default when omitted.",
"default": ""
},
"timer": {
"type": "string",
"description": "Cron expression executed by the scheduler (five or six space-separated fields).",
"default": ""
},
"template": {
"type": "string",
"description": "Output filename template override for downloads created by this task.",
"default": ""
},
"cli": {
"type": "string",
"description": "Additional yt-dlp arguments appended to the download request.",
"default": ""
},
"auto_start": {
"type": "boolean",
"description": "Automatically queue new items discovered by the task.",
"default": true
},
"handler_enabled": {
"type": "boolean",
"description": "Controls whether the matched handler remains enabled for this task.",
"default": true
}
},
"examples": [
{
"id": "4d9be675-ecb8-4b42-9a84-8cee71a7a2e2",
"name": "Weekly playlist sync",
"url": "https://www.youtube.com/playlist?list=PL12345",
"preset": "default",
"timer": "0 */6 * * *",
"auto_start": true
}
]
}
}
}

View file

@ -0,0 +1,315 @@
import json
import uuid
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from aiohttp import web
from aiohttp.web import Request
from jsonschema import Draft7Validator
from app.library.encoder import Encoder
from app.library.TaskDefinitions import TaskDefinitionRecord, TaskDefinitions
from app.routes.api import task_definitions as api
def _load_validator() -> Draft7Validator:
schema_path = Path(__file__).resolve().parent.parent / "schema" / "task_definition.json"
schema = json.loads(schema_path.read_text(encoding="utf-8"))
return Draft7Validator(schema)
def _sample_definition(name: str = "example", *, priority: int = 0) -> dict[str, Any]:
return {
"name": name,
"match": ["https://example.com/*"],
"priority": priority,
"parse": {
"items": {
"type": "css",
"selector": ".card",
"fields": {
"link": {"type": "css", "expression": "a", "attribute": "href"},
"title": {"type": "css", "expression": "a", "attribute": "text"},
},
}
},
}
class TestTaskDefinitionsManager:
def setup_method(self) -> None:
TaskDefinitions._reset_singleton()
def teardown_method(self) -> None:
TaskDefinitions._reset_singleton()
def test_load_populates_records(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
first_identifier = "b5c6ad5f-4745-4c05-88c8-dde1deae3b51"
second_identifier = "ae38a6b0-2c22-4763-ba60-801ae8ce1218"
first = tmp_path / f"{first_identifier}.json"
second = tmp_path / f"{second_identifier}.json"
first.write_text(json.dumps(_sample_definition("First", priority=5)), encoding="utf-8")
second.write_text(json.dumps(_sample_definition("Second", priority=1)), encoding="utf-8")
(tmp_path / "not-a-uuid.json").write_text(json.dumps(_sample_definition("Ignored")), encoding="utf-8")
(tmp_path / "0f9184de-5d3c-2111-8c21-6d3f0be1bd3d.json").write_text(
json.dumps(_sample_definition("Ignored2")),
encoding="utf-8",
)
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
records = manager.list()
assert len(records) == 2
assert [record.name for record in records] == ["Second", "First"]
assert [record.priority for record in records] == [1, 5]
def test_create_writes_file_and_refreshes(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
definition = _sample_definition("My Definition")
record = manager.create(definition)
refresh.assert_called_once_with(force=True)
identifier_uuid = uuid.UUID(record.identifier)
assert 4 == identifier_uuid.version
expected_filename = f"{record.identifier}.json"
saved_path = tmp_path / expected_filename
assert saved_path.exists()
saved_content = json.loads(saved_path.read_text(encoding="utf-8"))
assert saved_content["name"] == "My Definition"
assert saved_content["priority"] == 0
def test_update_missing_definition_raises(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with pytest.raises(ValueError, match="does not exist"):
manager.update("missing", _sample_definition("Updated"))
def test_update_overwrites_file(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
identifier = "c59ec7cf-6291-4f0f-86f8-d8cb12c325a4"
initial = _sample_definition("Original", priority=4)
path = tmp_path / f"{identifier}.json"
path.write_text(json.dumps(initial), encoding="utf-8")
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
updated_record = manager.update(identifier, _sample_definition("Updated", priority=2))
refresh.assert_called_once_with(force=True)
assert updated_record.name == "Updated"
assert updated_record.priority == 2
saved = json.loads(path.read_text(encoding="utf-8"))
assert saved["name"] == "Updated"
assert saved["priority"] == 2
def test_delete_removes_file(self, tmp_path: Path) -> None:
validator = _load_validator()
config = Mock(config_path=str(tmp_path), app_path=str(tmp_path))
identifier = "f0b71f47-6b65-4b6d-89fd-6b87ce47d3bc"
definition_path = tmp_path / f"{identifier}.json"
definition_path.write_text(json.dumps(_sample_definition("Delete")), encoding="utf-8")
manager = TaskDefinitions.get_instance(directory=tmp_path, config=config, validator=validator)
with patch("app.library.task_handlers.generic.GenericTaskHandler.refresh_definitions") as refresh:
manager.delete(identifier)
refresh.assert_called_once_with(force=True)
assert not definition_path.exists()
assert manager.get(identifier) is None
@pytest.mark.asyncio
class TestTaskDefinitionRoutes:
def setup_method(self) -> None:
TaskDefinitions._reset_singleton()
def teardown_method(self) -> None:
TaskDefinitions._reset_singleton()
async def test_list_definitions(self) -> None:
request = MagicMock(spec=Request)
request.query = {}
identifier = "9af7018f-8659-4d2a-a42b-b5d2c5f0a6e2"
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="Sample",
priority=0,
path=Path("/tmp/sample.json"),
data=_sample_definition("Sample"),
updated_at=123.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.list.return_value = [record]
response = await api.task_definitions_list(request, Encoder(), task_definitions)
payload = json.loads(response.text)
assert response.status == web.HTTPOk.status_code
assert payload == [record.serialize()]
assert "filename" not in payload[0]
async def test_list_definitions_includes_definition(self) -> None:
request = MagicMock(spec=Request)
request.query = {"include": "definition"}
identifier = "f5b5e88d-5c6b-4a27-8453-1b6a4fb8a8d1"
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="Sample",
priority=0,
path=Path("/tmp/sample.json"),
data=_sample_definition("Sample"),
updated_at=123.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.list.return_value = [record]
response = await api.task_definitions_list(request, Encoder(), task_definitions)
payload = json.loads(response.text)
assert payload[0]["definition"]["name"] == "Sample"
async def test_get_definition_not_found(self) -> None:
request = MagicMock(spec=Request)
request.match_info = {"identifier": "unknown"}
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.get.return_value = None
response = await api.task_definitions_get(request, Encoder(), task_definitions)
payload = json.loads(response.text)
assert response.status == web.HTTPNotFound.status_code
assert "error" in payload
async def test_create_definition_success(self) -> None:
payload_definition = _sample_definition("New", priority=1)
payload = {"definition": payload_definition}
request = MagicMock(spec=Request)
request.json = AsyncMock(return_value=payload)
identifier = "4f08b8af-b87a-4d6e-9289-39e5172898aa"
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="New",
priority=1,
path=Path("/tmp/new.json"),
data=payload["definition"],
updated_at=123.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.create.return_value = record
response = await api.task_definitions_create(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPCreated.status_code
assert body["id"] == identifier
assert body["priority"] == 1
assert "filename" not in body
task_definitions.create.assert_called_once_with(payload_definition)
async def test_create_definition_invalid_payload(self) -> None:
request = MagicMock(spec=Request)
request.json = AsyncMock(return_value=[]) # type: ignore[arg-type]
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_create(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPBadRequest.status_code
assert "error" in body
async def test_update_definition_success(self) -> None:
request = MagicMock(spec=Request)
identifier = "6d8d5719-95ae-4478-bb05-986f5b72b6c1"
request.match_info = {"identifier": identifier}
definition = _sample_definition("Updated", priority=4)
request.json = AsyncMock(return_value={"definition": definition})
record = TaskDefinitionRecord(
identifier=identifier,
filename=f"{identifier}.json",
name="Updated",
priority=4,
path=Path("/tmp/existing.json"),
data=definition,
updated_at=456.0,
)
task_definitions = MagicMock(spec=TaskDefinitions)
task_definitions.update.return_value = record
response = await api.task_definitions_update(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPOk.status_code
assert body["name"] == "Updated"
assert body["priority"] == 4
task_definitions.update.assert_called_once_with(identifier, definition)
async def test_update_definition_missing_identifier(self) -> None:
request = MagicMock(spec=Request)
request.match_info = {"identifier": ""}
request.json = AsyncMock(return_value={})
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_update(request, Encoder(), task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPBadRequest.status_code
assert "error" in body
async def test_delete_definition_success(self) -> None:
request = MagicMock(spec=Request)
identifier = "c9f4ac6c-a4ab-4d1a-8d25-764dc0c8a3f0"
request.match_info = {"identifier": identifier}
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_delete(request, task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPOk.status_code
assert body["status"] == "deleted"
task_definitions.delete.assert_called_once_with(identifier)
async def test_delete_definition_missing_identifier(self) -> None:
request = MagicMock(spec=Request)
request.match_info = {"identifier": ""}
task_definitions = MagicMock(spec=TaskDefinitions)
response = await api.task_definitions_delete(request, task_definitions)
body = json.loads(response.text)
assert response.status == web.HTTPBadRequest.status_code
assert "error" in body

View file

@ -43,6 +43,7 @@ dependencies = [
"selenium>=4.35.0",
"parsel>=1.10.0",
"jmespath>=1.0.1",
"jsonschema>=4.23.0",
]
[tool.ruff]
@ -164,6 +165,8 @@ ignore = [
"PLC0415",
"S603",
"D203",
"TRY004", # Like it's our choice to use ValuesError :|
"PT011",
]
# Allow fix for all enabled rules (when `--fix`) is provided.

View file

@ -0,0 +1,870 @@
<template>
<section class="card">
<header class="card-header">
<p class="card-header-title">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-pen-ruler" /></span>
<span>{{ headerTitle }}</span>
</span>
</p>
<div class="card-header-icon is-flex is-align-items-center">
<button type="button" class="button is-small" @click="() => showImport = !showImport" :disabled="isBusy">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !showImport, 'fa-arrow-up': showImport }" /></span>
<span>{{ showImport ? 'Hide import' : 'Show import' }}</span>
</button>
<div class="buttons has-addons ml-2">
<button type="button" class="button is-small"
:class="{ 'is-primary': 'gui' === mode, 'is-light': 'gui' !== mode }" @click="() => switchMode('gui')"
:disabled="!guiSupported || isBusy">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>GUI</span>
</button>
<button type="button" class="button is-small"
:class="{ 'is-primary': 'advanced' === mode, 'is-light': 'advanced' !== mode }"
@click="() => switchMode('advanced')" :disabled="isBusy">
<span class="icon"><i class="fa-solid fa-code" /></span>
<span>Advanced</span>
</button>
</div>
</div>
</header>
<div class="card-content">
<div class="columns is-multiline" v-if="showImport">
<div class="column is-12" v-if="availableDefinitions.length">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
Import from existing
</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="selectedExisting" :disabled="isBusy" @change="importExisting">
<option value="">Select a definition</option>
<option v-for="item in availableDefinitions" :key="item.id" :value="item.id">
{{ item.name || item.id }}
</option>
</select>
</div>
</div>
<p class="help is-bold">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Loads an existing definition into the editor. Changes are not saved until you submit.</span>
</span>
</p>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-file-import" /></span>
Import string
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input class="input" type="text" v-model="importString" :disabled="isBusy" autocomplete="off">
</div>
<div class="control">
<button class="button is-primary" type="button" @click="importFromString"
:disabled="isBusy || !importString.trim()">
<span class="icon"><i class="fa-solid fa-file-import" /></span>
<span>Import</span>
</button>
</div>
</div>
<p class="help is-bold">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Pastes a shared task definition string. Importing replaces the current editor contents.</span>
</span>
</p>
</div>
</div>
</div>
<Message v-if="!guiSupported" message_class="is-warning">
<p>
<span>
<span class="icon"><i class="fa-solid fa-triangle-exclamation" /></span>
<span>This task definition uses features that cannot be represented with the visual editor. You can still
update it
via the advanced view.</span>
</span>
</p>
</Message>
<div v-if="'gui' === mode">
<div class="columns is-multiline">
<div class="column is-6">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-heading" /></span>
Name
</label>
<div class="control">
<input class="input" type="text" v-model="guiState.name" :disabled="isBusy">
</div>
<p class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Human readable label for this definition.</span>
</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
Priority
</label>
<div class="control">
<input class="input" type="number" min="0" v-model.number="guiState.priority" :disabled="isBusy">
</div>
<p class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Lower values are evaluated first.</span>
</p>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-filter" /></span>
Match patterns
</label>
<div class="control">
<textarea class="textarea" rows="3" v-model="guiState.matchText" :disabled="isBusy"
placeholder="https://example.com/*&#10;https://example.org/channel/*" />
</div>
<p class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>One glob per line. Regex or object based rules are only supported in advanced mode.</span>
</p>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-gears" /></span>
Engine
</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="guiState.engineType" :disabled="isBusy">
<option value="httpx">HTTPX</option>
<option value="selenium">Selenium</option>
</select>
</div>
</div>
<p class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Choose the fetch engine. You should use HTTPX when possible.</span>
</p>
</div>
</div>
<div class="column is-6" v-if="'selenium' === guiState.engineType">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-plug" /></span>
Selenium Hub URL (Required)
</label>
<div class="control">
<input class="input" type="url" v-model="guiState.engineUrl" :disabled="isBusy"
placeholder="http://selenium:4444/wd/hub">
</div>
<p class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Remote webdriver endpoint.</span>
</p>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-server" /></span>
Request Method
</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="guiState.requestMethod" :disabled="isBusy">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
</div>
<p class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>HTTP method to use when fetching the page.</span>
</p>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-link" /></span>
Request URL (optional)
</label>
<div class="control">
<input class="input" type="url" v-model="guiState.requestUrl" :disabled="isBusy"
placeholder="https://example.com/feed">
</div>
<p class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Overrides the URL used to fetch the page. Useful for sites with separate feed URLs.</span>
</p>
</div>
</div>
<div class="column is-12">
<article class="message is-info" v-if="guiLimitations">
<div class="message-body">
{{ guiLimitations }}
</div>
</article>
</div>
<div class="column is-12">
<div>
<h4 class="title is-6">Container selector</h4>
<div class="columns is-multiline">
<div class="column is-4">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-list" /></span>
Type
</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="guiState.containerType" :disabled="isBusy">
<option value="css">CSS</option>
<option value="xpath">XPath</option>
<option value="jsonpath">JSONPath</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-8">
<div class="field">
<label class="label is-inline">
<span class="icon"><i class="fa-solid fa-crosshairs" /></span>
Selector / Expression
</label>
<div class="control">
<input class="input" type="text" v-model="guiState.containerSelector" :disabled="isBusy"
placeholder="div.card">
</div>
</div>
</div>
</div>
<h4 class="title is-6 mt-4">Extracted fields</h4>
<div class="field">
<button class="button is-small is-primary" type="button" @click="addField" :disabled="isBusy">
<span class="icon"><i class="fa-solid fa-plus" /></span>
<span>Add field</span>
</button>
</div>
<div class="table-container">
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr class="is-unselectable">
<th>Key</th>
<th>Type</th>
<th>Expression</th>
<th>Attribute</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-if="!guiState.fields.length">
<td colspan="5" class="has-text-centered">No extractor fields configured.</td>
</tr>
<tr v-for="(field, index) in guiState.fields" :key="field.key">
<td>
<input class="input" type="text" v-model="field.key" :disabled="isBusy">
</td>
<td>
<div class="select is-fullwidth">
<select v-model="field.type" :disabled="isBusy">
<option value="css">CSS</option>
<option value="xpath">XPath</option>
<option value="regex">Regex</option>
<option value="jsonpath">JSONPath</option>
</select>
</div>
</td>
<td>
<input class="input" type="text" v-model="field.expression" :disabled="isBusy">
</td>
<td>
<input class="input" type="text" v-model="field.attribute" :disabled="isBusy"
placeholder="Optional">
</td>
<td class="has-text-right">
<button class="button is-small is-danger" type="button" @click="removeField(index)"
:disabled="isBusy">
<span class="icon"><i class="fa-solid fa-trash" /></span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<p v-if="guiError" class="help is-danger">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-circle-exclamation" /></span>
<span>{{ guiError }}</span>
</span>
</p>
</div>
<div v-else>
<textarea class="textarea is-family-monospace" rows="18" v-model="jsonText" :readonly="submitting"
spellcheck="false" />
<p v-if="errorMessage" class="help is-danger mt-2">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-circle-exclamation" /></span>
<span>{{ errorMessage }}</span>
</span>
</p>
</div>
</div>
<footer class="card-footer p-4">
<div class="buttons">
<button class="button is-primary" type="button" @click="submit" :disabled="isBusy">
<span class="icon"><i class="fa-solid fa-floppy-disk" /></span>
<span>Save</span>
</button>
<button class="button is-danger" type="button" @click="cancel" :disabled="submitting">
<span class="icon"><i class="fa-solid fa-xmark" /></span>
<span>Cancel</span>
</button>
<button class="button is-link" type="button" v-if="'advanced' === mode" @click="beautify" :disabled="isBusy">
<span class="icon"><i class="fa-solid fa-wand-magic-sparkles" /></span>
<span>Format</span>
</button>
</div>
</footer>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import Message from '~/components/Message.vue'
import { decode } from '~/utils'
import type { TaskDefinitionDocument, TaskDefinitionSummary } from '~/types/task_definitions'
type EditorMode = 'gui' | 'advanced'
type GuiField = {
key: string
type: string
expression: string
attribute: string
}
type GuiState = {
name: string
priority: number
matchText: string
engineType: 'httpx' | 'selenium'
engineUrl: string
requestMethod: string
requestUrl: string
containerType: 'css' | 'xpath' | 'jsonpath'
containerSelector: string
fields: GuiField[]
}
const props = defineProps<{
title: string
document: TaskDefinitionDocument | null
loading?: boolean
submitting?: boolean
availableDefinitions?: readonly TaskDefinitionSummary[]
initialShowImport?: boolean
}>()
const emit = defineEmits<{
(e: 'submit', payload: TaskDefinitionDocument): void
(e: 'cancel'): void
(e: 'import-existing', id: string): void
}>()
const jsonText = ref<string>('')
const errorMessage = ref<string | null>(null)
const guiError = ref<string | null>(null)
const guiSupported = ref<boolean>(true)
const mode = ref<EditorMode>('gui')
const showImport = ref<boolean>(false)
const importString = ref<string>('')
const selectedExisting = ref<string>('')
const availableDefinitions = computed<readonly TaskDefinitionSummary[]>(() => props.availableDefinitions ?? [])
const guiState = reactive<GuiState>({
name: '',
priority: 0,
matchText: '',
engineType: 'httpx',
engineUrl: '',
requestMethod: 'GET',
requestUrl: '',
containerType: 'css',
containerSelector: '',
fields: [],
})
const loading = computed<boolean>(() => props.loading ?? false)
const submitting = computed<boolean>(() => props.submitting ?? false)
const isBusy = computed<boolean>(() => loading.value || submitting.value)
const headerTitle = computed<string>(() => props.title)
const guiLimitations = 'Only simple match globs, a single container selector and per-field extractors are exposed. ' +
'More advanced constructs require raw view mode.'
const resetGuiState = (state: GuiState): void => {
guiState.name = state.name
guiState.priority = state.priority
guiState.matchText = state.matchText
guiState.engineType = state.engineType
guiState.engineUrl = state.engineUrl
guiState.requestMethod = state.requestMethod
guiState.requestUrl = state.requestUrl
guiState.containerType = state.containerType
guiState.containerSelector = state.containerSelector
guiState.fields = state.fields.map(field => ({ ...field }))
}
const defaultField = (): GuiField => ({ key: '', type: 'css', expression: '', attribute: '' })
const addField = (): void => {
guiState.fields.push(defaultField())
}
const removeField = (index: number): void => {
guiState.fields.splice(index, 1)
}
const splitMatches = (text: string): string[] => {
return text.split(/\r?\n/).map(item => item.trim()).filter(Boolean)
}
const toGui = (document: TaskDefinitionDocument): GuiState | null => {
if (!document || Array.isArray(document) || 'object' !== typeof document) {
return null
}
const entry = document
const match = entry.match
if (!Array.isArray(match) || match.some(item => 'string' !== typeof item)) {
return null
}
const parse = entry.parse
if (!parse || Array.isArray(parse) || 'object' !== typeof parse) {
return null
}
const parseRecord = parse as Record<string, unknown>
const items = parseRecord.items
if (!items || Array.isArray(items) || 'object' !== typeof items) {
return null
}
const itemRecord = items as Record<string, unknown>
const fields = itemRecord.fields
if (!fields || Array.isArray(fields) || 'object' !== typeof fields) {
return null
}
const fieldRecord = fields as Record<string, unknown>
const guiFields: GuiField[] = []
for (const [key, value] of Object.entries(fieldRecord)) {
if (!value || Array.isArray(value) || 'object' !== typeof value) {
return null
}
const rule = value as Record<string, unknown>
if ('string' !== typeof rule.type || 'string' !== typeof rule.expression) {
return null
}
if (Object.keys(rule).some(prop => !['type', 'expression', 'attribute'].includes(prop))) {
return null
}
guiFields.push({
key,
type: String(rule.type),
expression: String(rule.expression),
attribute: 'string' === typeof rule.attribute ? String(rule.attribute) : '',
})
}
const engine = entry.engine as Record<string, unknown> | undefined
const engineType = (engine?.type === 'selenium') ? 'selenium' : 'httpx'
const engineUrl = 'string' === typeof engine?.options && engineType === 'selenium'
? ''
: (engine?.options as Record<string, unknown> | undefined)?.url as string | undefined
if (engineUrl && engineType === 'selenium' && 'string' !== typeof engineUrl) {
return null
}
const request = entry.request as Record<string, unknown> | undefined
const selectorType = String(itemRecord.type ?? 'css') as GuiState['containerType']
const selectorSource = (itemRecord.selector ?? itemRecord.expression) as string | undefined
if (!selectorSource || 'string' !== typeof selectorSource) {
return null
}
return {
name: 'string' === typeof entry.name ? entry.name : '',
priority: Number(entry.priority ?? 0) || 0,
matchText: match.join('\n'),
engineType,
engineUrl: engineType === 'selenium' ? String(engineUrl ?? '') : '',
requestMethod: 'string' === typeof request?.method ? String(request?.method) : 'GET',
requestUrl: 'string' === typeof request?.url ? String(request?.url) : '',
containerType: selectorType,
containerSelector: selectorSource,
fields: guiFields.length ? guiFields : [defaultField()],
}
}
const fromGui = (state: GuiState): TaskDefinitionDocument => {
if (!state.name.trim()) {
throw new Error('Name is required.')
}
const matches = splitMatches(state.matchText)
if (!matches.length) {
throw new Error('At least one match pattern is required.')
}
if (!state.containerSelector.trim()) {
throw new Error('Container selector is required.')
}
const formattedFields: Record<string, Record<string, string>> = {}
state.fields.forEach(field => {
if (!field.key.trim()) {
return
}
if (!field.expression.trim()) {
throw new Error(`Expression is required for field "${field.key}".`)
}
formattedFields[field.key.trim()] = {
type: field.type || 'css',
expression: field.expression,
...(field.attribute ? { attribute: field.attribute } : {}),
}
})
if (!Object.keys(formattedFields).length) {
throw new Error('Configure at least one extractor field.')
}
const doc: Record<string, unknown> = {
name: state.name.trim(),
priority: Number(state.priority) || 0,
match: matches,
parse: {
items: {
type: state.containerType,
selector: state.containerType === 'jsonpath' ? undefined : state.containerSelector,
expression: state.containerType === 'jsonpath' ? state.containerSelector : undefined,
fields: formattedFields,
},
},
}
if ('httpx' !== state.engineType || state.engineUrl) {
doc.engine = {
type: state.engineType,
...(state.engineType === 'selenium' && state.engineUrl
? { options: { url: state.engineUrl } }
: {}),
}
}
const request: Record<string, string> = {}
if (state.requestMethod && 'GET' !== state.requestMethod) {
request.method = state.requestMethod
}
if (state.requestUrl) {
request.url = state.requestUrl
}
if (Object.keys(request).length) {
doc.request = request
}
return doc as unknown as TaskDefinitionDocument
}
const parseImportedDocument = (payload: unknown): TaskDefinitionDocument => {
if (!payload || Array.isArray(payload) || 'object' !== typeof payload) {
throw new Error('Import payload is not a task definition object.')
}
const record = payload as Record<string, unknown>
const candidate = record.definition
if ('_type' in record && record._type !== undefined && record._type !== 'task_definition') {
throw new Error('Import string is not a task definition export.')
}
let base: TaskDefinitionDocument
if (candidate && !Array.isArray(candidate) && 'object' === typeof candidate) {
base = candidate as TaskDefinitionDocument
}
else {
base = payload as TaskDefinitionDocument
}
const clone = JSON.parse(JSON.stringify(base)) as TaskDefinitionDocument
const cloneRecord = clone
if ('name' in record && 'string' === typeof record.name) {
cloneRecord.name = record.name
}
if ('priority' in record && record.priority !== undefined) {
cloneRecord.priority = Number(record.priority) || 0
}
return clone
}
const parseDocument = (): TaskDefinitionDocument | null => {
try {
if (!jsonText.value.trim()) {
throw new Error('Definition cannot be empty.')
}
const parsed = JSON.parse(jsonText.value) as unknown
if (!parsed || Array.isArray(parsed) || 'object' !== typeof parsed) {
throw new Error('Definition must be a JSON object.')
}
errorMessage.value = null
return parsed as TaskDefinitionDocument
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'Invalid JSON document.'
return null
}
}
const applyDocument = (document: TaskDefinitionDocument | null): void => {
const shouldShowImport = props.initialShowImport ?? !document
showImport.value = shouldShowImport
importString.value = ''
selectedExisting.value = ''
if (!document) {
jsonText.value = ''
guiSupported.value = true
resetGuiState({
name: '',
priority: 0,
matchText: '',
engineType: 'httpx',
engineUrl: '',
requestMethod: 'GET',
requestUrl: '',
containerType: 'css',
containerSelector: '',
fields: [defaultField()],
})
return
}
try {
jsonText.value = JSON.stringify(document, null, 2)
const gui = toGui(document)
if (gui) {
guiSupported.value = true
resetGuiState(gui)
guiError.value = null
if ('gui' !== mode.value) {
mode.value = 'gui'
}
}
else {
guiSupported.value = false
mode.value = 'advanced'
}
}
catch (error) {
console.error('Failed to prepare definition for editing.', error)
jsonText.value = ''
guiSupported.value = false
mode.value = 'advanced'
errorMessage.value = 'Failed to prepare definition for editing.'
}
}
const importFromString = (): void => {
if (isBusy.value) {
return
}
if (!importString.value.trim()) {
guiError.value = 'Import string cannot be empty.'
return
}
try {
const decoded = decode(importString.value.trim())
const document = parseImportedDocument(decoded)
applyDocument(document)
guiError.value = null
errorMessage.value = null
importString.value = ''
showImport.value = false
}
catch (error) {
guiError.value = error instanceof Error ? error.message : 'Unable to import definition.'
}
}
const importExisting = (): void => {
if (!selectedExisting.value || isBusy.value) {
return
}
emit('import-existing', selectedExisting.value)
selectedExisting.value = ''
}
watch(() => props.document, (doc) => applyDocument(doc), { immediate: true })
const switchMode = (next: EditorMode): void => {
if (isBusy.value || next === mode.value) {
return
}
if ('gui' === next) {
if (!guiSupported.value) {
return
}
const parsed = parseDocument()
if (!parsed) {
return
}
const gui = toGui(parsed)
if (!gui) {
guiSupported.value = false
return
}
resetGuiState(gui)
guiSupported.value = true
}
if ('advanced' === next) {
try {
const doc = fromGui(guiState)
jsonText.value = JSON.stringify(doc, null, 2)
errorMessage.value = null
guiError.value = null
}
catch (error) {
guiError.value = error instanceof Error ? error.message : 'Failed to serialize GUI changes.'
return
}
}
mode.value = next
}
const submit = (): void => {
if (isBusy.value) {
return
}
if ('gui' === mode.value) {
try {
const doc = fromGui(guiState)
emit('submit', doc)
guiError.value = null
}
catch (error) {
guiError.value = error instanceof Error ? error.message : 'Unable to build definition.'
}
return
}
const parsed = parseDocument()
if (!parsed) {
return
}
emit('submit', parsed)
}
const beautify = (): void => {
if ('advanced' !== mode.value) {
return
}
const parsed = parseDocument()
if (!parsed) {
return
}
jsonText.value = JSON.stringify(parsed, null, 2)
errorMessage.value = null
}
const cancel = (): void => {
if (submitting.value) {
return
}
emit('cancel')
}
defineExpose({ submit, beautify })
</script>
<style scoped>
.textarea {
min-height: 16rem;
font-family: var(--font-monospace, 'JetBrains Mono', monospace);
}
.card-footer .buttons {
width: 100%;
justify-content: flex-end;
}
.buttons.has-addons .button {
border-radius: 4px;
}
.buttons.has-addons .button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.buttons.has-addons .button:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.table td,
.table th {
vertical-align: middle;
}
</style>

View file

@ -0,0 +1,186 @@
<style scoped>
code {
color: var(--bulma-code) !important
}
</style>
<template>
<div class="box">
<h2 class="title is-5">Inspect Task Handler</h2>
<form @submit.prevent="onSubmit">
<div class="field">
<label class="label" for="url">
<span class="icon-text">
<span class="icon"><i class="fas fa-link" /></span>
<span>URL</span>
</span>
</label>
<div class="control has-icons-left">
<input id="url" v-model="url" type="url" class="input" :class="{ 'is-danger': urlError }"
placeholder="https://..." required />
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
</div>
<p v-if="urlError" class="help is-danger">{{ urlError }}</p>
<p v-else class="help is-bold">
Enter the URL of the resource you want to inspect.
</p>
</div>
<div class="field">
<label class="label" for="preset">
<span class="icon-text">
<span class="icon"> <i class="fa-solid fa-list" /> </span>
<span>Preset</span>
</span>
</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="preset">
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
{{ cPreset.name }}
</option>
</optgroup>
<optgroup label="Default presets">
<option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name">
{{ dPreset.name }}
</option>
</optgroup>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-list" /></span>
</div>
<p class="help is-bold">
Select a preset to apply its settings during inspection. In real scenario, the preset will be based on what is
selected when creating the task.
</p>
</div>
<div class="field">
<label class="label" for="handler">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-cogs" /></span>
<span>Handler (For testing)</span>
</span>
</label>
<div class="control has-icons-left">
<input id="handler" v-model="handler" type="text" class="input" placeholder="Handler class name" />
<span class="icon is-small is-left"><i class="fa-solid fa-cogs" /></span>
</div>
<p class="help is-bold">
In real scenario, the system auto-detects the appropriate handler based on the URL. This field is for testing
purposes only.
</p>
</div>
<div class="field is-grouped is-grouped-right">
<div class="control">
<button class="button is-primary" type="submit" :disabled="loading">
<span class="icon"> <i class="fas fa-search" /></span>
<span>Inspect</span>
</button>
</div>
<div class="control">
<button class="button is-warning" type="button" @click="onReset" :disabled="loading">
<span class="icon"> <i class="fas fa-undo" /> </span>
<span>Reset</span>
</button>
</div>
</div>
</form>
<Message v-if="loading" class="has-background-info-90 has-text-dark mt-5">
<p>
<span class="icon-text">
<span class="icon"><i class="fas fa-spinner fa-spin" /></span>
<span>Inspecting.. please wait.</span>
</span>
</p>
</Message>
<div v-if="response" class="mt-4">
<Message v-if="response.error" message_class="has-background-danger-90 has-text-dark" title="Error"
icon="fas fa-exclamation-triangle">
<p>{{ response.error }}</p>
<p v-if="response.message">{{ response.message }}</p>
</Message>
<div class="content" v-else>
<h4>Result:</h4>
<pre><code>{{ response }}</code></pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { request } from '~/utils'
import { useConfigStore } from '~/stores/ConfigStore'
import type { TaskInspectRequest, TaskInspectResponse } from '~/types/task_inspect'
const props = defineProps<{
url?: string
preset?: string
handler?: string
}>()
const config = useConfigStore()
const url = ref<string>(props.url ?? '')
const preset = ref<string>(props.preset || config.app.default_preset || '')
const handler = ref<string>(props.handler ?? '')
const loading = ref<boolean>(false)
const response = ref<TaskInspectResponse | null>(null)
const urlError = ref<string>('')
// Watch for prop changes and update fields
watch(() => props.url, val => { if (val !== undefined) url.value = val })
watch(() => props.preset, val => { if (val !== undefined) preset.value = val })
watch(() => props.handler, val => { if (val !== undefined) handler.value = val })
const validateUrl = (val: string): boolean => {
try {
const u = new URL(val)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
async function onSubmit() {
urlError.value = ''
response.value = null
if (!url.value || !validateUrl(url.value)) {
urlError.value = 'Please enter a valid URL.'
return
}
loading.value = true
const payload: TaskInspectRequest = {
url: url.value.trim(),
preset: preset.value.trim() || undefined,
handler: handler.value.trim() || undefined,
}
try {
const res = await request('/api/tasks/inspect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
response.value = await res.json()
} catch (err: any) {
response.value = { error: err?.message || 'Unknown error' }
} finally {
loading.value = false
}
}
const onReset = () => {
url.value = props.url || ''
preset.value = props.preset || config.app.default_preset || ''
handler.value = props.handler || ''
response.value = null
urlError.value = ''
}
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)
</script>

View file

@ -82,7 +82,7 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
}
}
export default function useNotification() {
export const useNotification = () => {
return {
info: (message: string, opts?: notificationOptions) => notify('info', message, opts),
success: (message: string, opts?: notificationOptions) => notify('success', message, opts),

View file

@ -0,0 +1,309 @@
import { ref, readonly } from 'vue'
import { useNotification } from '~/composables/useNotification'
import { request } from '~/utils'
import type {
TaskDefinitionDetailed,
TaskDefinitionDocument,
TaskDefinitionErrorResponse,
TaskDefinitionSummary,
} from '~/types/task_definitions'
/**
* Reactive list of all task definition summaries, sorted by priority and name.
*/
const definitions = ref<Array<TaskDefinitionSummary>>([])
/**
* Indicates if a request is in progress.
*/
const isLoading = ref<boolean>(false)
/**
* Stores the last error message, if any.
*/
const lastError = ref<string | null>(null)
/**
* If true, methods will throw errors instead of returning null/false (for testing)
*/
const throwInstead = ref(false)
/**
* Notification composable for showing success/error messages.
*/
const notify = useNotification()
/**
* Sorts task definition summaries by priority (ascending), then name (A-Z).
* @param items Array of TaskDefinitionSummary
* @returns Sorted array of TaskDefinitionSummary
*/
const sortSummaries = (items: Array<TaskDefinitionSummary>): Array<TaskDefinitionSummary> => {
return [...items].sort((a, b) => {
if (a.priority === b.priority) {
return a.name.localeCompare(b.name)
}
return a.priority - b.priority
})
}
/**
* Safely reads JSON from a Response, returns null on error.
* @param response Fetch Response object
* @returns Parsed JSON or null
*/
const readJson = async (response: Response): Promise<unknown> => {
try {
const clone = response.clone()
return await clone.json()
}
catch {
return null
}
}
/**
* Throws an error if the response is not OK, using API error message if available.
* @param response Fetch Response object
* @throws Error with message from API or status code
*/
const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) {
return
}
const payload = await readJson(response)
let message = `Request failed with status ${response.status}`
if (payload && typeof payload === 'object' && 'error' in payload) {
const errorPayload = payload as TaskDefinitionErrorResponse
if (typeof errorPayload.error === 'string' && errorPayload.error.length > 0) {
message = errorPayload.error
}
}
throw new Error(message)
}
/**
* Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown
*/
const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
lastError.value = message
notify.error(message)
}
/**
* Updates or adds a summary in the definitions list, keeping sort order.
* @param summary TaskDefinitionSummary to update/add
*/
const updateSummaries = (summary: TaskDefinitionSummary): void => {
definitions.value = sortSummaries([
...definitions.value.filter(item => item.id !== summary.id),
summary,
])
}
/**
* Removes a summary from the definitions list by ID.
* @param id Task definition ID
*/
const removeSummary = (id: string) => definitions.value = definitions.value.filter(item => item.id !== id)
/**
* Loads all task definition summaries from the API.
* Updates definitions and lastError.
*/
const loadDefinitions = async (): Promise<void> => {
isLoading.value = true
try {
const response = await request('/api/task_definitions/')
await ensureSuccess(response)
const payload = await response.json() as unknown
if (!Array.isArray(payload)) {
throw new Error('Unexpected response while loading task definitions.')
}
const summaries: Array<TaskDefinitionSummary> = payload.map(item => {
if (!item || 'object' !== typeof item) {
throw new Error('Encountered malformed task definition entry.')
}
const entry = item as Record<string, unknown>
return {
id: String(entry.id ?? ''),
name: String(entry.name ?? ''),
priority: Number(entry.priority ?? 0),
updated_at: Number(entry.updated_at ?? 0),
}
})
definitions.value = sortSummaries(summaries)
lastError.value = null
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/**
* Fetches a detailed task definition by ID from the API.
* @param id Task definition ID
* @returns TaskDefinitionDetailed or null on error
*/
const getDefinition = async (id: string): Promise<TaskDefinitionDetailed | null> => {
try {
const response = await request(`/api/task_definitions/${id}`)
await ensureSuccess(response)
const payload = await response.json() as unknown
if (!payload || 'object' !== typeof payload) {
throw new Error('Unexpected response while retrieving task definition.')
}
const entry = payload as Record<string, unknown>
if (!('definition' in entry) || 'object' !== typeof entry.definition) {
throw new Error('Task definition response is missing definition payload.')
}
const detailed: TaskDefinitionDetailed = {
id: String(entry.id ?? ''),
name: String(entry.name ?? ''),
priority: Number(entry.priority ?? 0),
updated_at: Number(entry.updated_at ?? 0),
definition: entry.definition as TaskDefinitionDocument,
}
lastError.value = null
return detailed
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/**
* Creates a new task definition via API.
* @param definition TaskDefinitionDocument to create
* @returns Created TaskDefinitionDetailed or null on error
*/
const createDefinition = async (definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => {
try {
const response = await request('/api/task_definitions/', {
method: 'POST',
body: JSON.stringify({ definition }),
})
await ensureSuccess(response)
const payload = await response.json() as TaskDefinitionDetailed
updateSummaries({
id: payload.id,
name: payload.name,
priority: payload.priority,
updated_at: payload.updated_at,
})
notify.success('Task definition created.')
lastError.value = null
return payload
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/**
* Updates an existing task definition via API.
* @param id Task definition ID
* @param definition Updated TaskDefinitionDocument
* @returns Updated TaskDefinitionDetailed or null on error
*/
const updateDefinition = async (id: string, definition: TaskDefinitionDocument): Promise<TaskDefinitionDetailed | null> => {
try {
const response = await request(`/api/task_definitions/${id}`, {
method: 'PUT',
body: JSON.stringify({ definition }),
})
await ensureSuccess(response)
const payload = await response.json() as TaskDefinitionDetailed
updateSummaries({
id: payload.id,
name: payload.name,
priority: payload.priority,
updated_at: payload.updated_at,
})
notify.success('Task definition updated.')
lastError.value = null
return payload
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/**
* Deletes a task definition by ID via API.
* @param id Task definition ID
* @returns true if deleted, false on error
*/
const deleteDefinition = async (id: string): Promise<boolean> => {
try {
const response = await request(`/api/task_definitions/${id}`, { method: 'DELETE' })
await ensureSuccess(response)
removeSummary(id)
notify.success('Task definition deleted.')
lastError.value = null
return true
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return false
}
}
/**
* Clears the last error message.
*/
const clearError = () => lastError.value = null
/**
* useTaskDefinitions composable
*
* Returns reactive state and CRUD methods for task definitions.
* @returns Object with state and API methods
*/
export const useTaskDefinitions = () => ({
definitions: readonly(definitions),
isLoading: readonly(isLoading),
lastError: readonly(lastError),
loadDefinitions,
getDefinition,
createDefinition,
updateDefinition,
deleteDefinition,
clearError,
throwInstead,
})
export default useTaskDefinitions

View file

@ -34,11 +34,23 @@
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
</NuxtLink>
<div class="navbar-item has-dropdown">
<a class="navbar-link" @click="(e: MouseEvent) => openMenu(e)">
<span class="icon"><i class="fas fa-tasks" /></span>
<span>Tasks</span>
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>List</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>Tasks</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/task_definitions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Definitions</span>
</NuxtLink>
</div>
</div>
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon-text">

View file

@ -0,0 +1,399 @@
<template>
<main>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-diagram-project" /></span>
<span>Task Definitions</span>
</span>
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="isEditorOpen ? closeEditor() : openCreate()">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span v-if="!isMobile">New Definition</span>
</button>
</p>
<p class="control">
<button @click="() => inspect = true" class="button is-primary is-light">
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
<span v-if="!isMobile">Inspect</span>
</button>
</p>
<p class="control">
<button v-tooltip.bottom="'Change display style'" class="button has-tooltip-bottom"
@click="() => display_style = display_style === 'list' ? 'grid' : 'list'">
<span class="icon">
<i class="fa-solid"
:class="{ 'fa-table': display_style !== 'list', 'fa-table-list': display_style === 'list' }" /></span>
<span v-if="!isMobile">
{{ display_style === 'list' ? 'List' : 'Grid' }}
</span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="async () => await loadDefinitions()"
:class="{ 'is-loading': isLoading }">
<span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">
Create definitions to turn any website into a downloadable feed of links.
</span>
</div>
</div>
</div>
<div class="columns" v-if="'list' === display_style && definitions.length > 0 && !isEditorOpen">
<div class="column is-12">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed;">
<thead>
<tr class="has-text-centered is-unselectable">
<th width="40%">Name</th>
<th width="20%">Priority</th>
<th width="20%">Updated</th>
<th width="20%">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="definition in definitions" :key="definition.id">
<td class="is-vcentered is-text-overflow">
{{ definition.name || '(Unnamed definition)' }}
</td>
<td class="is-vcentered has-text-centered">{{ definition.priority }}</td>
<td class="is-vcentered has-text-centered">
<span class="user-hint" :date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-rtime="definition.updated_at" />
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-small is-info" type="button" @click="exportDefinition(definition)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
<span v-if="!isMobile">Export</span>
</button>
</div>
<div class="control">
<button class="button is-small is-warning" type="button" @click="openEdit(definition)">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span v-if="!isMobile">Edit</span>
</button>
</div>
<div class="control">
<button class="button is-small is-danger" type="button" @click="remove(definition)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span v-if="!isMobile">Delete</span>
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="columns is-multiline" v-if="'grid' === display_style && definitions.length > 0 && !isEditorOpen">
<div class="column is-6" v-for="definition in definitions" :key="definition.id">
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
{{ definition.name || '(Unnamed definition)' }}
</div>
<div class="card-header-icon">
<button class="has-text-info" v-tooltip="'Export'" @click="exportDefinition(definition)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</button>
</div>
</header>
<div class="card-content">
<div class="content">
<p>
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
<span>Priority: {{ definition.priority }}</span>
</span>
</p>
<p>
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-clock" /></span>
<span>Updated: <span class="user-hint"
:date-datetime="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-tooltip="moment.unix(definition.updated_at).format('YYYY-M-DD H:mm Z')"
v-rtime="definition.updated_at" />
</span>
</span>
</p>
</div>
</div>
<footer class="card-footer mt-auto">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="openEdit(definition)">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-pen-to-square" /></span>
<span>Edit</span>
</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="remove(definition)">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</span>
</button>
</div>
</footer>
</div>
</div>
</div>
<div class="columns is-multiline" v-if="!definitions.length">
<div class="column is-12">
<Message message_class="has-background-info-90 has-text-dark" title="Loading" icon="fas fa-spinner fa-spin"
message="Loading data. Please wait..." v-if="isLoading" />
<Message title="No definitions" message="No task definitions are configured yet. Create one to get started."
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle" v-else />
</div>
</div>
<div class="columns" v-if="isEditorOpen">
<div class="column is-12">
<TaskDefinitionEditor :title="editorTitle" :document="workingDefinition"
:initial-show-import="'create' === editorMode" :available-definitions="definitions" :loading="editorLoading"
:submitting="editorSubmitting" @submit="submitDefinition" @cancel="closeEditor"
@import-existing="importExistingDefinition" />
</div>
</div>
<Modal v-if="inspect" @close="() => inspect = false" :contentClass="`modal-content-max`">
<TaskInspect />
</Modal>
</main>
</template>
<script setup lang="ts">
import moment from 'moment'
import { computed, onMounted, ref } from 'vue'
import { useStorage } from '@vueuse/core'
import TaskDefinitionEditor from '~/components/TaskDefinitionEditor.vue'
import useTaskDefinitionsComposable from '~/composables/useTaskDefinitions'
import { useDialog } from '~/composables/useDialog'
import { useNotification } from '~/composables/useNotification'
import { copyText, encode } from '~/utils'
import { useMediaQuery } from '~/composables/useMediaQuery'
import type {
TaskDefinitionDetailed,
TaskDefinitionDocument,
TaskDefinitionSummary,
} from '~/types/task_definitions'
const DEFAULT_DEFINITION: TaskDefinitionDocument = {
name: 'New Definition',
priority: 0,
match: ['https://example.com/*'],
parse: {
items: {
type: 'css',
selector: 'body',
fields: {
link: { type: 'css', expression: 'a', attribute: 'href' },
title: { type: 'css', expression: 'a', attribute: 'text' },
},
},
},
}
const isMobile = useMediaQuery({ maxWidth: 1024 })
const taskDefs = useTaskDefinitionsComposable()
const definitionsRef = taskDefs.definitions
const isLoading = taskDefs.isLoading
const loadDefinitions = taskDefs.loadDefinitions
const getDefinition = taskDefs.getDefinition
const createDefinition = taskDefs.createDefinition
const updateDefinition = taskDefs.updateDefinition
const deleteDefinition = taskDefs.deleteDefinition
const definitions = computed(() => definitionsRef.value)
const { confirmDialog } = useDialog()
const toast = useNotification()
const isEditorOpen = ref<boolean>(false)
const editorMode = ref<'create' | 'edit'>('create')
const editorLoading = ref<boolean>(false)
const editorSubmitting = ref<boolean>(false)
const workingDefinition = ref<TaskDefinitionDocument | null>(null)
const workingId = ref<string | null>(null)
const inspect = ref<boolean>(false)
const display_style = useStorage<'list' | 'grid'>('task-definitions:display', 'grid')
const currentSummary = computed<TaskDefinitionSummary | undefined>(() => {
if ('edit' !== editorMode.value || !workingId.value) {
return undefined
}
return definitions.value.find(item => item.id === workingId.value)
})
const editorTitle = computed<string>(() => {
return 'create' === editorMode.value
? 'Create Task Definition'
: `Edit ${currentSummary.value?.name || 'Task Definition'}`
})
const cloneDocument = (document: TaskDefinitionDocument): TaskDefinitionDocument => {
return JSON.parse(JSON.stringify(document)) as TaskDefinitionDocument
}
const openCreate = (): void => {
editorMode.value = 'create'
workingId.value = null
workingDefinition.value = cloneDocument(DEFAULT_DEFINITION)
isEditorOpen.value = true
editorLoading.value = false
editorSubmitting.value = false
}
const openEdit = async (summary: TaskDefinitionSummary): Promise<void> => {
editorMode.value = 'edit'
workingId.value = summary.id
workingDefinition.value = null
editorLoading.value = true
editorSubmitting.value = false
isEditorOpen.value = true
const detailed: TaskDefinitionDetailed | null = await getDefinition(summary.id)
if (!detailed) {
isEditorOpen.value = false
editorLoading.value = false
return
}
const document = cloneDocument(detailed.definition)
const docRecord = document
if ('priority' in docRecord) {
docRecord.priority = Number(docRecord.priority)
}
else {
docRecord.priority = detailed.priority
}
workingDefinition.value = document
editorLoading.value = false
}
const importExistingDefinition = async (id: string): Promise<void> => {
const detailed = await getDefinition(id)
if (!detailed) {
toast.error('Failed to load task definition for import.')
return
}
const document = cloneDocument(detailed.definition)
const docRecord = document
if ('priority' in docRecord) {
docRecord.priority = Number(docRecord.priority)
}
else {
docRecord.priority = detailed.priority
}
workingDefinition.value = document
if ('create' === editorMode.value) {
workingId.value = null
}
editorLoading.value = false
}
const closeEditor = (): void => {
if (editorSubmitting.value) {
return
}
isEditorOpen.value = false
workingDefinition.value = null
workingId.value = null
editorLoading.value = false
}
const submitDefinition = async (definition: TaskDefinitionDocument): Promise<void> => {
editorSubmitting.value = true
try {
if ('create' === editorMode.value) {
const created = await createDefinition(definition)
if (created) {
isEditorOpen.value = false
workingDefinition.value = null
workingId.value = null
}
}
else if (workingId.value) {
const updated = await updateDefinition(workingId.value, definition)
if (updated) {
isEditorOpen.value = false
workingDefinition.value = null
workingId.value = null
}
}
}
finally {
editorSubmitting.value = false
}
}
const remove = async (summary: TaskDefinitionSummary): Promise<void> => {
const result = await confirmDialog({
title: 'Delete Task Definition',
message: `Are you sure you want to delete “${summary.name || summary.id}”?`,
confirmColor: 'is-danger',
})
if (!result.status) {
return
}
await deleteDefinition(summary.id)
}
const exportDefinition = async (summary: TaskDefinitionSummary): Promise<void> => {
const detailed = await getDefinition(summary.id)
if (!detailed) {
return
}
const payload = {
_type: 'task_definition',
_version: '1.0',
name: detailed.name,
priority: detailed.priority,
definition: detailed.definition,
}
return copyText(encode(payload))
}
onMounted(async () => {
if (!definitions.value.length) {
await loadDefinitions()
}
})
</script>

View file

@ -200,6 +200,11 @@
<span>Run now</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
<span>Inspect Handler</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="archiveAll(item)">
@ -324,6 +329,11 @@
<span>Run now</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
<span>Inspect Handler</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="archiveAll(item)">
@ -367,6 +377,10 @@
<code> <span class="icon"><i class="fa-solid fa-cogs" /></span> Actions > <span class="icon"><i
class="fa-solid fa-box-archive" /></span> Archive All</code> to archive all existing content.
</li>
<li>
For custom handler definitions, you shouldn't have a timer set, as yt-dlp wouldn't know what to do with
it.
</li>
</ul>
</span>
</Message>
@ -376,6 +390,10 @@
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
:html_message="dialog_confirm.html_message" @cancel="dialog_confirm = reset_dialog()" />
<Modal v-if="inspectTask" @close="() => inspectTask = null" :contentClass="`modal-content-max`">
<TaskInspect :url="inspectTask.url" :preset="inspectTask.preset" />
</Modal>
</main>
</template>
@ -383,6 +401,8 @@
import moment from 'moment'
import { useStorage } from '@vueuse/core'
import { CronExpressionParser } from 'cron-parser'
import Modal from '~/components/Modal.vue'
import TaskInspect from '~/components/TaskInspect.vue'
import type { task_item, exported_task, error_response } from '~/types/tasks'
const box = useConfirm()
@ -405,6 +425,7 @@ const masterSelectAll = ref(false)
const massRun = ref<boolean>(false)
const massDelete = ref<boolean>(false)
const table_container = ref(false)
const inspectTask = ref<task_item | null>(null)
const reset_dialog = () => ({
visible: false,

View file

@ -29,7 +29,14 @@ export default defineNuxtPlugin(nuxtApp => {
nuxtApp.vueApp.directive('rtime', {
mounted(el: RTimeElement, binding) {
const intervalMs = parseInterval(binding.arg)
const update = () => el.textContent = moment(binding.value).fromNow()
const update = () => {
const val = binding.value
if (Number.isFinite(val)) {
el.textContent = moment.unix(val as number).fromNow()
return
}
el.textContent = moment(val).fromNow()
}
update()
el._next_timer = window.setInterval(update, intervalMs)
@ -39,7 +46,14 @@ export default defineNuxtPlugin(nuxtApp => {
if (null != el._next_timer) clearInterval(el._next_timer)
const intervalMs = parseInterval(binding.arg)
const update = () => el.textContent = moment(binding.value).fromNow()
const update = () => {
const val = binding.value
if (Number.isFinite(val)) {
el.textContent = moment.unix(val as number).fromNow()
return
}
el.textContent = moment(val).fromNow()
}
update()
el._next_timer = window.setInterval(update, intervalMs)

View file

@ -30,6 +30,9 @@ export const useSocketStore = defineStore('socket', () => {
const opts = {
transports: ['websocket', 'polling'],
withCredentials: true,
reconnection: true,
reconnectionAttempts: 30,
reconnectionDelay: 5000
} as Partial<ManagerOptions & SocketOptions>
let url = runtimeConfig.public.wss

View file

@ -0,0 +1,109 @@
// --- Task Definition Schema Types ---
export type TaskMatchPattern = | string | { regex?: string; glob?: string }
export type EngineType = 'httpx' | 'selenium'
export interface EngineOptions {
url?: string
browser?: 'chrome'
arguments?: Array<string> | string
wait_for?: WaitForSelector
wait_timeout?: number
page_load_timeout?: number
[key: string]: unknown
}
export interface EngineConfig {
type?: EngineType
options?: EngineOptions
}
export type HttpMethod = 'GET' | 'POST'
export type StringMap = Record<string, string | number | boolean | null>
export interface RequestConfig {
method?: HttpMethod
url?: string
headers?: StringMap
params?: StringMap
data?: StringMap | string | null
json?: object | Array<unknown> | string | number | boolean | null
timeout?: number
}
export type ResponseType = 'html' | 'json'
export interface ResponseConfig {
type?: ResponseType
}
export type ExtractionType = 'css' | 'xpath' | 'regex' | 'jsonpath'
export interface PostFilter {
filter: string
value?: string
}
export interface ExtractionRule {
type: ExtractionType
expression: string
attribute?: string
post_filter?: PostFilter
}
export interface ContainerFields {
link: ExtractionRule
[field: string]: ExtractionRule
}
export type ContainerSelectorType = 'css' | 'xpath' | 'jsonpath'
export interface Container {
type?: ContainerSelectorType
selector?: string
expression?: string
fields: ContainerFields
}
export interface WaitForSelector {
type?: 'css' | 'xpath'
expression?: string
value?: string
}
export interface ParseConfig {
items?: Container
link?: ExtractionRule
[field: string]: ExtractionRule | Container | undefined
}
export interface TaskDefinitionDocument {
name: string
match: Array<TaskMatchPattern>
parse: ParseConfig
priority?: number
engine?: EngineConfig
request?: RequestConfig
response?: ResponseConfig
}
// --- Summaries and Error Types ---
export type TaskDefinitionSummary = {
id: string,
name: string,
priority: number,
updated_at: number,
}
export type TaskDefinitionDetailed = TaskDefinitionSummary & {
definition: TaskDefinitionDocument
}
export type TaskDefinitionErrorResponse = {
error: string,
}

20
ui/app/types/task_inspect.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
// Types for api/tasks/inspect
export interface TaskInspectRequest {
url: string;
preset?: string;
handler?: string;
}
export interface TaskInspectSuccess {
// The structure depends on TaskResult, but at minimum:
success?: boolean;
[key: string]: unknown;
}
export interface TaskInspectError {
error: string;
message?: string;
}
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectError;

View file

@ -0,0 +1,190 @@
import * as utils from '../../app/utils/index'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useTaskDefinitions } from '~/composables/useTaskDefinitions'
import type { TaskDefinitionSummary } from '~/types/task_definitions'
vi.mock('~/composables/useNotification', () => {
const success = vi.fn()
const error = vi.fn()
return {
useNotification: () => ({ success, error }),
default: () => ({ success, error })
}
})
// Sample data
const summary: TaskDefinitionSummary = {
id: 'abc',
name: 'Test',
priority: 1,
updated_at: 123456,
}
// Helper to create a mock Response object
function createMockResponse({ ok, status, jsonData }: { ok: boolean, status: number, jsonData: any }) {
return {
ok,
status,
headers: new Headers(),
redirected: false,
statusText: '',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() { return this },
async json() { return jsonData },
text: async () => JSON.stringify(jsonData),
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response
}
describe('useTaskDefinitions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sorts definitions by priority then name', async () => {
const items = [
{ id: '1', name: 'B', priority: 2, updated_at: 1 },
{ id: '2', name: 'A', priority: 2, updated_at: 2 },
{ id: '3', name: 'C', priority: 1, updated_at: 3 },
]
vi.spyOn(utils, 'request').mockResolvedValueOnce(createMockResponse({
ok: true,
status: 200,
jsonData: items,
}))
const defs = useTaskDefinitions()
await defs.loadDefinitions()
expect(defs.definitions.value.map(d => d.id)).toEqual(['3', '2', '1'])
})
it('handles empty payload', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(createMockResponse({
ok: true,
status: 200,
jsonData: [],
}))
const defs = useTaskDefinitions()
await defs.loadDefinitions()
expect(defs.definitions.value).toEqual([])
expect(defs.lastError.value).toBeNull()
})
it('handles malformed payload', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: true,
json: async () => ({ jsonData: [{}] })
})
const defs = useTaskDefinitions()
await expect(defs.loadDefinitions()).resolves.toBeUndefined()
})
it('handles malformed payload (throws when throwInstead is true)', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: true,
json: async () => ({ jsonData: [{}] })
})
const defs = useTaskDefinitions()
defs.throwInstead.value = true
await expect(defs.loadDefinitions()).rejects.toThrow()
})
it('handles duplicate IDs (no deduplication, both present)', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: true,
json: async () => [
{ id: 'dup', name: 'A', priority: 1 },
{ id: 'dup', name: 'B', priority: 2 },
]
})
const defs = useTaskDefinitions()
await defs.loadDefinitions()
expect(defs.definitions.value.length).toBe(2)
expect(defs.definitions.value[0].name).toBe('A')
expect(defs.definitions.value[1].name).toBe('B')
})
it('handles error on getDefinition', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({})
})
const defs = useTaskDefinitions()
await expect(defs.getDefinition('notfound')).rejects.toThrow('Request failed with status 404')
})
it('handles malformed getDefinition response', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: true,
json: async () => ({})
})
const defs = useTaskDefinitions()
await expect(defs.getDefinition('bad')).rejects.toThrow('Task definition response is missing definition payload.')
})
it('handles error on createDefinition', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({})
})
const defs = useTaskDefinitions()
await expect(defs.createDefinition({ id: 'fail', name: 'Fail', priority: 1 })).rejects.toThrow('Request failed with status 400')
})
it('handles error on updateDefinition', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({})
})
const defs = useTaskDefinitions()
await expect(defs.updateDefinition({ id: 'fail', name: 'Fail', priority: 1 })).rejects.toThrow('Request failed with status 400')
})
it('handles error on deleteDefinition', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: false,
status: 400,
statusText: 'Bad Request',
json: async () => ({})
})
const defs = useTaskDefinitions()
await expect(defs.deleteDefinition('fail')).rejects.toThrow('Request failed with status 400')
})
it('loads definitions successfully (duplicate test)', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(createMockResponse({
ok: true,
status: 200,
jsonData: [summary],
}))
const defs = useTaskDefinitions()
await defs.loadDefinitions()
expect(defs.definitions.value).toEqual([summary])
expect(defs.lastError.value).toBeNull()
})
it('calls success notification on createDefinition', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce({
ok: true,
json: async () => ({})
})
const defs = useTaskDefinitions()
await defs.createDefinition({ id: 'new', name: 'New', priority: 1 })
// Access the spy directly from the mock
const notify = (await import('~/composables/useNotification')).useNotification()
expect(notify.success).toHaveBeenCalledWith('Task definition created.')
})
});

216
uv.lock
View file

@ -82,15 +82,15 @@ wheels = [
[[package]]
name = "anyio"
version = "4.10.0"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252 }
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213 },
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097 },
]
[[package]]
@ -524,47 +524,92 @@ wheels = [
]
[[package]]
name = "lxml"
version = "6.0.1"
name = "jsonschema"
version = "4.25.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 }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342 }
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 },
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040 },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 },
]
[[package]]
name = "lxml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494 },
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146 },
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932 },
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060 },
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000 },
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496 },
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779 },
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072 },
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675 },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171 },
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175 },
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688 },
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655 },
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695 },
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841 },
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700 },
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347 },
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248 },
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801 },
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403 },
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974 },
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953 },
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054 },
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421 },
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684 },
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463 },
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437 },
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890 },
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185 },
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895 },
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246 },
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797 },
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404 },
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072 },
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617 },
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930 },
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380 },
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632 },
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171 },
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109 },
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061 },
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233 },
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739 },
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119 },
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665 },
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997 },
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957 },
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372 },
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653 },
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795 },
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023 },
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420 },
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837 },
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205 },
]
[[package]]
@ -1021,6 +1066,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
]
[[package]]
name = "referencing"
version = "0.36.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 },
]
[[package]]
name = "regex"
version = "2025.9.18"
@ -1113,6 +1171,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
]
[[package]]
name = "rpds-py"
version = "0.27.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741 },
{ url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574 },
{ url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051 },
{ url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395 },
{ url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334 },
{ url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691 },
{ url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868 },
{ url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469 },
{ url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125 },
{ url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341 },
{ url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511 },
{ url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736 },
{ url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462 },
{ url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034 },
{ url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392 },
{ url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355 },
{ url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138 },
{ url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247 },
{ url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699 },
{ url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852 },
{ url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582 },
{ url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126 },
{ url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486 },
{ url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832 },
{ url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249 },
{ url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356 },
{ url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300 },
{ url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714 },
{ url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943 },
{ url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472 },
{ url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676 },
{ url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313 },
{ url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080 },
{ url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868 },
{ url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750 },
{ url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688 },
{ url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225 },
{ url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361 },
{ url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493 },
{ url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623 },
{ url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800 },
{ url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943 },
{ url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739 },
{ url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120 },
{ url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944 },
{ url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283 },
{ url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320 },
{ url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760 },
{ url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476 },
{ url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418 },
{ url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771 },
{ url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022 },
{ url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787 },
{ url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538 },
{ url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512 },
{ url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813 },
{ url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385 },
{ url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097 },
]
[[package]]
name = "ruff"
version = "0.13.1"
@ -1359,11 +1483,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.9.5"
version = "2025.9.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/b2/fb255d991857a6a8b2539487ed6063e7bf318f19310d81f039dedb3c2ad6/yt_dlp-2025.9.5.tar.gz", hash = "sha256:9ce080f80b2258e872fe8a75f4707ea2c644e697477186e20b9a04d9a9ea37cf", size = 3045982 }
sdist = { url = "https://files.pythonhosted.org/packages/90/77/24a13bbd3190849e7e37e6093aa9648f3b26a836d37ba67e3429ee89ad1d/yt_dlp-2025.9.23.tar.gz", hash = "sha256:9282ad1deadb4c90b2e6d3bcf9f360abf88c5f2e4ba836dad7b51387b086d757", size = 3036855 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/64/b3cc116e4f209c493f23d6af033c60ba32df74e086190fbed2bdc0073d12/yt_dlp-2025.9.5-py3-none-any.whl", hash = "sha256:68a03b5c50e3d0f6af7244bd4bf491c1b12e4e2112b051cde05cdfd2647eb9a8", size = 3272317 },
{ url = "https://files.pythonhosted.org/packages/1e/b7/723a4061d7efac4bbb86eddef9ceec0fc1e85415c7d665c2e5e1879759bb/yt_dlp-2025.9.23-py3-none-any.whl", hash = "sha256:84e36acf2dfbadb307d734a590dd937923173b13c57cf45a662f40c93e746302", size = 3241497 },
]
[[package]]
@ -1388,6 +1512,7 @@ dependencies = [
{ name = "httpx" },
{ name = "httpx-curl-cffi" },
{ name = "jmespath" },
{ name = "jsonschema" },
{ name = "multidict" },
{ name = "mutagen" },
{ name = "parsel" },
@ -1438,6 +1563,7 @@ requires-dist = [
{ name = "httpx" },
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
{ name = "jmespath", specifier = ">=1.0.1" },
{ name = "jsonschema", specifier = ">=4.23.0" },
{ name = "multidict", specifier = "==6.5.1" },
{ name = "mutagen" },
{ name = "parsel", specifier = ">=1.10.0" },