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
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:
commit
b105749121
40 changed files with 4108 additions and 171 deletions
38
.vscode/settings.json
vendored
38
.vscode/settings.json
vendored
|
|
@ -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
10
FAQ.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
435
app/library/TaskDefinitions.py
Normal file
435
app/library/TaskDefinitions.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
145
app/routes/api/task_definitions.py
Normal file
145
app/routes/api/task_definitions.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
|
|
|||
56
app/schema/conditions.json
Normal file
56
app/schema/conditions.json
Normal 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
78
app/schema/dl_fields.json
Normal 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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
175
app/schema/notifications.json
Normal file
175
app/schema/notifications.json
Normal 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
67
app/schema/presets.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
84
app/schema/tasks.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
315
app/tests/test_task_definitions.py
Normal file
315
app/tests/test_task_definitions.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
870
ui/app/components/TaskDefinitionEditor.vue
Normal file
870
ui/app/components/TaskDefinitionEditor.vue
Normal 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/* 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>
|
||||
186
ui/app/components/TaskInspect.vue
Normal file
186
ui/app/components/TaskInspect.vue
Normal 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>
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
309
ui/app/composables/useTaskDefinitions.ts
Normal file
309
ui/app/composables/useTaskDefinitions.ts
Normal 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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
399
ui/app/pages/task_definitions.vue
Normal file
399
ui/app/pages/task_definitions.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
109
ui/app/types/task_definitions.ts
Normal file
109
ui/app/types/task_definitions.ts
Normal 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
20
ui/app/types/task_inspect.d.ts
vendored
Normal 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;
|
||||
190
ui/tests/composables/useTaskDefinitions.test.ts
Normal file
190
ui/tests/composables/useTaskDefinitions.test.ts
Normal 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
216
uv.lock
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue