Merge pull request #423 from arabcoders/dev
Use local downloaded thumbnail if available
This commit is contained in:
commit
dd3059d01e
39 changed files with 5623 additions and 1048 deletions
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
|
|
@ -34,12 +34,14 @@
|
|||
"Cfmrc",
|
||||
"choco",
|
||||
"cifs",
|
||||
"connectionpool",
|
||||
"consoletitle",
|
||||
"continuedl",
|
||||
"cookiesfrombrowser",
|
||||
"copyts",
|
||||
"creationflags",
|
||||
"cronsim",
|
||||
"currsize",
|
||||
"dailymotion",
|
||||
"datas",
|
||||
"dateparser",
|
||||
|
|
@ -189,5 +191,13 @@
|
|||
"app/tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": true,
|
||||
"python.testing.pytestEnabled": true
|
||||
"python.testing.pytestEnabled": true,
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"**/var/config/tasks/*.json"
|
||||
],
|
||||
"url": "./app/library/task_handlers/task_definition.schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
57
API.md
57
API.md
|
|
@ -32,6 +32,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [DELETE /api/archiver](#delete-apiarchiver)
|
||||
- [GET /api/tasks](#get-apitasks)
|
||||
- [PUT /api/tasks](#put-apitasks)
|
||||
- [POST /api/tasks/inspect](#post-apitasksinspect)
|
||||
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
||||
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
|
||||
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
|
||||
|
|
@ -551,6 +552,62 @@ or on error
|
|||
|
||||
---
|
||||
|
||||
### POST /api/tasks/inspect
|
||||
**Purpose**: Preview which handler matches a URL and review the items it would queue without dispatching downloads.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/feed", // required, validated URL
|
||||
"preset": "news-preset", // optional preset override - In real scenarios, the preset come from the task.
|
||||
"handler": "GenericTaskHandler" // optional explicit handler class name, in real scenarios, the handler is resolved automatically.
|
||||
}
|
||||
```
|
||||
|
||||
**Response (success)**:
|
||||
```json
|
||||
{
|
||||
"matched": true,
|
||||
"handler": "GenericTaskHandler",
|
||||
"supported": true,
|
||||
"items": [
|
||||
{
|
||||
"url": "https://example.com/video/1",
|
||||
"title": "Example title",
|
||||
"archive_id": "generic 1",
|
||||
"metadata": {
|
||||
"source_task": "...",
|
||||
"source_handler": "GenericTaskHandler"
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"raw_count": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Returns `200 OK` when inspection succeeds.
|
||||
- The `metadata` object is optional and may contain handler-specific keys.
|
||||
|
||||
**Response (failure)**:
|
||||
```json
|
||||
{
|
||||
"matched": false,
|
||||
"handler": null,
|
||||
"error": "No handler matched the supplied URL.",
|
||||
"message": "No handler matched the supplied URL.",
|
||||
"metadata": {
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Returns `400 Bad Request` when the handler cannot process the URL or inspection fails.
|
||||
- When a handler is specified but does not support manual inspection, `supported` is `false` and the `message` explains the limitation.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/tasks/{id}/mark
|
||||
**Purpose**: Mark all entries associated with a scheduled task as downloaded.
|
||||
|
||||
|
|
|
|||
128
FAQ.md
128
FAQ.md
|
|
@ -85,10 +85,6 @@ the `yt-dlp` output and attempt to download the media directly to your iOS devic
|
|||
starting point for those who want to download media directly to their iOS device. We provide no support for this use case
|
||||
other than the shortcut itself. this shortcut missing support for parsing the http_headers, it's only parse the cookies.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Authentication
|
||||
|
||||
To enable basic authentication, set the `YTP_AUTH_USERNAME` and `YTP_AUTH_PASSWORD` environment variables. And restart
|
||||
|
|
@ -97,11 +93,6 @@ As this is a simple basic authentication, if your browser doesn't show the promp
|
|||
|
||||
`http://username:password@your_ytptube_url:port`
|
||||
|
||||
# Basic mode
|
||||
|
||||
What does the basic mode do? it basically strips down the interface to the bare minimum,
|
||||
to make it easier for non-technical users to use it.
|
||||
|
||||
# I cant download anything
|
||||
|
||||
If you are receiving errors like:
|
||||
|
|
@ -154,6 +145,125 @@ YTP_YTDLP_VERSION=2025.07.21 or master or nightly
|
|||
|
||||
Then restart the container to apply the changes.
|
||||
|
||||
# How can I monitor sites without RSS feeds?
|
||||
|
||||
YTPTube includes a **generic task handler** that turns JSON definition files into site-specific scrapers. You can use it
|
||||
to watch pages that do not expose RSS or public APIs and automatically enqueue new links into the download queue.
|
||||
|
||||
1. Create definition files under `/config/tasks/*.json` (for Docker this is the mounted `config/tasks/` folder).
|
||||
2. Keep your scheduled task in `tasks.json` pointing at the page you want to monitor and make sure it uses a preset that
|
||||
enables a download archive (`--download-archive`).
|
||||
3. When the task runs, the handler scans the JSON files, picks the first definition whose `match` rule covers the task
|
||||
URL, fetches the page, extracts items, and queues the unseen ones.
|
||||
|
||||
### Definition schema
|
||||
|
||||
Each file must contain a single JSON object with the following keys:
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "example", // Friendly identifier shown in logs
|
||||
"match": [
|
||||
"https://example.com/articles/*", // Glob strings, or objects with {"regex": "..."} or {"glob": "..."}
|
||||
{ "regex": "https://example.com/post/[0-9]+" }
|
||||
],
|
||||
"engine": { // Optional, defaults to HTTPX
|
||||
"type": "httpx", // "httpx" (default) or "selenium"
|
||||
"options": {
|
||||
"url": "http://selenium:4444/wd/hub", // Selenium-only: remote hub URL
|
||||
"arguments": ["--headless", "--disable-gpu"],
|
||||
"wait_for": { "type": "css", "expression": ".article" },
|
||||
"wait_timeout": 15,
|
||||
"page_load_timeout": 60
|
||||
}
|
||||
},
|
||||
"request": { // Optional HTTP settings
|
||||
"method": "GET", // GET or POST
|
||||
"url": "https://example.com/articles/latest", // Override the task URL if needed
|
||||
"headers": { "User-Agent": "MyAgent/1.0" },
|
||||
"params": { "page": 1 },
|
||||
"data": null,
|
||||
"json": null,
|
||||
"timeout": 30
|
||||
},
|
||||
"response": { // Optional: how to interpret the body
|
||||
"type": "html" // "html" (default) or "json"
|
||||
},
|
||||
"parse": {
|
||||
"items": { // Optional container for per-item extraction
|
||||
"selector": ".columns .card", // Defaults to CSS; set "type": "xpath" to use XPath
|
||||
"fields": {
|
||||
"link": { // Required inside fields: the per-item URL
|
||||
"type": "css",
|
||||
"expression": ".card-header a",
|
||||
"attribute": "href"
|
||||
},
|
||||
"title": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a",
|
||||
"attribute": "text"
|
||||
},
|
||||
"poet": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:first-child a",
|
||||
"attribute": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"page_title": { // Optional global field outside the container
|
||||
"type": "css",
|
||||
"expression": "title",
|
||||
"attribute": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For JSON endpoints, switch the response format and use `jsonpath` selectors:
|
||||
|
||||
```json5
|
||||
{
|
||||
"response": { "type": "json" },
|
||||
"parse": {
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": { "type": "jsonpath", "expression": "url" },
|
||||
"title": { "type": "jsonpath", "expression": "title" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Parsing rules
|
||||
|
||||
- Every definition must provide a `link` field either at the top level or inside `parse.items.fields`. Other fields are optional metadata attached to the queued item.
|
||||
- CSS and XPath rules may specify `attribute`:
|
||||
- `text` / `inner_text` applies `normalize-space()`.
|
||||
- `html` / `outer_html` returns the raw HTML fragment.
|
||||
- Any other value reads that attribute from the element. When omitted, the handler uses text and, for `link`, falls
|
||||
back to `href` automatically.
|
||||
- Regex rules scan the HTML fragment associated with the current scope (page-level or container). Set `attribute` to a named/numbered capture group or rely on the first group.
|
||||
- `post_filter` lets you run a final regex on the extracted value and pick a named (`value`) group.
|
||||
- When you declare `parse.items`, each matching container is processed independently so missing fields in one card do not shift values for the rest.
|
||||
- For JSON responses (`response.type = "json"`), set the container `type` and field `type` to `jsonpath` and supply [JMESPath](https://jmespath.org/) expressions. Relative values are resolved against each container object and converted to strings automatically.
|
||||
|
||||
### Fetch engines
|
||||
|
||||
- **httpx (default)**: supports custom headers, query params, JSON/body payloads, proxy inherited from the task preset,
|
||||
and optional timeout.
|
||||
- **selenium**: uses a remote Chrome session. Provide the hub URL under `engine.options.url`; only Chrome is supported at
|
||||
the moment. Optional keys: `arguments` (list or string), `wait_for` (type `css`/`xpath` + `expression`), `wait_timeout`,
|
||||
and `page_load_timeout`.
|
||||
|
||||
Definitions are reloaded automatically when files change, so you can tweak them without restarting YTPTube. Check
|
||||
`var/config/tasks/01-*.json` for sample files.
|
||||
|
||||
> [!NOTE]
|
||||
> A machine-readable schema is available at `app/library/task_handlers/task_definition.schema.json` if you want to validate your JSON with editors or CI tools.
|
||||
|
||||
# How to generate POT tokens?
|
||||
|
||||
You need a pot provider server we already have the extractor `bgutil-ytdlp-pot-provider` pre-installed in the container.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||

|
||||
|
||||
**YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from
|
||||
YouTube and other video platforms easier and more user-friendly. It supports downloading playlists, channels, and
|
||||
live streams, and includes features like scheduling downloads, sending notifications, and a built-in video player.
|
||||
video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and
|
||||
includes features like scheduling downloads, sending notifications, and built-in video player.
|
||||
|
||||

|
||||
|
||||
|
|
@ -16,6 +16,7 @@ live streams, and includes features like scheduling downloads, sending notificat
|
|||
* Random beautiful background.
|
||||
* Handles live and upcoming streams.
|
||||
* Schedule channels or playlists to be downloaded automatically.
|
||||
* Create your own custom task handler feeds for downloads, See [Feeds documentation](FAQ.md#how-can-i-monitor-sites-without-rss-feeds).
|
||||
* Send notification to targets based on selected events. includes [Apprise](https://github.com/caronc/apprise?tab=readme-ov-file#readme) support.
|
||||
* Support per link options.
|
||||
* Support for limits per extractor and overall global limit.
|
||||
|
|
@ -38,7 +39,7 @@ Please read the [FAQ](FAQ.md) for more information.
|
|||
## Run using docker command
|
||||
|
||||
```bash
|
||||
mkdir -p ./{config,downloads} && docker run -d --rm --user "$UID:${GID-$UID}" --name ytptube \
|
||||
mkdir -p ./{config,downloads} && docker run -d --rm --user "${UID}:${UID}" --name ytptube \
|
||||
-p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
|
||||
ghcr.io/arabcoders/ytptube:latest
|
||||
```
|
||||
|
|
@ -93,7 +94,7 @@ For simple API documentation, you can refer to the [API documentation](API.md).
|
|||
|
||||
# Disclaimer
|
||||
|
||||
This project is not affiliated with YouTube, yt-dlp, or any other service. It's a personal project that was created to
|
||||
This project is not affiliated with yt-dlp, or any other service. It's a personal project that was created to
|
||||
make downloading videos from the internet easier. It's not intended to be used for piracy or any other illegal activities.
|
||||
|
||||
# Social contact
|
||||
|
|
|
|||
|
|
@ -465,6 +465,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
dl = ItemDTO(
|
||||
id=str(entry.get("id")),
|
||||
title=str(entry.get("title")),
|
||||
description=str(entry.get("description", "")),
|
||||
url=str(entry.get("webpage_url") or entry.get("url")),
|
||||
preset=item.preset,
|
||||
folder=item.folder,
|
||||
|
|
@ -982,6 +983,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
items["queue"][k] = self._active[k].info if k in self._active else v
|
||||
|
||||
for k, v in self.done.saved_items():
|
||||
v.get_file_sidecar()
|
||||
items["done"][k] = v
|
||||
|
||||
for k, v in self.queue.items():
|
||||
|
|
@ -989,8 +991,11 @@ class DownloadQueue(metaclass=Singleton):
|
|||
items["queue"][k] = self._active[k].info if k in self._active else v
|
||||
|
||||
for k, v in self.done.items():
|
||||
if k not in items["done"]:
|
||||
items["done"][k] = v.info
|
||||
if k in items["done"]:
|
||||
continue
|
||||
|
||||
v.info.get_file_sidecar()
|
||||
items["done"][k] = v.info
|
||||
|
||||
return items
|
||||
|
||||
|
|
@ -1251,6 +1256,9 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if task.cancelled():
|
||||
return
|
||||
|
||||
if exc := task.exception():
|
||||
task_name: str = task.get_name() if task.get_name() else "unknown_task"
|
||||
LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {traceback.format_exc()}")
|
||||
if not (exc := task.exception()):
|
||||
return
|
||||
|
||||
task_name: str = task.get_name() if task.get_name() else "unknown_task"
|
||||
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {tb}")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from email.utils import formatdate
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.library.Utils import archive_add, archive_delete, archive_read, clean_item, get_archive_id
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Utils import (
|
||||
archive_add,
|
||||
archive_delete,
|
||||
archive_read,
|
||||
clean_item,
|
||||
get_archive_id,
|
||||
get_file,
|
||||
get_file_sidecar,
|
||||
)
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("ItemDTO")
|
||||
|
|
@ -47,12 +56,37 @@ class Item:
|
|||
"""If the item should be started automatically."""
|
||||
|
||||
def serialize(self) -> dict:
|
||||
"""
|
||||
Serialize the item to a dictionary.
|
||||
|
||||
Returns:
|
||||
dict: The serialized item.
|
||||
|
||||
"""
|
||||
return self.__dict__.copy()
|
||||
|
||||
def json(self) -> str:
|
||||
return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)
|
||||
"""
|
||||
Convert the item to a JSON string.
|
||||
|
||||
Returns:
|
||||
str: The JSON string representation of the item.
|
||||
|
||||
"""
|
||||
return Encoder(sort_keys=True, indent=4).encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a value from the item by key.
|
||||
|
||||
Args:
|
||||
key (str): The key to get the value for.
|
||||
default (Any): The default value to return if the key is not found.
|
||||
|
||||
Returns:
|
||||
Any: The value for the key, or the default value if the key is not found
|
||||
|
||||
"""
|
||||
return self.__dict__.get(key, default)
|
||||
|
||||
def has_extras(self) -> bool:
|
||||
|
|
@ -77,6 +111,13 @@ class Item:
|
|||
|
||||
@staticmethod
|
||||
def _default_preset() -> str:
|
||||
"""
|
||||
Get the default preset from the configuration.
|
||||
|
||||
Returns:
|
||||
str: The default preset name.
|
||||
|
||||
"""
|
||||
from .config import Config
|
||||
|
||||
return Config.get_instance().default_preset
|
||||
|
|
@ -167,12 +208,33 @@ class Item:
|
|||
return Item(**data)
|
||||
|
||||
def get_archive_id(self) -> str | None:
|
||||
"""
|
||||
Get the archive ID for the download URL.
|
||||
|
||||
Returns:
|
||||
str | None: The archive ID if available, None otherwise.
|
||||
|
||||
"""
|
||||
return get_archive_id(self.url).get("archive_id") if self.url else None
|
||||
|
||||
def get_extractor(self) -> str | None:
|
||||
"""
|
||||
Get the extractor key for the download URL.
|
||||
|
||||
Returns:
|
||||
str | None: The extractor key if available, None otherwise.
|
||||
|
||||
"""
|
||||
return get_archive_id(self.url).get("ie_key") if self.url else None
|
||||
|
||||
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||
"""
|
||||
Get the yt-dlp options for the item.
|
||||
|
||||
Returns:
|
||||
YTDLPOpts: The yt-dlp options for the item.
|
||||
|
||||
"""
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if self.preset:
|
||||
|
|
@ -184,14 +246,35 @@ class Item:
|
|||
return params
|
||||
|
||||
def get_archive_file(self) -> str | None:
|
||||
"""
|
||||
Get the archive file path from the yt-dlp options.
|
||||
|
||||
Returns:
|
||||
str | None: The archive file path if available, None otherwise.
|
||||
|
||||
"""
|
||||
return self.get_ytdlp_opts().get_all().get("download_archive")
|
||||
|
||||
def is_archived(self) -> bool:
|
||||
"""
|
||||
Check if the item has been archived.
|
||||
|
||||
Returns:
|
||||
bool: True if the item has been archived, False otherwise.
|
||||
|
||||
"""
|
||||
archive_id: str | None = self.get_archive_id()
|
||||
archive_file: str | None = self.get_archive_file()
|
||||
return len(archive_read(archive_file, [archive_id])) > 0 if archive_file and archive_id else False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Get a short string representation of the item.
|
||||
|
||||
Returns:
|
||||
str: A short string representation of the item.
|
||||
|
||||
"""
|
||||
from .config import Config
|
||||
from .Utils import calc_download_path, strip_newline
|
||||
|
||||
|
|
@ -230,6 +313,8 @@ class ItemDTO:
|
|||
""" The ID of the item yt-dlp """
|
||||
title: str
|
||||
""" The title of the item. """
|
||||
description: str = ""
|
||||
""" The description of the item. """
|
||||
url: str
|
||||
""" The URL of the item. """
|
||||
preset: str = "default"
|
||||
|
|
@ -272,6 +357,8 @@ class ItemDTO:
|
|||
""" If the item has been archived. """
|
||||
archive_id: str | None = None
|
||||
""" The archive ID of the item. """
|
||||
sidecar: dict = field(default_factory=dict)
|
||||
""" Sidecar data associated with the item. """
|
||||
|
||||
# yt-dlp injected fields.
|
||||
tmpfilename: str | None = None
|
||||
|
|
@ -288,24 +375,101 @@ class ItemDTO:
|
|||
_archive_file: str | None = None
|
||||
|
||||
def serialize(self) -> dict:
|
||||
if "finished" == self.status and not self._recomputed:
|
||||
self.archive_status()
|
||||
"""
|
||||
Serialize the item to a dictionary.
|
||||
|
||||
Returns:
|
||||
dict: The serialized item.
|
||||
|
||||
"""
|
||||
if "finished" == self.status:
|
||||
if not self._recomputed:
|
||||
self.archive_status()
|
||||
|
||||
self.get_file_sidecar()
|
||||
|
||||
item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields())
|
||||
return item
|
||||
|
||||
def json(self) -> str:
|
||||
return json.dumps(self.serialize(), default=lambda o: o.__dict__, sort_keys=True, indent=4)
|
||||
"""
|
||||
Convert the item to a JSON string.
|
||||
|
||||
Returns:
|
||||
str: The JSON string representation of the item.
|
||||
|
||||
"""
|
||||
return Encoder(sort_keys=True, indent=4).encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a value from the item by key.
|
||||
|
||||
Args:
|
||||
key (str): The key to get the value for.
|
||||
default (Any): The default value to return if the key is not found.
|
||||
|
||||
Returns:
|
||||
Any: The value for the key, or the default value if the key is not found
|
||||
|
||||
"""
|
||||
return self.__dict__.get(key, default)
|
||||
|
||||
def get_id(self) -> str:
|
||||
"""
|
||||
Get the unique identifier for the item.
|
||||
|
||||
Returns:
|
||||
str: The unique identifier for the item.
|
||||
|
||||
"""
|
||||
return self._id
|
||||
|
||||
def name(self) -> str:
|
||||
"""
|
||||
Get a short string representation of the item.
|
||||
|
||||
Returns:
|
||||
str: A short string representation of the item.
|
||||
|
||||
"""
|
||||
return f'id="{self.id}", title="{self.title}"'
|
||||
|
||||
def get_file(self, download_path: Path | None = None) -> Path | None:
|
||||
"""
|
||||
Get the file path of the downloaded item.
|
||||
|
||||
Args:
|
||||
download_path (Path | None): The base download path. If None, it will be taken from the config.
|
||||
|
||||
Returns:
|
||||
Path | None: The file path of the downloaded item, or None if not available.
|
||||
|
||||
"""
|
||||
if not self.filename:
|
||||
return None
|
||||
|
||||
if not download_path:
|
||||
from .config import Config
|
||||
|
||||
base_path = Path(Config.get_instance().download_path)
|
||||
else:
|
||||
base_path = download_path if isinstance(download_path, Path) else Path(download_path)
|
||||
|
||||
filename = base_path
|
||||
|
||||
if self.folder:
|
||||
filename: Path = filename / self.folder
|
||||
|
||||
filename = filename / self.filename
|
||||
|
||||
try:
|
||||
realFile, status = get_file(download_path=base_path, file=str(filename.relative_to(base_path)))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return Path(realFile) if status in (200, 302) else None
|
||||
|
||||
def get_archive_id(self) -> str | None:
|
||||
"""
|
||||
Get the archive ID for the download URL.
|
||||
|
|
@ -326,6 +490,13 @@ class ItemDTO:
|
|||
return self.archive_id
|
||||
|
||||
def get_extractor(self) -> str | None:
|
||||
"""
|
||||
Get the extractor key for the download URL.
|
||||
|
||||
Returns:
|
||||
str | None: The extractor key if available, None otherwise.
|
||||
|
||||
"""
|
||||
if self.archive_id:
|
||||
return self.archive_id.split(" ")[0]
|
||||
|
||||
|
|
@ -353,6 +524,13 @@ class ItemDTO:
|
|||
return params
|
||||
|
||||
def archive_status(self, force: bool = False) -> None:
|
||||
"""
|
||||
Recompute the archive status of the item.
|
||||
|
||||
Args:
|
||||
force (bool): If True, force recomputation even if already computed.
|
||||
|
||||
"""
|
||||
if not force and (self._recomputed or not self.archive_id):
|
||||
return
|
||||
|
||||
|
|
@ -413,9 +591,28 @@ class ItemDTO:
|
|||
|
||||
return True
|
||||
|
||||
def get_file_sidecar(self) -> dict:
|
||||
"""
|
||||
Get sidecar files associated with the item.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with sidecar files categorized by type.
|
||||
|
||||
"""
|
||||
if filename := self.get_file():
|
||||
self.sidecar = get_file_sidecar(filename)
|
||||
|
||||
return self.sidecar
|
||||
|
||||
@staticmethod
|
||||
def removed_fields() -> tuple:
|
||||
"""Fields that once existed but are no longer used."""
|
||||
"""
|
||||
Fields that once existed but are no longer used.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple of field names that are no longer used.
|
||||
|
||||
"""
|
||||
return (
|
||||
"thumbnail",
|
||||
"quality",
|
||||
|
|
@ -431,6 +628,10 @@ class ItemDTO:
|
|||
)
|
||||
|
||||
def __post_init__(self):
|
||||
"""
|
||||
Post-initialization to compute archive status if applicable.
|
||||
"""
|
||||
self.get_archive_id()
|
||||
self.get_archive_file()
|
||||
self.archive_status()
|
||||
self.get_file_sidecar()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from collections.abc import Awaitable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -464,36 +464,36 @@ class Notification(metaclass=Singleton):
|
|||
try:
|
||||
LOG.info(f"Sending Notification event '{ev.event}: {ev.id}' to '{target.name}'.")
|
||||
|
||||
reqBody: dict[str, Any] = {
|
||||
"method": target.request.method.upper(),
|
||||
"url": target.request.url,
|
||||
"headers": {
|
||||
"User-Agent": f"YTPTube/{self._version}",
|
||||
"X-Event-Id": ev.id,
|
||||
"X-Event": ev.event,
|
||||
"Content-Type": "application/json"
|
||||
if "json" == target.request.type.lower()
|
||||
else "application/x-www-form-urlencoded",
|
||||
},
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": f"YTPTube/{self._version}",
|
||||
"X-Event-Id": ev.id,
|
||||
"X-Event": ev.event,
|
||||
"Content-Type": "application/json"
|
||||
if "json" == target.request.type.lower()
|
||||
else "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
if len(target.request.headers) > 0:
|
||||
for h in target.request.headers:
|
||||
reqBody["headers"][h.key] = h.value
|
||||
headers.update({h.key: h.value for h in target.request.headers if h.key and h.value})
|
||||
|
||||
body_key: str = "json" if "json" == target.request.type.lower() else "data"
|
||||
reqBody[body_key] = self._deep_unpack(ev.serialize())
|
||||
payload: dict = ev.serialize()
|
||||
|
||||
if "data" != target.request.data_key:
|
||||
reqBody[body_key][target.request.data_key] = reqBody[body_key]["data"]
|
||||
reqBody[body_key].pop("data", None)
|
||||
payload[target.request.data_key] = payload["data"]
|
||||
payload.pop("data", None)
|
||||
|
||||
if "form" == target.request.type.lower():
|
||||
reqBody[body_key][target.request.data_key] = self._encoder.encode(
|
||||
reqBody[body_key][target.request.data_key]
|
||||
)
|
||||
payload[target.request.data_key] = self._encoder.encode(payload[target.request.data_key])
|
||||
else:
|
||||
payload = self._encoder.encode(payload)
|
||||
|
||||
response = await self._client.request(**reqBody)
|
||||
response = await self._client.request(
|
||||
method=target.request.method.upper(),
|
||||
url=target.request.url,
|
||||
headers=headers,
|
||||
data=payload if "form" == target.request.type.lower() else None,
|
||||
content=payload if "json" == target.request.type.lower() else None,
|
||||
)
|
||||
|
||||
respData: dict[str, Any] = {
|
||||
"url": target.request.url,
|
||||
|
|
@ -512,7 +512,9 @@ class Notification(metaclass=Singleton):
|
|||
err_msg = str(e)
|
||||
if not err_msg:
|
||||
err_msg: str = type(e).__name__
|
||||
LOG.error(f"Error sending Notification event '{ev.event}: {ev.id}' to '{target.name}'. '{err_msg!s}'.")
|
||||
|
||||
tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
|
||||
LOG.error(f"Error sending Notification event '{ev.event}: {ev.id}' to '{target.name}'. '{err_msg!s}'. {tb}")
|
||||
return {"url": target.request.url, "status": 500, "text": str(ev)}
|
||||
|
||||
def emit(self, e: Event, _, **__) -> None:
|
||||
|
|
@ -522,18 +524,5 @@ class Notification(metaclass=Singleton):
|
|||
self._offload.submit(self.send, e)
|
||||
return
|
||||
|
||||
def _deep_unpack(self, data: dict) -> dict:
|
||||
for k, v in data.items():
|
||||
if isinstance(v, dict):
|
||||
data[k] = self._deep_unpack(v)
|
||||
if isinstance(v, list):
|
||||
data[k] = [self._deep_unpack(i) for i in v]
|
||||
if isinstance(v, datetime):
|
||||
data[k] = v.isoformat()
|
||||
if isinstance(v, object) and hasattr(v, "serialize"):
|
||||
data[k] = v.serialize()
|
||||
|
||||
return data
|
||||
|
||||
async def noop(self) -> None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ import sys
|
|||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
SUPPORTED_CODECS: tuple[str] = ("h264_qsv", "h264_nvenc", "h264_amf", "h264_videotoolbox", "h264_vaapi", "libx264")
|
||||
"Supported encoder names in order of preference."
|
||||
|
||||
|
||||
def has_dri_devices() -> bool:
|
||||
"""
|
||||
|
|
@ -40,6 +37,7 @@ def ffmpeg_encoders() -> set[str]:
|
|||
set[str]: A set of available ffmpeg encoder names.
|
||||
|
||||
"""
|
||||
from .config import SUPPORTED_CODECS
|
||||
try:
|
||||
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], # noqa: S607
|
||||
|
|
@ -75,6 +73,7 @@ def select_encoder(configured: str) -> str:
|
|||
str: The selected concrete encoder name.
|
||||
|
||||
"""
|
||||
from .config import SUPPORTED_CODECS
|
||||
configured = (configured or "").strip()
|
||||
|
||||
avail: set[str] = ffmpeg_encoders()
|
||||
|
|
|
|||
|
|
@ -10,15 +10,9 @@ from typing import TYPE_CHECKING, Any, ClassVar
|
|||
|
||||
from aiohttp import web
|
||||
|
||||
from .config import Config
|
||||
from .config import SUPPORTED_CODECS, Config
|
||||
from .ffprobe import ffprobe
|
||||
from .SegmentEncoders import (
|
||||
SUPPORTED_CODECS,
|
||||
encoder_fallback_chain,
|
||||
get_builder_for_codec,
|
||||
has_dri_devices,
|
||||
select_encoder,
|
||||
)
|
||||
from .SegmentEncoders import encoder_fallback_chain, get_builder_for_codec, has_dri_devices, select_encoder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import json
|
|||
import logging
|
||||
import pkgutil
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -15,11 +16,11 @@ from .config import Config
|
|||
from .DownloadQueue import DownloadQueue
|
||||
from .encoder import Encoder
|
||||
from .Events import EventBus, Events
|
||||
from .ItemDTO import Item
|
||||
from .ItemDTO import Item, ItemDTO
|
||||
from .Scheduler import Scheduler
|
||||
from .Services import Services
|
||||
from .Singleton import Singleton
|
||||
from .Utils import archive_add, archive_delete, extract_info, init_class, validate_url
|
||||
from .Utils import archive_add, archive_delete, archive_read, extract_info, init_class, validate_url
|
||||
from .YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("tasks")
|
||||
|
|
@ -127,6 +128,95 @@ class Task:
|
|||
return {"file": archive_file, "items": items}
|
||||
|
||||
|
||||
def _split_inspect_metadata(metadata: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Split commonly consumed metadata keys from the rest.
|
||||
|
||||
Args:
|
||||
metadata (dict[str, Any]|None): The metadata to split.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, Any], dict[str, Any]]: The primary and extra metadata.
|
||||
|
||||
"""
|
||||
metadata = dict(metadata or {})
|
||||
primary: dict[str, Any] = {}
|
||||
|
||||
for key in ("matched", "handler", "supported"):
|
||||
if key in metadata:
|
||||
primary[key] = metadata.pop(key)
|
||||
|
||||
return primary, metadata
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskItem:
|
||||
url: str
|
||||
"The URL of the item."
|
||||
title: str | None = None
|
||||
"The title of the item."
|
||||
archive_id: str | None = None
|
||||
"The archive ID of the item."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the item."
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskResult:
|
||||
items: list[TaskItem] = field(default_factory=list)
|
||||
"The list of items."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the result."
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the task result.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The serialized task result.
|
||||
|
||||
"""
|
||||
primary, extra = _split_inspect_metadata(self.metadata)
|
||||
payload: dict[str, Any] = {**primary, "items": [asdict(item) for item in self.items]}
|
||||
|
||||
if extra:
|
||||
payload["metadata"] = extra
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskFailure:
|
||||
message: str
|
||||
"A human-readable message describing the failure."
|
||||
error: str | None = None
|
||||
"An optional error code or string."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the failure."
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the task failure.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The serialized task failure.
|
||||
|
||||
"""
|
||||
primary, extra = _split_inspect_metadata(self.metadata)
|
||||
payload: dict[str, Any] = dict(primary)
|
||||
|
||||
if self.error:
|
||||
payload["error"] = self.error
|
||||
|
||||
if self.message and (not self.error or self.message != self.error):
|
||||
payload["message"] = self.message
|
||||
|
||||
if extra:
|
||||
payload["metadata"] = extra
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
class Tasks(metaclass=Singleton):
|
||||
"""
|
||||
This class is used to manage the tasks.
|
||||
|
|
@ -199,6 +289,7 @@ class Tasks(metaclass=Singleton):
|
|||
|
||||
"""
|
||||
self.load()
|
||||
Services.get_instance().add("tasks", self)
|
||||
|
||||
async def event_handler(data, _):
|
||||
if data and data.data:
|
||||
|
|
@ -211,6 +302,10 @@ class Tasks(metaclass=Singleton):
|
|||
"""Return the tasks."""
|
||||
return self._tasks
|
||||
|
||||
def get_handler(self) -> "HandleTask":
|
||||
"""Expose the handle task helper."""
|
||||
return self._task_handler
|
||||
|
||||
def get(self, task_id: str) -> Task | None:
|
||||
"""
|
||||
Get a task by its ID.
|
||||
|
|
@ -471,8 +566,21 @@ class HandleTask:
|
|||
"The configuration."
|
||||
self._task_name: str = f"{__class__.__name__}._dispatcher"
|
||||
"The task name for the scheduler."
|
||||
self._queued: dict[str, set[str]] = {}
|
||||
"Queued archive IDs per handler."
|
||||
self._failure_count: dict[str, dict[str, int]] = {}
|
||||
"Failure counts per handler and archive ID."
|
||||
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.ITEM_ERROR,
|
||||
self._handle_item_error,
|
||||
f"{__class__.__name__}.item_error",
|
||||
)
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load the available handlers and schedule the dispatcher.
|
||||
"""
|
||||
self._handlers: list[type] = self._discover()
|
||||
|
||||
timer: str = self._config.tasks_handler_timer
|
||||
|
|
@ -551,7 +659,7 @@ class HandleTask:
|
|||
|
||||
return None
|
||||
|
||||
async def dispatch(self, task: Task, handler: type | None = None, **kwargs) -> Any | None:
|
||||
async def dispatch(self, task: Task, handler: type | None = None, **kwargs) -> TaskResult | TaskFailure | None: # noqa: ARG002
|
||||
"""
|
||||
Dispatch a task to the appropriate handler.
|
||||
|
||||
|
|
@ -561,7 +669,7 @@ class HandleTask:
|
|||
**kwargs: Additional context to pass to the handler.
|
||||
|
||||
Returns:
|
||||
Any|None: The result of the handler's execution, or None if no handler found.
|
||||
TaskResult|TaskFailure|None: The extraction outcome, or None if no handler matched.
|
||||
|
||||
"""
|
||||
if not handler:
|
||||
|
|
@ -569,12 +677,225 @@ class HandleTask:
|
|||
if handler is None:
|
||||
return None
|
||||
|
||||
services: Services = Services.get_instance()
|
||||
|
||||
try:
|
||||
return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler.extract, task=task, config=self._config
|
||||
)
|
||||
except NotImplementedError:
|
||||
LOG.error(f"Handler '{handler.__name__}' does not implement extract().")
|
||||
return TaskFailure(message="Handler does not support extraction.")
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
raise
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
LOG.error(f"Handler '{handler.__name__}' failed to extract items: {extraction.message}")
|
||||
return extraction
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
f"Handler '{handler.__name__}' returned unexpected result type '{type(extraction).__name__}'.",
|
||||
)
|
||||
return TaskFailure(
|
||||
message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__}
|
||||
)
|
||||
|
||||
raw_items: list[TaskItem] = extraction.items or []
|
||||
metadata: dict[str, Any] = extraction.metadata or {}
|
||||
|
||||
handler_name: str = handler.__name__
|
||||
queued: set[str] = self._queued.setdefault(handler_name, set())
|
||||
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
|
||||
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
archive_file: str | None = params.get("download_archive")
|
||||
|
||||
download_queue: DownloadQueue = services.get("queue") or DownloadQueue.get_instance()
|
||||
notify: EventBus = services.get("notify") or EventBus.get_instance()
|
||||
|
||||
archive_ids: list[str] = [
|
||||
item.archive_id for item in raw_items if isinstance(item, TaskItem) and item.archive_id
|
||||
]
|
||||
downloaded: list[str] = archive_read(archive_file, archive_ids) if archive_file else []
|
||||
|
||||
filtered: list[TaskItem] = []
|
||||
|
||||
for item in raw_items:
|
||||
if not isinstance(item, TaskItem):
|
||||
LOG.warning("Handler '{handler.__name__}' produced non-TaskItem entry: {item!r}")
|
||||
continue
|
||||
|
||||
url: str = item.url
|
||||
if not url:
|
||||
continue
|
||||
|
||||
archive_id: str | None = item.archive_id
|
||||
if not archive_id:
|
||||
LOG.warning(f"'{task.name}': Item with URL '{url}' is missing an archive ID. Skipping.")
|
||||
continue
|
||||
|
||||
if archive_id in queued:
|
||||
continue
|
||||
|
||||
queued.add(archive_id)
|
||||
|
||||
if archive_file and archive_id in downloaded:
|
||||
continue
|
||||
|
||||
if download_queue.queue.exists(url=url):
|
||||
continue
|
||||
|
||||
try:
|
||||
done = download_queue.done.get(url=url)
|
||||
if "error" != done.info.status:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if archive_id not in failures:
|
||||
failures[archive_id] = 0
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
if not filtered:
|
||||
if raw_items:
|
||||
LOG.debug(
|
||||
f"Handler '{handler.__name__}' produced '{len(raw_items)}' for '{task.name}' items, none queued after filtering."
|
||||
)
|
||||
return TaskResult(items=[], metadata=metadata)
|
||||
|
||||
LOG.info(
|
||||
f"Handler '{handler.__name__}' Found '{len(filtered)}' new items for '{task.name}' (raw={len(raw_items)})."
|
||||
)
|
||||
|
||||
base_item = Item.format(
|
||||
{
|
||||
"url": task.url,
|
||||
"preset": task.preset or self._config.default_preset,
|
||||
"folder": task.folder or "",
|
||||
"template": task.template or "",
|
||||
"cli": task.cli or "",
|
||||
"auto_start": task.auto_start,
|
||||
"extras": {"source_task": task.id, "source_handler": handler.__name__},
|
||||
}
|
||||
)
|
||||
|
||||
for item in filtered:
|
||||
metadata_entry: dict[str, Any] = item.metadata if isinstance(item.metadata, dict) else {}
|
||||
extras: dict[str, Any] = base_item.extras.copy()
|
||||
if metadata_entry:
|
||||
extras["metadata"] = metadata_entry
|
||||
|
||||
notify.emit(
|
||||
Events.ADD_URL,
|
||||
data=base_item.new_with(url=item.url, extras=extras).serialize(),
|
||||
)
|
||||
|
||||
return TaskResult(items=filtered, metadata=metadata)
|
||||
|
||||
async def inspect(
|
||||
self,
|
||||
url: str,
|
||||
preset: str | None = None,
|
||||
handler_name: str | None = None,
|
||||
) -> TaskResult | TaskFailure:
|
||||
if not self._handlers:
|
||||
self._handlers = self._discover()
|
||||
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Inspector",
|
||||
url=url,
|
||||
preset=preset or self._config.default_preset,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
services = Services.get_instance()
|
||||
|
||||
handler_cls: type | None
|
||||
if handler_name:
|
||||
handler_cls = next((cls for cls in self._handlers if cls.__name__.lower() == handler_name.lower()), None)
|
||||
if handler_cls is None:
|
||||
message: str = f"Handler '{handler_name}' not found."
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": handler_name},
|
||||
)
|
||||
|
||||
try:
|
||||
matched = services.handle_sync(handler=handler_cls.can_handle, task=task)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
LOG.exception(exc)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||
)
|
||||
|
||||
if not matched:
|
||||
return TaskFailure(
|
||||
message="Handler cannot process the supplied URL.",
|
||||
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||
)
|
||||
else:
|
||||
handler_cls = self._find_handler(task)
|
||||
if handler_cls is None:
|
||||
message = "No handler matched the supplied URL."
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": None},
|
||||
)
|
||||
|
||||
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
|
||||
|
||||
try:
|
||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler_cls.extract, task=task, config=self._config
|
||||
)
|
||||
except NotImplementedError:
|
||||
return TaskFailure(
|
||||
message="Handler does not support manual inspection.",
|
||||
metadata={**base_metadata, "supported": False},
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={**base_metadata, "supported": True},
|
||||
)
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
combined_failure_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||
if extraction.metadata:
|
||||
combined_failure_metadata.update(extraction.metadata)
|
||||
|
||||
failure_error = extraction.error if extraction.error else extraction.message
|
||||
|
||||
return TaskFailure(
|
||||
message=extraction.message,
|
||||
error=failure_error,
|
||||
metadata=combined_failure_metadata,
|
||||
)
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
f"Handler '{handler_cls.__name__}' returned unexpected result type '{type(extraction).__name__}' during inspection.",
|
||||
)
|
||||
extraction = TaskResult()
|
||||
|
||||
combined_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||
if extraction.metadata:
|
||||
combined_metadata.update(extraction.metadata)
|
||||
|
||||
return TaskResult(items=list(extraction.items), metadata=combined_metadata)
|
||||
|
||||
def _discover(self) -> list[type]:
|
||||
import importlib
|
||||
|
||||
|
|
@ -591,7 +912,28 @@ class HandleTask:
|
|||
if cls.__module__ != module.__name__:
|
||||
continue
|
||||
|
||||
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "handle", None)):
|
||||
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "extract", None)):
|
||||
handlers.append(cls)
|
||||
|
||||
return handlers
|
||||
|
||||
async def _handle_item_error(self, event, _name, **_kwargs):
|
||||
item = getattr(event, "data", None)
|
||||
if not isinstance(item, ItemDTO):
|
||||
return
|
||||
|
||||
extras = getattr(item, "extras", {}) or {}
|
||||
handler_name = extras.get("source_handler")
|
||||
if not handler_name:
|
||||
return
|
||||
|
||||
archive_id = item.archive_id
|
||||
if not archive_id:
|
||||
return
|
||||
|
||||
queued = self._queued.get(handler_name)
|
||||
if queued:
|
||||
queued.discard(archive_id)
|
||||
|
||||
failures = self._failure_count.setdefault(handler_name, {})
|
||||
failures[archive_id] = failures.get(archive_id, 0) + 1
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import os
|
|||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from functools import lru_cache, wraps
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
|
@ -87,8 +88,6 @@ DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}
|
|||
T = TypeVar("T")
|
||||
"Generic type variable."
|
||||
|
||||
ARCHIVE_IDS_CACHE: dict[str, dict] = {}
|
||||
"Cache for archive IDs."
|
||||
|
||||
class StreamingError(Exception):
|
||||
"""Raised when an error occurs during streaming."""
|
||||
|
|
@ -99,6 +98,112 @@ class FileLogFormatter(logging.Formatter):
|
|||
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
|
||||
"""
|
||||
Decorator that applies an LRU cache with a time-to-live (TTL) to a function.
|
||||
Supports both synchronous and asynchronous functions.
|
||||
|
||||
Args:
|
||||
ttl_seconds (int): Time-to-live in seconds.
|
||||
max_size (int): Maximum size of the cache.
|
||||
|
||||
Returns:
|
||||
Decorated function with caching capabilities.
|
||||
|
||||
"""
|
||||
import inspect
|
||||
|
||||
def decorator(func):
|
||||
is_async = inspect.iscoroutinefunction(func)
|
||||
|
||||
if is_async:
|
||||
# For async functions, we need to cache the actual result, not the coroutine
|
||||
cache = {}
|
||||
cache_expiry = {}
|
||||
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
key = str(args) + str(sorted(kwargs.items()))
|
||||
current_time = time.monotonic()
|
||||
|
||||
# Check if we have a cached result that hasn't expired
|
||||
if key in cache and key in cache_expiry and current_time < cache_expiry[key]:
|
||||
cached_result = cache[key]
|
||||
try:
|
||||
return copy.deepcopy(cached_result)
|
||||
except Exception:
|
||||
return cached_result
|
||||
|
||||
# Call the function and cache the result
|
||||
result = await func(*args, **kwargs)
|
||||
try:
|
||||
cached_value = copy.deepcopy(result)
|
||||
except Exception:
|
||||
cached_value = result
|
||||
|
||||
cache[key] = cached_value
|
||||
cache_expiry[key] = current_time + ttl_seconds
|
||||
|
||||
# Limit cache size
|
||||
if len(cache) > max_size:
|
||||
# Remove oldest entries
|
||||
oldest_keys = sorted(cache_expiry.keys(), key=lambda k: cache_expiry[k])[: len(cache) - max_size]
|
||||
for old_key in oldest_keys:
|
||||
cache.pop(old_key, None)
|
||||
cache_expiry.pop(old_key, None)
|
||||
|
||||
try:
|
||||
return copy.deepcopy(cached_value)
|
||||
except Exception:
|
||||
return cached_value
|
||||
|
||||
# Expose cache management methods
|
||||
def cache_clear():
|
||||
cache.clear()
|
||||
cache_expiry.clear()
|
||||
|
||||
def cache_info():
|
||||
# Use functools._CacheInfo for compatibility with standard lru_cache
|
||||
from functools import _CacheInfo
|
||||
|
||||
return _CacheInfo(hits=0, misses=len(cache), maxsize=max_size, currsize=len(cache))
|
||||
|
||||
async_wrapper.cache_clear = cache_clear
|
||||
async_wrapper.cache_info = cache_info
|
||||
return async_wrapper
|
||||
|
||||
# For sync functions, use the original implementation
|
||||
|
||||
@lru_cache(maxsize=max_size)
|
||||
def cached(*args, **kwargs):
|
||||
result = func(*args, **kwargs)
|
||||
try:
|
||||
return copy.deepcopy(result)
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
cached_expiry = time.monotonic() + ttl_seconds
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
nonlocal cached_expiry
|
||||
if time.monotonic() >= cached_expiry:
|
||||
cached.cache_clear()
|
||||
cached_expiry = time.monotonic() + ttl_seconds
|
||||
result = cached(*args, **kwargs)
|
||||
try:
|
||||
return copy.deepcopy(result)
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
# expose cache_clear, cache_info
|
||||
sync_wrapper.cache_clear = cached.cache_clear
|
||||
sync_wrapper.cache_info = cached.cache_info
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def calc_download_path(base_path: str | Path, folder: str | None = None, create_path: bool = True) -> str:
|
||||
"""
|
||||
Calculates download path and prevents folder traversal.
|
||||
|
|
@ -387,7 +492,7 @@ def check_id(file: Path) -> bool | str:
|
|||
return False
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
@timed_lru_cache(ttl_seconds=300, max_size=256)
|
||||
def is_private_address(hostname: str) -> bool:
|
||||
ip: str = socket.gethostbyname(hostname)
|
||||
ip_obj: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(ip)
|
||||
|
|
@ -545,19 +650,23 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def get_file_sidecar(file: Path) -> list[dict]:
|
||||
@timed_lru_cache(ttl_seconds=60, max_size=256)
|
||||
def get_file_sidecar(file: Path | None = None) -> dict[dict]:
|
||||
"""
|
||||
Get sidecar files for the given file.
|
||||
|
||||
Args:
|
||||
file (Path): The video file.
|
||||
file (Path|None): The video file.
|
||||
|
||||
Returns:
|
||||
list: List of sidecar files.
|
||||
dict: A dictionary with sidecar files categorized by type.
|
||||
|
||||
"""
|
||||
files: dict = {}
|
||||
|
||||
if not file:
|
||||
return files
|
||||
|
||||
for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")):
|
||||
if f == file or f.is_file() is False or f.stem.startswith("."):
|
||||
continue
|
||||
|
|
@ -595,7 +704,6 @@ def get_file_sidecar(file: Path) -> list[dict]:
|
|||
return files
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def get_possible_images(dir: str) -> list[dict]:
|
||||
images: list = []
|
||||
|
||||
|
|
@ -1093,6 +1201,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
|
|||
raise ValueError(msg) from e
|
||||
|
||||
|
||||
@timed_lru_cache(ttl_seconds=300, max_size=256)
|
||||
def get_archive_id(url: str) -> dict[str, str | None]:
|
||||
"""
|
||||
Get the archive ID for a given URL.
|
||||
|
|
@ -1116,9 +1225,6 @@ def get_archive_id(url: str) -> dict[str, str | None]:
|
|||
"archive_id": None,
|
||||
}
|
||||
|
||||
if url in ARCHIVE_IDS_CACHE:
|
||||
return ARCHIVE_IDS_CACHE[url]
|
||||
|
||||
if YTDLP_INFO_CLS is None:
|
||||
YTDLP_INFO_CLS = YTDLP(
|
||||
params={
|
||||
|
|
@ -1151,7 +1257,6 @@ def get_archive_id(url: str) -> dict[str, str | None]:
|
|||
LOG.exception(e)
|
||||
LOG.error(f"Error getting archive ID: {e}")
|
||||
|
||||
ARCHIVE_IDS_CACHE.update({url: idDict})
|
||||
return idDict
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
|
|||
if TYPE_CHECKING:
|
||||
from subprocess import CompletedProcess
|
||||
|
||||
SUPPORTED_CODECS: tuple[str] = ("h264_qsv", "h264_nvenc", "h264_amf", "h264_videotoolbox", "h264_vaapi", "libx264")
|
||||
"Supported encoder names in order of preference."
|
||||
|
||||
|
||||
class Config(metaclass=Singleton):
|
||||
app_env: str = "production"
|
||||
|
|
@ -417,6 +420,11 @@ class Config(metaclass=Singleton):
|
|||
msg: str = f"Invalid app environment '{self.app_env}' specified. Must be 'production' or 'development'."
|
||||
raise ValueError(msg)
|
||||
|
||||
if self.streamer_vcodec and self.streamer_vcodec not in SUPPORTED_CODECS:
|
||||
supported = ", ".join(SUPPORTED_CODECS)
|
||||
msg: str = f"Invalid video codec '{self.streamer_vcodec}' specified. Supported: '{supported}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "dev-master" == self.app_version:
|
||||
self._version_via_git()
|
||||
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ import operator
|
|||
import os
|
||||
import subprocess # qa: ignore
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anyio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.library.cache import Cache
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -238,7 +236,8 @@ class FFProbeResult:
|
|||
}
|
||||
|
||||
|
||||
async def ffprobe(file: str) -> FFProbeResult:
|
||||
@timed_lru_cache(ttl_seconds=300, max_size=128)
|
||||
async def ffprobe(file: Path|str) -> FFProbeResult:
|
||||
"""
|
||||
Run ffprobe on a file and return the parsed data as a dictionary.
|
||||
|
||||
|
|
@ -249,21 +248,12 @@ async def ffprobe(file: str) -> FFProbeResult:
|
|||
dict: A dictionary containing the parsed data.
|
||||
|
||||
"""
|
||||
from app.library.Services import Services
|
||||
|
||||
f = Path(file)
|
||||
f = Path(file) if isinstance(file, str) else file
|
||||
|
||||
if not f.exists():
|
||||
msg = f"No such media file '{file}'."
|
||||
raise OSError(msg)
|
||||
|
||||
cache: Cache | None = Services.get_instance().get("cache")
|
||||
cache_key: str = f"ffprobe:{f!s}:{f.stat().st_size}"
|
||||
|
||||
if cache and (cached := cache.get(cache_key)):
|
||||
LOG.debug(f"ffprobe cache hit for '{cache_key}'")
|
||||
return cached
|
||||
|
||||
try:
|
||||
async with await anyio.open_file(os.devnull, "w") as tempf:
|
||||
await asyncio.create_subprocess_exec(
|
||||
|
|
@ -310,7 +300,4 @@ async def ffprobe(file: str) -> FFProbeResult:
|
|||
elif stream.is_attachment():
|
||||
result.attachment.append(stream)
|
||||
|
||||
if cache:
|
||||
cache.set(cache_key, result, ttl=300)
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,73 +1,30 @@
|
|||
# flake8: noqa: ARG004
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
from app.library.Tasks import Task
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
||||
|
||||
|
||||
class BaseHandler:
|
||||
queued: set[str] = set()
|
||||
failure_count: dict[str, int] = {}
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
"""Ensure each subclass has its own state containers."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "queued" not in cls.__dict__:
|
||||
cls.queued = set()
|
||||
if "failure_count" not in cls.__dict__:
|
||||
cls.failure_count = {}
|
||||
|
||||
async def event_handler(data, _):
|
||||
if data and data.data:
|
||||
await cls.on_error(data.data)
|
||||
|
||||
EventBus.get_instance().subscribe(Events.ITEM_ERROR, event_handler, f"{cls.__name__}.on_error")
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def handle(task: Task, notify: EventBus, config: Config, queue: DownloadQueue):
|
||||
pass
|
||||
async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
async def inspect(cls, task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
return await cls.extract(task=task, config=config)
|
||||
|
||||
@staticmethod
|
||||
def parse(url: str) -> Any | None:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def on_error(cls, item: ItemDTO) -> None:
|
||||
"""
|
||||
Handle errors by logging them and removing the queued ID if it exists.
|
||||
|
||||
Args:
|
||||
item (ItemDTO): The error data containing the URL and other information.
|
||||
|
||||
"""
|
||||
if not item or not isinstance(item, ItemDTO):
|
||||
return
|
||||
|
||||
if not item.archive_id or not cls.failure_count.get(item.archive_id, None):
|
||||
LOG.debug(f"Item '{item.name()}' not queued by the handler.")
|
||||
return
|
||||
|
||||
failCount: int = int(cls.failure_count.get(item.archive_id, 0))
|
||||
|
||||
LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{failCount + 1}'.")
|
||||
if item.archive_id in cls.queued:
|
||||
cls.queued.remove(item.archive_id)
|
||||
|
||||
cls.failure_count[item.archive_id] = 1 + failCount
|
||||
|
||||
@staticmethod
|
||||
def tests() -> list[tuple[str, bool]]:
|
||||
return []
|
||||
|
|
|
|||
1280
app/library/task_handlers/generic.py
Normal file
1280
app/library/task_handlers/generic.py
Normal file
File diff suppressed because it is too large
Load diff
456
app/library/task_handlers/task_definition.schema.json
Normal file
456
app/library/task_handlers/task_definition.schema.json
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://ytptube.app/schemas/task-definition.json",
|
||||
"title": "YTPTube Generic Task Definition",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"match",
|
||||
"parse"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Human-readable identifier for this definition."
|
||||
},
|
||||
"match": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Glob pattern matched against task URLs."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"regex": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Regular expression applied to task URLs."
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Glob pattern applied to task URLs."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"regex"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"glob"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Patterns that determine which tasks use this definition."
|
||||
},
|
||||
"engine": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"httpx",
|
||||
"selenium"
|
||||
],
|
||||
"default": "httpx",
|
||||
"description": "Fetch engine to use (HTTPX or Selenium)."
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"description": "Engine-specific configuration.",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Remote Selenium hub URL."
|
||||
},
|
||||
"browser": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"chrome"
|
||||
],
|
||||
"description": "Selenium browser name (currently only chrome)."
|
||||
},
|
||||
"arguments": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Browser launch arguments for Selenium."
|
||||
},
|
||||
"wait_for": {
|
||||
"$ref": "#/definitions/waitFor"
|
||||
},
|
||||
"wait_timeout": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Seconds to wait for the wait_for selector."
|
||||
},
|
||||
"page_load_timeout": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Seconds to allow page load to complete."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Optional engine configuration (defaults to HTTPX)."
|
||||
},
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"GET",
|
||||
"POST"
|
||||
],
|
||||
"default": "GET",
|
||||
"description": "HTTP method to use when fetching the page."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "Override URL to fetch instead of the task URL."
|
||||
},
|
||||
"headers": {
|
||||
"$ref": "#/definitions/stringMap",
|
||||
"description": "Additional request headers."
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/stringMap",
|
||||
"description": "Query string parameters."
|
||||
},
|
||||
"data": {
|
||||
"description": "Form payload for POST requests.",
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/definitions/stringMap"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"json": {
|
||||
"description": "JSON payload for POST requests.",
|
||||
"type": [
|
||||
"object",
|
||||
"array",
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"description": "Timeout in seconds for the request."
|
||||
}
|
||||
},
|
||||
"description": "Optional HTTP request overrides."
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"html",
|
||||
"json"
|
||||
],
|
||||
"default": "html",
|
||||
"description": "Body format returned by the target URL."
|
||||
}
|
||||
}
|
||||
},
|
||||
"parse": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"description": "Field extraction rules and optional container definition.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/container"
|
||||
}
|
||||
},
|
||||
"patternProperties": {
|
||||
"^(?!items$).+$": {
|
||||
"$ref": "#/definitions/extractionRule"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"not": {
|
||||
"required": [
|
||||
"items"
|
||||
]
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"link"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"stringMap": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"waitFor": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"css",
|
||||
"xpath"
|
||||
],
|
||||
"description": "Selector type to wait for."
|
||||
},
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Selector expression."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Alias for expression for backwards compatibility."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"expression"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"value"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"postFilter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"filter"
|
||||
],
|
||||
"properties": {
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Regular expression applied after extraction."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Named or numbered capture group to return."
|
||||
}
|
||||
}
|
||||
},
|
||||
"containerFields": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^.+$": {
|
||||
"$ref": "#/definitions/extractionRule"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"link"
|
||||
]
|
||||
},
|
||||
"container": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"css",
|
||||
"xpath",
|
||||
"jsonpath"
|
||||
],
|
||||
"default": "css",
|
||||
"description": "Selector type to use for locating repeatable elements."
|
||||
},
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "CSS/XPath selector identifying each item container."
|
||||
},
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Alias for selector."
|
||||
},
|
||||
"fields": {
|
||||
"$ref": "#/definitions/containerFields"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fields"
|
||||
],
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"selector"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"expression"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"extractionRule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"type",
|
||||
"expression"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"css",
|
||||
"xpath",
|
||||
"regex",
|
||||
"jsonpath"
|
||||
],
|
||||
"description": "Extraction strategy."
|
||||
},
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Selector or pattern used to extract values."
|
||||
},
|
||||
"attribute": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Optional attribute or group to return."
|
||||
},
|
||||
"post_filter": {
|
||||
"$ref": "#/definitions/postFilter"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"name": "example",
|
||||
"match": [
|
||||
"https://example.com/articles/*",
|
||||
{
|
||||
"regex": "https://example.com/post/[0-9]+"
|
||||
}
|
||||
],
|
||||
"engine": {
|
||||
"type": "httpx"
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"headers": {
|
||||
"User-Agent": "MyCustomAgent/1.0"
|
||||
}
|
||||
},
|
||||
"parse": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": ".article a.link",
|
||||
"attribute": "href"
|
||||
},
|
||||
"title": {
|
||||
"type": "css",
|
||||
"expression": ".article .title",
|
||||
"attribute": "text"
|
||||
},
|
||||
"id": {
|
||||
"type": "regex",
|
||||
"expression": "id=(?P<id>[0-9]+)",
|
||||
"post_filter": {
|
||||
"filter": "(?P<id>[0-9]+)",
|
||||
"value": "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "container-example",
|
||||
"match": [
|
||||
"https://example.com/list"
|
||||
],
|
||||
"parse": {
|
||||
"items": {
|
||||
"type": "css",
|
||||
"selector": ".cards .card",
|
||||
"fields": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a",
|
||||
"attribute": "href"
|
||||
},
|
||||
"title": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a",
|
||||
"attribute": "text"
|
||||
},
|
||||
"poet": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:first-child a",
|
||||
"attribute": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -3,19 +3,14 @@ import re
|
|||
from typing import TYPE_CHECKING
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.Tasks import Task
|
||||
from app.library.Utils import archive_read, get_archive_id
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.Download import Download
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -30,40 +25,23 @@ class TwitchHandler(BaseHandler):
|
|||
return TwitchHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def handle(task: Task, notify: EventBus, queue: DownloadQueue):
|
||||
"""
|
||||
Fetch the RSS feed for a Twitch channel VODs, parse entries,
|
||||
and enqueue new items that are not in the archive/queue already.
|
||||
|
||||
Args:
|
||||
task (Task): The task containing the Twitch channel URL.
|
||||
notify (EventBus): The event bus for notifications.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
|
||||
"""
|
||||
async def _collect_feed(
|
||||
task: Task,
|
||||
params: dict,
|
||||
handle_name: str,
|
||||
) -> tuple[str, list[dict[str, str]], bool]:
|
||||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
handleName: str | None = TwitchHandler.parse(task.url)
|
||||
if not handleName:
|
||||
LOG.error(f"Cannot parse '{task.name}' URL: {task.url}")
|
||||
return
|
||||
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
archive_file: str | None = params.get("download_archive")
|
||||
if not archive_file:
|
||||
LOG.error(f"Task '{task.name}' does not have an archive file.")
|
||||
return
|
||||
|
||||
feed_url: str = TwitchHandler.FEED.format(handle=handleName)
|
||||
feed_url: str = TwitchHandler.FEED.format(handle=handle_name)
|
||||
|
||||
LOG.debug(f"Fetching '{task.name}' feed.")
|
||||
response = await TwitchHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
||||
items: list = []
|
||||
root: Element[str] = fromstring(response.text)
|
||||
items: list[dict[str, str]] = []
|
||||
has_items = False
|
||||
|
||||
root: Element[str] = fromstring(response.text)
|
||||
for entry in root.findall("channel/item"):
|
||||
link_elem: Element[str] | None = entry.find("link")
|
||||
url: str = link_elem.text.strip() if link_elem is not None and link_elem.text else ""
|
||||
|
|
@ -71,12 +49,14 @@ class TwitchHandler(BaseHandler):
|
|||
LOG.warning(f"Entry in '{task.name}' feed is missing URL. Skipping entry.")
|
||||
continue
|
||||
|
||||
m: re.Match[str] | None = re.search(r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url)
|
||||
if not m:
|
||||
match: re.Match[str] | None = re.search(
|
||||
r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url
|
||||
)
|
||||
if not match:
|
||||
LOG.warning(f"URL in '{task.name}' feed does not look like a VOD link: {url}")
|
||||
continue
|
||||
|
||||
vid: str = m.group("id")
|
||||
vid: str = match.group("id")
|
||||
|
||||
title_elem: Element[str] | None = entry.find("title")
|
||||
title: str = title_elem.text.strip() if title_elem is not None and title_elem.text else ""
|
||||
|
|
@ -89,68 +69,34 @@ class TwitchHandler(BaseHandler):
|
|||
LOG.warning(f"Could not compute archive ID for video '{vid}' in '{task.name}' feed. Skipping entry.")
|
||||
continue
|
||||
|
||||
if archive_id in TwitchHandler.queued:
|
||||
continue
|
||||
|
||||
items.append({"id": vid, "url": url, "title": title, "archive_id": archive_id})
|
||||
|
||||
if len(items) < 1:
|
||||
if not has_items:
|
||||
LOG.warning(f"No entries found in '{task.name}' feed. URL: {feed_url}")
|
||||
else:
|
||||
LOG.debug(f"No new items found in '{task.name}' feed.")
|
||||
return
|
||||
return feed_url, items, has_items
|
||||
|
||||
filtered: list = []
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
handle_name: str | None = TwitchHandler.parse(task.url)
|
||||
if not handle_name:
|
||||
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
||||
|
||||
downloaded: list[str] = archive_read(archive_file, [item["archive_id"] for item in items])
|
||||
|
||||
for item in items:
|
||||
TwitchHandler.queued.add(item["archive_id"])
|
||||
|
||||
if item["archive_id"] in downloaded:
|
||||
continue
|
||||
|
||||
if queue.queue.exists(url=item["url"]):
|
||||
continue
|
||||
|
||||
try:
|
||||
done: Download = queue.done.get(url=item["url"])
|
||||
if "error" != done.info.status:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if item["archive_id"] not in TwitchHandler.failure_count:
|
||||
TwitchHandler.failure_count[item["archive_id"]] = 0
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
if len(filtered) < 1:
|
||||
LOG.debug(f"No new items found in '{task.name}' feed.")
|
||||
return
|
||||
|
||||
LOG.info(f"Found '{len(filtered)}' new items from '{task.name}' feed.")
|
||||
|
||||
rItem: Item = Item.format(
|
||||
{
|
||||
"url": feed_url,
|
||||
"preset": task.preset,
|
||||
"folder": task.folder if task.folder else "",
|
||||
"template": task.template if task.template else "",
|
||||
"cli": task.cli if task.cli else "",
|
||||
"auto_start": task.auto_start,
|
||||
"extras": {"source_task": task.id},
|
||||
}
|
||||
)
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
|
||||
try:
|
||||
for item in filtered:
|
||||
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")
|
||||
return
|
||||
feed_url, items, has_items = await TwitchHandler._collect_feed(task, params, handle_name)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
||||
for entry in items:
|
||||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id))
|
||||
|
||||
return TaskResult( items=task_items, metadata={ "feed_url": feed_url, "has_entries": has_items } )
|
||||
|
||||
@staticmethod
|
||||
def parse(url: str) -> str | None:
|
||||
|
|
|
|||
|
|
@ -1,21 +1,16 @@
|
|||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.Tasks import Task
|
||||
from app.library.Utils import archive_read, get_archive_id
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.Download import Download
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -32,39 +27,28 @@ class YoutubeHandler(BaseHandler):
|
|||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
||||
LOG.debug(f"'{task.name}': Task does not have an archive file configured.")
|
||||
return False
|
||||
|
||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
||||
return YoutubeHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def handle(task: Task, notify: EventBus, queue: DownloadQueue):
|
||||
async def _get(task: Task, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
|
||||
"""
|
||||
Fetch the Atom feed for a YouTube channel or playlist, parse entries,
|
||||
and return a list of videos with metadata.
|
||||
Fetch the feed and return raw entries.
|
||||
|
||||
Args:
|
||||
task (Task): The task containing the YouTube URL.
|
||||
notify (EventBus): The event bus for notifications.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
params (dict): The ytdlp options.
|
||||
parsed (dict): The parsed URL components.
|
||||
|
||||
Returns:
|
||||
tuple[str, list[dict[str, str]], int]: The feed URL, list of
|
||||
|
||||
"""
|
||||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||
if not parsed:
|
||||
LOG.error(f"'{task.name}': Cannot parse task URL: {task.url}")
|
||||
return
|
||||
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
|
||||
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
|
||||
LOG.debug(f"'{task.name}': Fetching feed.")
|
||||
|
||||
items: list = []
|
||||
|
||||
response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
||||
|
|
@ -74,10 +58,12 @@ class YoutubeHandler(BaseHandler):
|
|||
"yt": "http://www.youtube.com/xml/schemas/2015",
|
||||
}
|
||||
|
||||
real_count: int = 0
|
||||
items: list[dict[str, str]] = []
|
||||
real_count = 0
|
||||
|
||||
for entry in root.findall("atom:entry", ns):
|
||||
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
|
||||
vid: str | None = vid_elem.text if vid_elem is not None else ""
|
||||
vid: str = vid_elem.text if vid_elem is not None and vid_elem.text else ""
|
||||
if not vid:
|
||||
LOG.warning(f"'{task.name}': Entry in the feed is missing a video ID. Skipping.")
|
||||
continue
|
||||
|
|
@ -91,73 +77,43 @@ class YoutubeHandler(BaseHandler):
|
|||
continue
|
||||
|
||||
title_elem: Element[str] | None = entry.find("atom:title", ns)
|
||||
title: str | None = title_elem.text if title_elem is not None else ""
|
||||
title: str = title_elem.text if title_elem is not None and title_elem.text else ""
|
||||
|
||||
pub_elem: Element[str] | None = entry.find("atom:published", ns)
|
||||
published: str | None = pub_elem.text if pub_elem is not None else ""
|
||||
real_count += 1
|
||||
published: str = pub_elem.text if pub_elem is not None and pub_elem.text else ""
|
||||
|
||||
if archive_id in YoutubeHandler.queued:
|
||||
continue
|
||||
real_count += 1
|
||||
|
||||
items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id})
|
||||
|
||||
if len(items) < 1:
|
||||
if real_count < 1:
|
||||
LOG.warning(f"'{task.name}': No entries found the RSS feed. URL: {feed_url}")
|
||||
else:
|
||||
LOG.debug(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
|
||||
return
|
||||
return feed_url, items, real_count
|
||||
|
||||
filtered: list = []
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||
if not parsed:
|
||||
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
|
||||
|
||||
downloaded: list[str] = archive_read(params.get("download_archive"), [item["archive_id"] for item in items])
|
||||
|
||||
for item in items:
|
||||
YoutubeHandler.queued.add(item["archive_id"])
|
||||
if item["archive_id"] in downloaded:
|
||||
continue
|
||||
|
||||
if queue.queue.exists(url=item["url"]):
|
||||
continue
|
||||
|
||||
try:
|
||||
done: Download = queue.done.get(url=item["url"])
|
||||
if "error" != done.info.status:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if item["archive_id"] not in YoutubeHandler.failure_count:
|
||||
YoutubeHandler.failure_count[item["archive_id"]] = 0
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
if len(filtered) < 1:
|
||||
LOG.debug(f"'{task.name}': Feed has '{real_count}' entries, all already downloaded/queued.")
|
||||
return
|
||||
|
||||
LOG.info(f"'{task.name}': Found '{len(filtered)}/{real_count}' new items from feed.")
|
||||
|
||||
rItem: Item = Item.format(
|
||||
{
|
||||
"url": feed_url,
|
||||
"preset": task.preset,
|
||||
"folder": task.folder if task.folder else "",
|
||||
"template": task.template if task.template else "",
|
||||
"cli": task.cli if task.cli else "",
|
||||
"auto_start": task.auto_start,
|
||||
"extras": {"source_task": task.id},
|
||||
}
|
||||
)
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
|
||||
try:
|
||||
for item in filtered:
|
||||
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"'{task.name}': Error while adding items from task feed. {e!s}")
|
||||
return
|
||||
feed_url, items, real_count = await YoutubeHandler._get(task, params, parsed)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
||||
for entry in items:
|
||||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
metadata: dict[str, Any] = {"published": entry.get("published")}
|
||||
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata))
|
||||
|
||||
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "entry_count": real_count})
|
||||
|
||||
@staticmethod
|
||||
def parse(url: str) -> dict[str, str] | None:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Download import Download
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.encoder import Encoder
|
||||
|
|
@ -73,7 +71,7 @@ async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
|
|||
|
||||
|
||||
@route("GET", "api/history/{id}", "item_view")
|
||||
async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response:
|
||||
async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Update an item in the history.
|
||||
|
||||
|
|
@ -82,7 +80,6 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, co
|
|||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
config (Config): The configuration instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
|
@ -104,23 +101,11 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, co
|
|||
"ffprobe": {},
|
||||
}
|
||||
|
||||
if item.info.filename:
|
||||
if "finished" == item.info.status and (filename := item.info.get_file()):
|
||||
from app.library.ffprobe import ffprobe
|
||||
|
||||
try:
|
||||
from app.library.ffprobe import ffprobe
|
||||
from app.library.Utils import get_file
|
||||
|
||||
filename = Path(config.download_path)
|
||||
if item.info.folder:
|
||||
filename: Path = filename / item.info.folder
|
||||
|
||||
filename: Path = filename / item.info.filename
|
||||
|
||||
if filename.exists():
|
||||
realFile, status = get_file(
|
||||
download_path=config.download_path, file=str(filename.relative_to(config.download_path))
|
||||
)
|
||||
if status in (web.HTTPOk.status_code, web.HTTPFound.status_code):
|
||||
info["ffprobe"] = await ffprobe(str(realFile))
|
||||
info["ffprobe"] = await ffprobe(filename)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,45 @@ from aiohttp.web import Request, Response
|
|||
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.router import route
|
||||
from app.library.Tasks import Task, Tasks
|
||||
from app.library.Utils import init_class, validate_uuid
|
||||
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
|
||||
from app.library.Utils import init_class, validate_url, validate_uuid
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("POST", "api/tasks/inspect", "task_handler_inspect")
|
||||
async def task_handler_inspect(request: Request, tasks: Tasks, encoder: Encoder) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
url: str | None = data.get("url") if isinstance(data, dict) else None
|
||||
if not url:
|
||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||
try:
|
||||
validate_url(url)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
|
||||
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
|
||||
|
||||
try:
|
||||
result: TaskResult | TaskFailure = await tasks.get_handler().inspect(
|
||||
url=url, preset=preset, handler_name=handler_name
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to inspect handler.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data=result,
|
||||
status=web.HTTPBadRequest.status_code if isinstance(result, TaskFailure) else web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", "api/tasks/", "tasks")
|
||||
async def tasks(encoder: Encoder) -> Response:
|
||||
"""
|
||||
|
|
|
|||
207
app/tests/test_ffprobe.py
Normal file
207
app/tests/test_ffprobe.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestFFProbe:
|
||||
"""Test the ffprobe module functionality."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test files."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_file = Path(self.temp_dir) / "test_video.mp4"
|
||||
self.test_file.touch()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up test files."""
|
||||
import shutil
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_with_existing_file(self):
|
||||
"""Test ffprobe with an existing file."""
|
||||
from app.library.ffprobe import FFProbeResult, ffprobe
|
||||
|
||||
# Mock subprocess to avoid actual ffprobe execution
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
# Mock the subprocess result
|
||||
mock_process = AsyncMock()
|
||||
mock_process.wait.return_value = 0
|
||||
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}}', b"")
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch("anyio.open_file") as mock_open_file:
|
||||
mock_file = AsyncMock()
|
||||
mock_open_file.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
result = await ffprobe(str(self.test_file))
|
||||
assert isinstance(result, FFProbeResult)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_with_nonexistent_file(self):
|
||||
"""Test ffprobe with a non-existent file."""
|
||||
from app.library.ffprobe import ffprobe
|
||||
|
||||
nonexistent_file = Path(self.temp_dir) / "does_not_exist.mp4"
|
||||
|
||||
with pytest.raises(OSError, match="No such media file"):
|
||||
await ffprobe(str(nonexistent_file))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_caching_behavior(self):
|
||||
"""Test that ffprobe results are cached with enhanced async timed_lru_cache."""
|
||||
from app.library.ffprobe import ffprobe
|
||||
|
||||
# Test that the function has been decorated with caching
|
||||
assert hasattr(ffprobe, "cache_clear"), "ffprobe should have cache_clear method from timed_lru_cache"
|
||||
assert hasattr(ffprobe, "cache_info"), "ffprobe should have cache_info method from timed_lru_cache"
|
||||
|
||||
# Clear cache to start fresh
|
||||
ffprobe.cache_clear()
|
||||
|
||||
# Mock subprocess to avoid actual ffprobe execution
|
||||
call_count = 0
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
def create_mock_process(*_args, **_kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
mock_process = AsyncMock()
|
||||
mock_process.wait.return_value = 0
|
||||
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}, "streams": []}', b"")
|
||||
return mock_process
|
||||
|
||||
mock_subprocess.side_effect = create_mock_process
|
||||
|
||||
with patch("anyio.open_file") as mock_open_file:
|
||||
mock_file = AsyncMock()
|
||||
mock_open_file.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
# First call should execute the function
|
||||
result1 = await ffprobe(str(self.test_file))
|
||||
assert result1 is not None
|
||||
assert isinstance(result1.metadata, dict)
|
||||
first_call_count = call_count
|
||||
|
||||
# Second call with same argument should use cached result
|
||||
result2 = await ffprobe(str(self.test_file))
|
||||
assert result2 is not None
|
||||
assert isinstance(result2.metadata, dict)
|
||||
|
||||
# The subprocess should not be called again for the actual ffprobe execution
|
||||
# (it may be called for the -h check, but the main execution should be cached)
|
||||
assert call_count == first_call_count, "Second call should use cached result"
|
||||
|
||||
# Results should be equivalent (same data, may not be same object due to async nature)
|
||||
assert result1.metadata == result2.metadata
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ffprobe_with_path_object(self):
|
||||
"""Test ffprobe with Path object input."""
|
||||
from app.library.ffprobe import ffprobe
|
||||
|
||||
# Mock subprocess to avoid actual ffprobe execution
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
mock_process = AsyncMock()
|
||||
mock_process.wait.return_value = 0
|
||||
mock_process.communicate.return_value = (b'{"format": {"duration": "10.0"}}', b"")
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
with patch("anyio.open_file") as mock_open_file:
|
||||
mock_file = AsyncMock()
|
||||
mock_open_file.return_value.__aenter__.return_value = mock_file
|
||||
|
||||
# Test with Path object
|
||||
result = await ffprobe(self.test_file)
|
||||
assert result is not None
|
||||
|
||||
def test_ffprobe_result_properties(self):
|
||||
"""Test FFProbeResult object properties."""
|
||||
from app.library.ffprobe import FFProbeResult, FFStream
|
||||
|
||||
result = FFProbeResult()
|
||||
|
||||
# Test empty result
|
||||
assert result.video == []
|
||||
assert result.audio == []
|
||||
assert result.subtitle == []
|
||||
assert result.attachment == []
|
||||
assert result.metadata == {}
|
||||
|
||||
# Test adding streams
|
||||
video_stream = FFStream({
|
||||
"index": 0,
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
})
|
||||
|
||||
audio_stream = FFStream({
|
||||
"index": 1,
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"channels": 2
|
||||
})
|
||||
|
||||
result.video.append(video_stream)
|
||||
result.audio.append(audio_stream)
|
||||
|
||||
assert len(result.video) == 1
|
||||
assert len(result.audio) == 1
|
||||
assert result.video[0].is_video()
|
||||
assert result.audio[0].is_audio()
|
||||
|
||||
def test_stream_object_methods(self):
|
||||
"""Test Stream object methods."""
|
||||
from app.library.ffprobe import FFStream
|
||||
|
||||
# Test video stream
|
||||
video_stream = FFStream({
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"duration": "10.5"
|
||||
})
|
||||
|
||||
assert video_stream.is_video()
|
||||
assert not video_stream.is_audio()
|
||||
assert not video_stream.is_subtitle()
|
||||
assert not video_stream.is_attachment()
|
||||
|
||||
# Test audio stream
|
||||
audio_stream = FFStream({
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"channels": 2,
|
||||
"sample_rate": "44100"
|
||||
})
|
||||
|
||||
assert not audio_stream.is_video()
|
||||
assert audio_stream.is_audio()
|
||||
assert not audio_stream.is_subtitle()
|
||||
assert not audio_stream.is_attachment()
|
||||
|
||||
# Test subtitle stream
|
||||
subtitle_stream = FFStream({
|
||||
"codec_type": "subtitle",
|
||||
"codec_name": "subrip"
|
||||
})
|
||||
|
||||
assert not subtitle_stream.is_video()
|
||||
assert not subtitle_stream.is_audio()
|
||||
assert subtitle_stream.is_subtitle()
|
||||
assert not subtitle_stream.is_attachment()
|
||||
|
||||
# Test attachment stream
|
||||
attachment_stream = FFStream({
|
||||
"codec_type": "attachment",
|
||||
"codec_name": "ttf"
|
||||
})
|
||||
|
||||
assert not attachment_stream.is_video()
|
||||
assert not attachment_stream.is_audio()
|
||||
assert not attachment_stream.is_subtitle()
|
||||
assert attachment_stream.is_attachment()
|
||||
356
app/tests/test_generic_task_handler.py
Normal file
356
app/tests/test_generic_task_handler.py
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.library.task_handlers.generic import (
|
||||
ContainerDefinition,
|
||||
EngineConfig,
|
||||
ExtractionRule,
|
||||
GenericTaskHandler,
|
||||
RequestConfig,
|
||||
ResponseConfig,
|
||||
TaskDefinition,
|
||||
load_task_definitions,
|
||||
)
|
||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_generic_handler(monkeypatch):
|
||||
monkeypatch.setattr(GenericTaskHandler, "_definitions", [])
|
||||
monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {})
|
||||
|
||||
|
||||
def test_load_task_definitions_parses_valid_file(tmp_path: Path):
|
||||
tasks_dir = tmp_path / "tasks"
|
||||
tasks_dir.mkdir()
|
||||
|
||||
definition_content = {
|
||||
"name": "example",
|
||||
"match": ["https://example.com/articles/*"],
|
||||
"parse": {
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)"},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
},
|
||||
}
|
||||
|
||||
(tasks_dir / "01-example.json").write_text(json.dumps(definition_content), encoding="utf-8")
|
||||
|
||||
config = SimpleNamespace(config_path=str(tmp_path))
|
||||
definitions = load_task_definitions(config=config)
|
||||
|
||||
assert len(definitions) == 1
|
||||
definition = definitions[0]
|
||||
assert definition.name == "example"
|
||||
assert definition.matchers[0].matches("https://example.com/articles/123")
|
||||
assert definition.parsers["link"].expression == ".article a.link::attr(href)"
|
||||
|
||||
|
||||
def test_load_task_definitions_handles_container(tmp_path: Path):
|
||||
tasks_dir = tmp_path / "tasks"
|
||||
tasks_dir.mkdir()
|
||||
|
||||
definition_content = {
|
||||
"name": "container",
|
||||
"match": ["https://example.com/cards"],
|
||||
"parse": {
|
||||
"items": {
|
||||
"selector": ".cards .card",
|
||||
"fields": {
|
||||
"link": {"type": "css", "expression": ".card-header a", "attribute": "href"},
|
||||
"title": {"type": "css", "expression": ".card-header a", "attribute": "text"},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
(tasks_dir / "02-container.json").write_text(json.dumps(definition_content), encoding="utf-8")
|
||||
|
||||
config = SimpleNamespace(config_path=str(tmp_path))
|
||||
definitions = load_task_definitions(config=config)
|
||||
|
||||
assert len(definitions) == 1
|
||||
definition = definitions[0]
|
||||
assert definition.container is not None
|
||||
assert definition.container.selector == ".cards .card"
|
||||
assert "link" in definition.container.fields
|
||||
assert definition.parsers == {}
|
||||
|
||||
|
||||
def test_load_task_definitions_handles_json(tmp_path: Path):
|
||||
tasks_dir = tmp_path / "tasks"
|
||||
tasks_dir.mkdir()
|
||||
|
||||
definition_content = {
|
||||
"name": "json-def",
|
||||
"match": ["https://example.com/api"],
|
||||
"response": {"type": "json"},
|
||||
"parse": {
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
(tasks_dir / "03-json.json").write_text(json.dumps(definition_content), encoding="utf-8")
|
||||
|
||||
config = SimpleNamespace(config_path=str(tmp_path))
|
||||
definitions = load_task_definitions(config=config)
|
||||
|
||||
assert len(definitions) == 1
|
||||
definition = definitions[0]
|
||||
assert definition.response.format == "json"
|
||||
assert definition.container is not None
|
||||
assert definition.container.selector_type == "jsonpath"
|
||||
assert definition.container.fields["link"].type == "jsonpath"
|
||||
|
||||
|
||||
def test_parse_items_extracts_values():
|
||||
definition = TaskDefinition(
|
||||
name="example",
|
||||
source=Path("example.json"),
|
||||
matchers=[],
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
parsers={
|
||||
"link": ExtractionRule(type="css", expression=".article a.link::attr(href)", attribute=None),
|
||||
"title": ExtractionRule(type="css", expression=".article .title", attribute="text"),
|
||||
"id": ExtractionRule(type="css", expression=".article", attribute="data-id"),
|
||||
},
|
||||
)
|
||||
|
||||
html = """
|
||||
<div class="article" data-id="101">
|
||||
<a class="link" href="/article-101">First</a>
|
||||
<span class="title">First Title</span>
|
||||
</div>
|
||||
<div class="article" data-id="102">
|
||||
<a class="link" href="https://example.com/article-102">Second</a>
|
||||
<span class="title">Second Title</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/")
|
||||
|
||||
assert len(items) == 2
|
||||
assert items[0]["link"] == "https://example.com/article-101"
|
||||
assert items[0]["title"] == "First Title"
|
||||
assert items[0]["id"] == "101"
|
||||
assert items[1]["link"] == "https://example.com/article-102"
|
||||
|
||||
|
||||
def test_parse_items_handles_nested_card_layout():
|
||||
definition = TaskDefinition(
|
||||
name="nested",
|
||||
source=Path("nested.json"),
|
||||
matchers=[],
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
parsers={},
|
||||
container=ContainerDefinition(
|
||||
selector_type="css",
|
||||
selector=".columns .card",
|
||||
fields={
|
||||
"link": ExtractionRule(
|
||||
type="css",
|
||||
expression=".card-header a[href]",
|
||||
attribute="href",
|
||||
),
|
||||
"title": ExtractionRule(
|
||||
type="css",
|
||||
expression=".card-header a[href]",
|
||||
attribute="text",
|
||||
),
|
||||
"poet": ExtractionRule(
|
||||
type="css",
|
||||
expression="footer .card-footer-item:first-child a",
|
||||
attribute="text",
|
||||
),
|
||||
"category": ExtractionRule(
|
||||
type="css",
|
||||
expression="footer .card-footer-item:nth-child(2) a",
|
||||
attribute="text",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
html = """
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
|
||||
<a href="/poems/view/111" title="First Poem">First Poem</a>
|
||||
</p>
|
||||
</div>
|
||||
<footer class="card-footer has-text-centered">
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> By <a href="/poet/alpha">Poet Alpha</a></span>
|
||||
</p>
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> In <a href="/category/one">Category One</a></span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
|
||||
<a href="/poems/view/222" title="Second Poem">Second Poem</a>
|
||||
</p>
|
||||
</div>
|
||||
<footer class="card-footer has-text-centered">
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> By <a href="/poet/beta">Poet Beta</a></span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com")
|
||||
|
||||
assert len(items) == 2
|
||||
assert items[0]["link"] == "https://example.com/poems/view/111"
|
||||
assert items[0]["title"] == "First Poem"
|
||||
assert items[0]["poet"] == "Poet Alpha"
|
||||
assert items[0]["category"] == "Category One"
|
||||
|
||||
assert items[1]["link"] == "https://example.com/poems/view/222"
|
||||
assert items[1]["title"] == "Second Poem"
|
||||
assert items[1]["poet"] == "Poet Beta"
|
||||
assert "category" not in items[1]
|
||||
|
||||
|
||||
def test_parse_items_handles_json_container():
|
||||
definition = TaskDefinition(
|
||||
name="json",
|
||||
source=Path("json.json"),
|
||||
matchers=[],
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
parsers={},
|
||||
container=ContainerDefinition(
|
||||
selector_type="jsonpath",
|
||||
selector="entries",
|
||||
fields={
|
||||
"link": ExtractionRule(type="jsonpath", expression="url"),
|
||||
"title": ExtractionRule(type="jsonpath", expression="title"),
|
||||
"id": ExtractionRule(type="jsonpath", expression="id"),
|
||||
},
|
||||
),
|
||||
response=ResponseConfig(format="json"),
|
||||
)
|
||||
|
||||
payload = {
|
||||
"entries": [
|
||||
{"url": "/video/1", "title": "First", "id": 1},
|
||||
{"url": "https://example.com/video/2", "title": "Second", "id": 2},
|
||||
{"title": "Missing Link", "id": 3},
|
||||
]
|
||||
}
|
||||
|
||||
items = GenericTaskHandler._parse_items(
|
||||
definition=definition,
|
||||
html="",
|
||||
base_url="https://example.com",
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
assert len(items) == 2
|
||||
assert items[0]["link"] == "https://example.com/video/1"
|
||||
assert items[0]["title"] == "First"
|
||||
assert items[0]["id"] == "1"
|
||||
|
||||
assert items[1]["link"] == "https://example.com/video/2"
|
||||
assert items[1]["title"] == "Second"
|
||||
assert items[1]["id"] == "2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_task_handler_inspect(monkeypatch):
|
||||
definition = TaskDefinition(
|
||||
name="json-inspect",
|
||||
source=Path("json-inspect.json"),
|
||||
matchers=[],
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
parsers={},
|
||||
container=ContainerDefinition(
|
||||
selector_type="jsonpath",
|
||||
selector="items",
|
||||
fields={
|
||||
"link": ExtractionRule(type="jsonpath", expression="url"),
|
||||
"title": ExtractionRule(type="jsonpath", expression="title"),
|
||||
},
|
||||
),
|
||||
response=ResponseConfig(format="json"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
GenericTaskHandler,
|
||||
"_find_definition",
|
||||
classmethod(lambda cls, url: definition), # noqa: ARG005
|
||||
)
|
||||
|
||||
async def fake_fetch_content(url, definition, ytdlp_opts): # noqa: ARG001
|
||||
return "", {"items": [{"url": "/video/1", "title": "First"}]}
|
||||
|
||||
monkeypatch.setattr(GenericTaskHandler, "_fetch_content", staticmethod(fake_fetch_content))
|
||||
|
||||
task = Task(id="inspect", name="Inspect", url="https://example.com/api")
|
||||
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
|
||||
|
||||
assert isinstance(result, TaskResult)
|
||||
assert len(result.items) == 1
|
||||
item = result.items[0]
|
||||
assert item.url == "https://example.com/video/1"
|
||||
assert item.title == "First"
|
||||
|
||||
|
||||
def test_parse_items_handles_json_top_level_list():
|
||||
definition = TaskDefinition(
|
||||
name="json-list",
|
||||
source=Path("json-list.json"),
|
||||
matchers=[],
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
parsers={},
|
||||
container=ContainerDefinition(
|
||||
selector_type="jsonpath",
|
||||
selector="[]",
|
||||
fields={
|
||||
"link": ExtractionRule(type="jsonpath", expression="url"),
|
||||
"title": ExtractionRule(type="jsonpath", expression="title"),
|
||||
},
|
||||
),
|
||||
response=ResponseConfig(format="json"),
|
||||
)
|
||||
|
||||
payload = [
|
||||
{"url": "/video/1", "title": "First"},
|
||||
{"url": "/video/2", "title": "Second"},
|
||||
]
|
||||
|
||||
items = GenericTaskHandler._parse_items(
|
||||
definition=definition,
|
||||
html="",
|
||||
base_url="https://example.com",
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
assert len(items) == 2
|
||||
assert items[0]["link"] == "https://example.com/video/1"
|
||||
assert items[0]["title"] == "First"
|
||||
assert items[1]["link"] == "https://example.com/video/2"
|
||||
assert items[1]["title"] == "Second"
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -165,3 +166,116 @@ class TestItemDTO:
|
|||
ok2 = dto.archive_delete()
|
||||
assert ok2 is True
|
||||
m_del.assert_called_once()
|
||||
|
||||
def test_get_file_method(self):
|
||||
"""Test ItemDTO get_file method returns correct path."""
|
||||
# Create ItemDTO with filename but no folder
|
||||
dto = ItemDTO(
|
||||
id="test-id",
|
||||
title="Test Video",
|
||||
url="https://youtube.com/watch?v=test123",
|
||||
folder="",
|
||||
status="finished",
|
||||
filename="test_video.mp4",
|
||||
)
|
||||
|
||||
# Test with no filename returns None
|
||||
dto_no_file = ItemDTO(
|
||||
id="test-id-2",
|
||||
title="Test Video 2",
|
||||
url="https://youtube.com/watch?v=test456",
|
||||
folder="",
|
||||
status="finished",
|
||||
)
|
||||
assert dto_no_file.get_file() is None
|
||||
|
||||
# Mock get_file function to return success (without custom download_path)
|
||||
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
|
||||
mock_get_file.return_value = ("/downloads/test_video.mp4", 200)
|
||||
mock_config.get_instance.return_value.download_path = "/downloads"
|
||||
|
||||
result = dto.get_file()
|
||||
assert result == Path("/downloads/test_video.mp4")
|
||||
|
||||
# Test with folder
|
||||
dto_with_folder = ItemDTO(
|
||||
id="test-id-3",
|
||||
title="Test Video 3",
|
||||
url="https://youtube.com/watch?v=test789",
|
||||
folder="media",
|
||||
status="finished",
|
||||
filename="test_video.mp4",
|
||||
)
|
||||
|
||||
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
|
||||
mock_get_file.return_value = ("/downloads/media/test_video.mp4", 200)
|
||||
mock_config.get_instance.return_value.download_path = "/downloads"
|
||||
|
||||
result = dto_with_folder.get_file()
|
||||
assert result == Path("/downloads/media/test_video.mp4")
|
||||
|
||||
# Test with file not found
|
||||
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
|
||||
mock_get_file.return_value = ("/downloads/test_video.mp4", 404)
|
||||
mock_config.get_instance.return_value.download_path = "/downloads"
|
||||
|
||||
result = dto.get_file()
|
||||
assert result is None
|
||||
|
||||
# Test with exception during file access
|
||||
with patch("app.library.ItemDTO.get_file") as mock_get_file, patch("app.library.config.Config") as mock_config:
|
||||
mock_get_file.side_effect = ValueError("File path error")
|
||||
mock_config.get_instance.return_value.download_path = "/downloads"
|
||||
|
||||
result = dto.get_file()
|
||||
assert result is None
|
||||
|
||||
# Test with custom download_path parameter (Config not imported in this case)
|
||||
with patch("app.library.ItemDTO.get_file") as mock_get_file:
|
||||
mock_get_file.return_value = ("/custom/test_video.mp4", 200)
|
||||
|
||||
result = dto.get_file(download_path=Path("/custom"))
|
||||
assert result == Path("/custom/test_video.mp4")
|
||||
|
||||
def test_get_file_sidecar_populates_from_utils(self):
|
||||
with patch.object(ItemDTO, "__post_init__", lambda _: None):
|
||||
dto = ItemDTO(id="sidecar", title="Title", url="u", folder="f")
|
||||
|
||||
expected_sidecar = {
|
||||
"subtitle": [
|
||||
{
|
||||
"file": Path("/downloads/video.en.srt"),
|
||||
"lang": "en",
|
||||
"name": "SRT (0) - en",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
patch("app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=Path("/downloads/video.mp4")) as mock_get_file,
|
||||
patch("app.library.ItemDTO.get_file_sidecar", return_value=expected_sidecar) as mock_utils_sidecar,
|
||||
):
|
||||
result = dto.get_file_sidecar()
|
||||
|
||||
mock_get_file.assert_called_once_with(dto)
|
||||
mock_utils_sidecar.assert_called_once_with(Path("/downloads/video.mp4"))
|
||||
assert result is expected_sidecar
|
||||
assert dto.sidecar is expected_sidecar
|
||||
|
||||
def test_get_file_sidecar_returns_existing_when_no_file(self):
|
||||
with patch.object(ItemDTO, "__post_init__", lambda _: None):
|
||||
dto = ItemDTO(id="sidecar-none", title="Title", url="u", folder="f")
|
||||
|
||||
existing = {"existing": []}
|
||||
dto.sidecar = existing
|
||||
|
||||
with (
|
||||
patch("app.library.ItemDTO.ItemDTO.get_file", autospec=True, return_value=None) as mock_get_file,
|
||||
patch("app.library.ItemDTO.get_file_sidecar") as mock_utils_sidecar,
|
||||
):
|
||||
result = dto.get_file_sidecar()
|
||||
|
||||
mock_get_file.assert_called_once_with(dto)
|
||||
mock_utils_sidecar.assert_not_called()
|
||||
assert result is existing
|
||||
assert dto.sidecar is existing
|
||||
|
|
|
|||
|
|
@ -1003,87 +1003,6 @@ class TestNotification:
|
|||
assert result is None
|
||||
mock_worker_instance.submit.assert_called_once_with(notification.send, event)
|
||||
|
||||
def test_deep_unpack_simple_dict(self):
|
||||
"""Test _deep_unpack method with simple dictionary."""
|
||||
with patch("app.library.Notifications.Config") as mock_config, \
|
||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
||||
|
||||
mock_config.get_instance.return_value = Mock(
|
||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
||||
)
|
||||
mock_worker.get_instance.return_value = Mock()
|
||||
|
||||
notification = Notification.get_instance()
|
||||
|
||||
data = {"key1": "value1", "key2": 123}
|
||||
result = notification._deep_unpack(data)
|
||||
|
||||
assert result == {"key1": "value1", "key2": 123}
|
||||
|
||||
def test_deep_unpack_nested_dict(self):
|
||||
"""Test _deep_unpack method with nested dictionary."""
|
||||
with patch("app.library.Notifications.Config") as mock_config, \
|
||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
||||
|
||||
mock_config.get_instance.return_value = Mock(
|
||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
||||
)
|
||||
mock_worker.get_instance.return_value = Mock()
|
||||
|
||||
notification = Notification.get_instance()
|
||||
|
||||
data = {
|
||||
"level1": {
|
||||
"level2": {
|
||||
"value": "nested"
|
||||
}
|
||||
}
|
||||
}
|
||||
result = notification._deep_unpack(data)
|
||||
|
||||
assert result["level1"]["level2"]["value"] == "nested"
|
||||
|
||||
def test_deep_unpack_with_datetime(self):
|
||||
"""Test _deep_unpack method with datetime objects."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
with patch("app.library.Notifications.Config") as mock_config, \
|
||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
||||
|
||||
mock_config.get_instance.return_value = Mock(
|
||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
||||
)
|
||||
mock_worker.get_instance.return_value = Mock()
|
||||
|
||||
notification = Notification.get_instance()
|
||||
|
||||
test_datetime = datetime.now(tz=UTC)
|
||||
data = {"timestamp": test_datetime}
|
||||
result = notification._deep_unpack(data)
|
||||
|
||||
assert result["timestamp"] == test_datetime.isoformat()
|
||||
|
||||
def test_deep_unpack_with_serializable_object(self):
|
||||
"""Test _deep_unpack method with object having serialize method."""
|
||||
with patch("app.library.Notifications.Config") as mock_config, \
|
||||
patch("app.library.Notifications.BackgroundWorker") as mock_worker:
|
||||
|
||||
mock_config.get_instance.return_value = Mock(
|
||||
debug=False, config_path="/tmp", app_version="1.0.0"
|
||||
)
|
||||
mock_worker.get_instance.return_value = Mock()
|
||||
|
||||
notification = Notification.get_instance()
|
||||
|
||||
# Mock object with serialize method
|
||||
mock_obj = Mock()
|
||||
mock_obj.serialize.return_value = {"serialized": "data"}
|
||||
|
||||
data = {"object": mock_obj}
|
||||
result = notification._deep_unpack(data)
|
||||
|
||||
assert result["object"] == {"serialized": "data"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop(self):
|
||||
"""Test noop method."""
|
||||
|
|
|
|||
57
app/tests/test_twitch_handler.py
Normal file
57
app/tests/test_twitch_handler.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import pytest
|
||||
|
||||
from app.library.task_handlers.twitch import TwitchHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class DummyOpts:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def get_all(self):
|
||||
return self._data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twitch_handler_inspect(monkeypatch):
|
||||
feed = """
|
||||
<rss><channel>
|
||||
<item>
|
||||
<link>https://www.twitch.tv/videos/111</link>
|
||||
<title>First VOD</title>
|
||||
</item>
|
||||
<item>
|
||||
<link>https://www.twitch.tv/videos/222</link>
|
||||
<title>Second VOD</title>
|
||||
</item>
|
||||
</channel></rss>
|
||||
""".strip()
|
||||
|
||||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="inspect",
|
||||
name="Inspect",
|
||||
url="https://www.twitch.tv/testchannel",
|
||||
preset="default",
|
||||
)
|
||||
|
||||
result = await TwitchHandler.extract(task)
|
||||
|
||||
assert isinstance(result, TaskResult)
|
||||
assert len(result.items) == 2
|
||||
assert result.items[0].url == "https://www.twitch.tv/videos/111"
|
||||
assert result.items[0].title == "First VOD"
|
||||
assert result.metadata.get("has_entries") is True
|
||||
|
|
@ -44,6 +44,7 @@ from app.library.Utils import (
|
|||
str_to_dt,
|
||||
strip_newline,
|
||||
tail_log,
|
||||
timed_lru_cache,
|
||||
validate_url,
|
||||
validate_uuid,
|
||||
ytdlp_reject,
|
||||
|
|
@ -65,6 +66,233 @@ class TestStreamingError:
|
|||
assert isinstance(error, Exception)
|
||||
|
||||
|
||||
class TestTimedLruCache:
|
||||
"""Test the timed_lru_cache decorator."""
|
||||
|
||||
def test_timed_lru_cache_basic_functionality(self):
|
||||
"""Test that timed_lru_cache caches function results."""
|
||||
call_count = 0
|
||||
|
||||
@timed_lru_cache(ttl_seconds=60, max_size=10)
|
||||
def test_function(x):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return x * 2
|
||||
|
||||
# First call should execute the function
|
||||
result1 = test_function(5)
|
||||
assert result1 == 10
|
||||
assert call_count == 1
|
||||
|
||||
# Second call with same argument should return cached result
|
||||
result2 = test_function(5)
|
||||
assert result2 == 10
|
||||
assert call_count == 1 # Should not increment
|
||||
|
||||
# Different argument should execute the function again
|
||||
result3 = test_function(10)
|
||||
assert result3 == 20
|
||||
assert call_count == 2
|
||||
|
||||
def test_timed_lru_cache_expiration(self):
|
||||
"""Test that cache expires after TTL."""
|
||||
import time
|
||||
|
||||
call_count = 0
|
||||
|
||||
@timed_lru_cache(ttl_seconds=1, max_size=10) # 1 second TTL
|
||||
def test_function(x):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return x * 2
|
||||
|
||||
# First call
|
||||
result1 = test_function(5)
|
||||
assert result1 == 10
|
||||
assert call_count == 1
|
||||
|
||||
# Second call immediately should be cached
|
||||
result2 = test_function(5)
|
||||
assert result2 == 10
|
||||
assert call_count == 1
|
||||
|
||||
# Wait for cache to expire
|
||||
time.sleep(1.1)
|
||||
|
||||
# Call after expiration should execute function again
|
||||
result3 = test_function(5)
|
||||
assert result3 == 10
|
||||
assert call_count == 2
|
||||
|
||||
def test_timed_lru_cache_methods_exposed(self):
|
||||
"""Test that cache_clear and cache_info methods are exposed."""
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
@timed_lru_cache(ttl_seconds=60, max_size=10)
|
||||
def test_function(x):
|
||||
return x * 2
|
||||
|
||||
# Test that methods exist
|
||||
assert hasattr(test_function, "cache_clear")
|
||||
assert hasattr(test_function, "cache_info")
|
||||
|
||||
# Call function to populate cache
|
||||
test_function(5)
|
||||
|
||||
# Get cache info
|
||||
info = test_function.cache_info()
|
||||
assert info.hits == 0
|
||||
assert info.misses == 1
|
||||
|
||||
# Call again to test hit
|
||||
test_function(5)
|
||||
info = test_function.cache_info()
|
||||
assert info.hits == 1
|
||||
assert info.misses == 1
|
||||
|
||||
# Clear cache
|
||||
test_function.cache_clear()
|
||||
info = test_function.cache_info()
|
||||
assert info.hits == 0
|
||||
assert info.misses == 0
|
||||
|
||||
def test_timed_lru_cache_max_size(self):
|
||||
"""Test that cache respects max_size limit."""
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
@timed_lru_cache(ttl_seconds=60, max_size=2)
|
||||
def test_function(x):
|
||||
return x * 2
|
||||
|
||||
# Fill cache to max size
|
||||
test_function(1)
|
||||
test_function(2)
|
||||
|
||||
info = test_function.cache_info()
|
||||
assert info.misses == 2
|
||||
|
||||
# Adding another item should not exceed max size
|
||||
test_function(3)
|
||||
info = test_function.cache_info()
|
||||
assert info.misses == 3
|
||||
|
||||
# Test that earlier entries may be evicted
|
||||
test_function(1) # This might be a cache miss due to LRU eviction
|
||||
|
||||
|
||||
class TestAsyncTimedLruCache:
|
||||
"""Test async functionality of timed_lru_cache decorator."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_timed_lru_cache_basic(self):
|
||||
"""Test basic async caching functionality."""
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
call_count = 0
|
||||
|
||||
@timed_lru_cache(ttl_seconds=300, max_size=128)
|
||||
async def async_test_func(x):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return x * 2
|
||||
|
||||
# First call should execute the function
|
||||
result1 = await async_test_func(5)
|
||||
assert result1 == 10
|
||||
assert call_count == 1
|
||||
|
||||
# Second call should use cached result
|
||||
result2 = await async_test_func(5)
|
||||
assert result2 == 10
|
||||
assert call_count == 1 # Should not increment
|
||||
|
||||
# Different argument should execute function again
|
||||
result3 = await async_test_func(3)
|
||||
assert result3 == 6
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_timed_lru_cache_expiry(self):
|
||||
"""Test that async cache entries expire after TTL."""
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
call_count = 0
|
||||
|
||||
@timed_lru_cache(ttl_seconds=0.1, max_size=128) # 100ms TTL
|
||||
async def async_expire_func(x):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return x * 3
|
||||
|
||||
# First call
|
||||
result1 = await async_expire_func(2)
|
||||
assert result1 == 6
|
||||
assert call_count == 1
|
||||
|
||||
# Immediate second call should use cache
|
||||
result2 = await async_expire_func(2)
|
||||
assert result2 == 6
|
||||
assert call_count == 1
|
||||
|
||||
# Wait for cache to expire
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# Third call should execute function again due to expiry
|
||||
result3 = await async_expire_func(2)
|
||||
assert result3 == 6
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_cache_methods(self):
|
||||
"""Test that async cached functions expose cache management methods."""
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
@timed_lru_cache(ttl_seconds=300, max_size=128)
|
||||
async def async_method_test(x):
|
||||
return x + 1
|
||||
|
||||
# Test that cache methods exist
|
||||
assert hasattr(async_method_test, "cache_clear")
|
||||
assert hasattr(async_method_test, "cache_info")
|
||||
|
||||
# Test cache_info
|
||||
info = async_method_test.cache_info()
|
||||
assert hasattr(info, "hits")
|
||||
assert hasattr(info, "misses")
|
||||
assert hasattr(info, "maxsize")
|
||||
assert hasattr(info, "currsize")
|
||||
assert info.maxsize == 128
|
||||
|
||||
# Test cache_clear
|
||||
await async_method_test(1)
|
||||
async_method_test.cache_clear()
|
||||
info_after_clear = async_method_test.cache_info()
|
||||
assert info_after_clear.currsize == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_cache_max_size(self):
|
||||
"""Test that async cache respects max_size parameter."""
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
@timed_lru_cache(ttl_seconds=300, max_size=2)
|
||||
async def async_limited_func(x):
|
||||
return x * 4
|
||||
|
||||
# Fill cache beyond max_size
|
||||
result1 = await async_limited_func(1)
|
||||
result2 = await async_limited_func(2)
|
||||
result3 = await async_limited_func(3) # Should evict oldest entry
|
||||
|
||||
# Verify results
|
||||
assert result1 == 4
|
||||
assert result2 == 8
|
||||
assert result3 == 12
|
||||
|
||||
# Check cache size is limited
|
||||
info = async_limited_func.cache_info()
|
||||
assert info.currsize <= 2
|
||||
|
||||
|
||||
class TestFileLogFormatter:
|
||||
"""Test the FileLogFormatter class."""
|
||||
|
||||
|
|
|
|||
61
app/tests/test_youtube_handler.py
Normal file
61
app/tests/test_youtube_handler.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import pytest
|
||||
|
||||
from app.library.task_handlers.youtube import YoutubeHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class DummyOpts:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
|
||||
def get_all(self):
|
||||
return self._data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_youtube_handler_inspect(monkeypatch):
|
||||
feed = """
|
||||
<feed xmlns='http://www.w3.org/2005/Atom' xmlns:yt='http://www.youtube.com/xml/schemas/2015'>
|
||||
<entry>
|
||||
<yt:videoId>abc123</yt:videoId>
|
||||
<title>First Video</title>
|
||||
<published>2024-01-01T00:00:00Z</published>
|
||||
</entry>
|
||||
<entry>
|
||||
<yt:videoId>def456</yt:videoId>
|
||||
<title>Second Video</title>
|
||||
<published>2024-01-02T00:00:00Z</published>
|
||||
</entry>
|
||||
</feed>
|
||||
""".strip()
|
||||
|
||||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="inspect",
|
||||
name="Inspect",
|
||||
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||
preset="default",
|
||||
)
|
||||
|
||||
result = await YoutubeHandler.extract(task)
|
||||
|
||||
assert isinstance(result, TaskResult)
|
||||
assert len(result.items) == 2
|
||||
first = result.items[0]
|
||||
assert first.url == "https://www.youtube.com/watch?v=abc123"
|
||||
assert first.title == "First Video"
|
||||
assert first.metadata.get("published") == "2024-01-01T00:00:00Z"
|
||||
assert result.metadata.get("entry_count") == 2
|
||||
|
|
@ -40,6 +40,9 @@ dependencies = [
|
|||
"bgutil-ytdlp-pot-provider>=1.2.1",
|
||||
"pycryptodome>=3.23.0",
|
||||
"httpx-curl-cffi>=0.1.4",
|
||||
"selenium>=4.35.0",
|
||||
"parsel>=1.10.0",
|
||||
"jmespath>=1.0.1",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
</p>
|
||||
|
||||
<!-- prompt input -->
|
||||
<div v-if="state.current?.type === 'prompt'" class="field">
|
||||
<div v-if="'prompt' === state.current?.type" class="field">
|
||||
<div class="control">
|
||||
<input ref="inputEl" class="input" type="text" v-model="localInput"
|
||||
:placeholder="(state.current?.opts as any)?.placeholder ?? ''" @keyup.stop />
|
||||
|
|
@ -61,8 +61,8 @@
|
|||
</section>
|
||||
|
||||
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
|
||||
<template v-if="state.current?.type === 'alert'">
|
||||
<button class="button is-danger" @click="onEnter">
|
||||
<template v-if="'alert' === state.current?.type">
|
||||
<button id="primaryButton" class="button is-danger" @click="onEnter">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-check" /></span>
|
||||
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
|
||||
|
|
@ -70,10 +70,11 @@
|
|||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state.current?.type === 'confirm' || state.current?.type === 'prompt'">
|
||||
<template v-else-if="'confirm' === state.current?.type || 'prompt' === state.current?.type">
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<button class="button" @click="onEnter" :class="state.current?.opts.confirmColor ?? 'is-primary'"
|
||||
<button id="primaryButton" class="button" @click="onEnter"
|
||||
:class="state.current?.opts.confirmColor ?? 'is-primary'"
|
||||
:disabled="localInput === (state.current?.opts as PromptOptions)?.initial">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-check" /></span>
|
||||
|
|
@ -114,7 +115,7 @@ watch(() => state.current, (cur) => {
|
|||
enableOpacity()
|
||||
}
|
||||
|
||||
localInput.value = cur?.type === 'prompt' ? (cur.opts as any).initial ?? '' : ''
|
||||
localInput.value = 'prompt' === cur?.type ? (cur.opts as any).initial ?? '' : ''
|
||||
}, { immediate: true })
|
||||
|
||||
const inputEl = ref<HTMLInputElement>()
|
||||
|
|
@ -123,12 +124,12 @@ const focusPrimary = () => {
|
|||
if (!root) {
|
||||
return
|
||||
}
|
||||
const btn = root.querySelector<HTMLButtonElement>('.modal-card-foot .button.is-primary')
|
||||
const btn = root.querySelector<HTMLButtonElement>('#primaryButton')
|
||||
btn?.focus()
|
||||
}
|
||||
const focusInput = async () => {
|
||||
await nextTick()
|
||||
if (state.current?.type === 'prompt') {
|
||||
if ('prompt' === state.current?.type) {
|
||||
requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true }))
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,10 +117,8 @@
|
|||
{{ formatTime(item.extras.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="showThumbnails && item.extras.thumbnail">
|
||||
<FloatingImage
|
||||
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
:title="`[${item.preset}] - ${item.title}`">
|
||||
<div v-if="showThumbnails && getImage(item)">
|
||||
<FloatingImage :image="getImage(item)" :title="`[${item.preset}] - ${item.title}`">
|
||||
<div class="is-text-overflow">
|
||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||
</div>
|
||||
|
|
@ -272,22 +270,17 @@
|
|||
<figure class="image is-3by1">
|
||||
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
|
||||
<div class="play-icon"></div>
|
||||
<img @load="(e: Event) => pImg(e)"
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img @load="(e: Event) => pImg(e)" :src="getImage(item)" v-if="getImage(item)" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||
class="play-overlay">
|
||||
<div class="play-icon embed-icon"></div>
|
||||
<img @load="(e: Event) => pImg(e)"
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img @load="(e: Event) => pImg(e)" :src="getImage(item)" v-if="getImage(item)" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<template v-else>
|
||||
<img @load="(e: Event) => pImg(e)" v-if="item.extras?.thumbnail"
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
|
||||
<img @load="(e: Event) => pImg(e)" v-if="getImage(item)" :src="getImage(item)" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</template>
|
||||
</figure>
|
||||
|
|
@ -912,6 +905,18 @@ const makePath = (item: StoreItem) => {
|
|||
return ''
|
||||
}
|
||||
const real_path = eTrim(item.download_dir, '/') + '/' + sTrim(item.filename, '/')
|
||||
return real_path.replace(config.app.download_path, '').replace(/^\//, '')
|
||||
return stripPath(config.app.download_path, real_path)
|
||||
}
|
||||
|
||||
const getImage = (item: StoreItem): string => {
|
||||
if (item.sidecar?.image && item.sidecar.image.length > 0) {
|
||||
return uri('/api/download/' + encodeURIComponent(stripPath(config.app.download_path, item.sidecar.image[0]?.file || '')))
|
||||
}
|
||||
|
||||
if (!item?.extras?.thumbnail) {
|
||||
return '/images/placeholder.png'
|
||||
}
|
||||
|
||||
return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
16
ui/app/types/store.d.ts
vendored
16
ui/app/types/store.d.ts
vendored
|
|
@ -1,4 +1,14 @@
|
|||
type ItemStatus = 'finished' | 'preparing' | 'error' | 'cancelled' | 'downloading' | 'postprocessing' | 'not_live' | 'skip' | null;
|
||||
|
||||
type SideCar = {
|
||||
file: string
|
||||
}
|
||||
|
||||
type sideCarSubtitle = SideCar & {
|
||||
lang: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type StoreItem = {
|
||||
/** Unique identifier for the item */
|
||||
_id: string
|
||||
|
|
@ -42,6 +52,12 @@ type StoreItem = {
|
|||
auto_start: boolean
|
||||
/** Options for the item */
|
||||
options: Record<string, unknown>
|
||||
/** Sidecar associated with the item. */
|
||||
sidecar: {
|
||||
Unknown?: Array<SideCar>
|
||||
subtitle?: Array<sideCarSubtitle>
|
||||
image?: Array<SideCar>
|
||||
},
|
||||
/** Extras for the item */
|
||||
extras: {
|
||||
/** Which channel the item belongs to */
|
||||
|
|
|
|||
|
|
@ -677,10 +677,18 @@ const enableOpacity = (): boolean => {
|
|||
return true
|
||||
}
|
||||
|
||||
const stripPath = (base_path: string, real_path: string): string => {
|
||||
if (!base_path) {
|
||||
return real_path
|
||||
}
|
||||
|
||||
return real_path.replace(base_path, '').replace(/^\//, '')
|
||||
}
|
||||
|
||||
export {
|
||||
separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
|
||||
getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
|
||||
request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
|
||||
makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime,
|
||||
sleep, awaiter, encode, decode, disableOpacity, enableOpacity
|
||||
sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,21 +23,21 @@
|
|||
"@vueuse/nuxt": "^13.9.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cron-parser": "^5.3.1",
|
||||
"cron-parser": "^5.4.0",
|
||||
"cronstrue": "^3.3.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.12",
|
||||
"marked": "^16.3.0",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.7",
|
||||
"marked-gfm-heading-id": "^4.1.2",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.1.2",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-toastification": "2.0.0-rc.5",
|
||||
"marked": "^16.2.1",
|
||||
"marked-alert": "^2.1.2",
|
||||
"marked-base-url": "^1.1.7",
|
||||
"marked-gfm-heading-id": "^4.1.2"
|
||||
"vue-toastification": "2.0.0-rc.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.9.0",
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.44.0",
|
||||
"eslint": "^9.35.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4",
|
||||
|
|
|
|||
1394
ui/pnpm-lock.yaml
1394
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
1
ui/pnpm-workspace.yaml
Normal file
1
ui/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
minimumReleaseAge: 4320
|
||||
550
ui/tests/utils/index.test.ts
Normal file
550
ui/tests/utils/index.test.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { MockInstance } from 'vitest'
|
||||
|
||||
type StorageEntry = { value: unknown }
|
||||
|
||||
const notificationMock = {
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}
|
||||
|
||||
const runtimeConfig = {
|
||||
app: {
|
||||
baseURL: '/base-path',
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('#imports', () => ({
|
||||
useRuntimeConfig: vi.fn(() => runtimeConfig),
|
||||
useNotification: vi.fn(() => notificationMock),
|
||||
}))
|
||||
|
||||
// Mock the global Nuxt composables since they auto-import
|
||||
vi.stubGlobal('useRuntimeConfig', vi.fn(() => runtimeConfig))
|
||||
vi.stubGlobal('useNotification', vi.fn(() => notificationMock))
|
||||
|
||||
const storageMap = new Map<string, StorageEntry | unknown>()
|
||||
|
||||
const useStorageFn = vi.fn(<T>(key: string, defaultValue: T) => {
|
||||
if (!storageMap.has(key)) {
|
||||
storageMap.set(key, { value: defaultValue })
|
||||
}
|
||||
return storageMap.get(key)
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useStorage: useStorageFn,
|
||||
}))
|
||||
|
||||
const clipboardWriteMock = vi.fn(() => Promise.resolve())
|
||||
const fetchMock = vi.fn()
|
||||
const getRandomValuesMock = vi.fn((buffer: Uint8Array) => {
|
||||
buffer.fill(1)
|
||||
return buffer
|
||||
})
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalClipboard = globalThis.navigator?.clipboard
|
||||
const originalCrypto = globalThis.crypto
|
||||
|
||||
let utils: Awaited<typeof import('../../app/utils/index')>
|
||||
let fetchSpy: MockInstance | undefined
|
||||
|
||||
const resetStorage = () => {
|
||||
storageMap.clear()
|
||||
storageMap.set('random_bg', { value: true })
|
||||
storageMap.set('random_bg_opacity', { value: 0.95 })
|
||||
}
|
||||
|
||||
// Minimal DOM/window/custom event shims
|
||||
type Listener = (evt: { type: string; detail?: any }) => void
|
||||
const listeners = new Map<string, Listener[]>()
|
||||
|
||||
const win: any = {
|
||||
addEventListener: (type: string, cb: Listener) => {
|
||||
const arr = listeners.get(type) ?? []
|
||||
arr.push(cb)
|
||||
listeners.set(type, arr)
|
||||
},
|
||||
removeEventListener: (type: string, cb: Listener) => {
|
||||
const arr = listeners.get(type) ?? []
|
||||
listeners.set(type, arr.filter(x => x !== cb))
|
||||
},
|
||||
dispatchEvent: (evt: { type: string; detail?: any }) => {
|
||||
const arr = listeners.get(evt.type) ?? []
|
||||
arr.forEach(cb => cb(evt))
|
||||
return true
|
||||
},
|
||||
focus: () => {},
|
||||
navigator: {},
|
||||
}
|
||||
globalThis.window = win as unknown as Window & typeof globalThis
|
||||
// Ensure global navigator is present
|
||||
globalThis.navigator = win.navigator as Navigator
|
||||
|
||||
class MiniCustomEvent<T = any> {
|
||||
type: string
|
||||
detail?: T
|
||||
constructor(type: string, init?: { detail?: T }) {
|
||||
this.type = type
|
||||
this.detail = init?.detail
|
||||
}
|
||||
}
|
||||
globalThis.CustomEvent = MiniCustomEvent as unknown as typeof CustomEvent
|
||||
|
||||
class ClassList {
|
||||
private set = new Set<string>()
|
||||
contains = (c: string) => this.set.has(c)
|
||||
add = (c: string) => { this.set.add(c) }
|
||||
remove = (c: string) => { this.set.delete(c) }
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
id = ''
|
||||
classList = new ClassList()
|
||||
private attrs = new Map<string, string>()
|
||||
innerHTML = ''
|
||||
setAttribute(k: string, v: string) { this.attrs.set(k, v) }
|
||||
getAttribute(k: string) { return this.attrs.has(k) ? (this.attrs.get(k) as string) : null }
|
||||
removeAttribute(k: string) { this.attrs.delete(k) }
|
||||
}
|
||||
|
||||
const registry = new Map<string, FakeElement>()
|
||||
const body = new FakeElement()
|
||||
;(body as any).appendChild = (el: FakeElement) => {
|
||||
if (el.id) registry.set(el.id, el)
|
||||
}
|
||||
|
||||
const doc: any = {
|
||||
body,
|
||||
createElement: (_tag: string) => new FakeElement(),
|
||||
querySelector: (sel: string) => {
|
||||
if (sel === 'body') return body
|
||||
if (sel.startsWith('#')) return registry.get(sel.slice(1)) ?? null
|
||||
return null
|
||||
},
|
||||
execCommand: () => true,
|
||||
}
|
||||
globalThis.document = doc as unknown as Document
|
||||
globalThis.HTMLElement = FakeElement as unknown as typeof HTMLElement
|
||||
globalThis.Node = FakeElement as unknown as typeof Node
|
||||
// btoa/atob polyfills
|
||||
globalThis.btoa = globalThis.btoa ?? ((str: string) => Buffer.from(str, 'binary').toString('base64'))
|
||||
globalThis.atob = globalThis.atob ?? ((b64: string) => Buffer.from(b64, 'base64').toString('binary'))
|
||||
|
||||
beforeAll(async () => {
|
||||
// Import utils after all mocks are set up
|
||||
utils = await import('../../app/utils/index')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetStorage()
|
||||
runtimeConfig.app.baseURL = '/base-path'
|
||||
notificationMock.info.mockClear()
|
||||
notificationMock.success.mockClear()
|
||||
notificationMock.warning.mockClear()
|
||||
notificationMock.error.mockClear()
|
||||
notificationMock.notify.mockClear()
|
||||
useStorageFn.mockClear()
|
||||
|
||||
fetchMock.mockReset()
|
||||
clipboardWriteMock.mockReset()
|
||||
getRandomValuesMock.mockClear()
|
||||
|
||||
if (typeof originalFetch === 'function') {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(fetchMock)
|
||||
} else {
|
||||
;(globalThis as any).fetch = fetchMock
|
||||
fetchSpy = undefined
|
||||
}
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: clipboardWriteMock },
|
||||
})
|
||||
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: { getRandomValues: getRandomValuesMock },
|
||||
})
|
||||
Object.defineProperty(window as any, 'crypto', {
|
||||
configurable: true,
|
||||
value: { getRandomValues: getRandomValuesMock },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fetchSpy) {
|
||||
fetchSpy.mockRestore()
|
||||
} else if (!originalFetch) {
|
||||
delete (globalThis as any).fetch
|
||||
} else {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
})
|
||||
} else {
|
||||
delete (navigator as any).clipboard
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
configurable: true,
|
||||
value: originalCrypto,
|
||||
})
|
||||
Object.defineProperty(window as any, 'crypto', {
|
||||
configurable: true,
|
||||
value: originalCrypto,
|
||||
})
|
||||
|
||||
if (document.body) {
|
||||
document.body.innerHTML = ''
|
||||
document.body.removeAttribute('style')
|
||||
}
|
||||
})
|
||||
|
||||
// no afterAll needed for lightweight stubs
|
||||
|
||||
describe('utils/index setup', () => {
|
||||
it('exposes core utilities after mocks initialize', () => {
|
||||
expect(Array.isArray(utils.separators)).toBe(true)
|
||||
expect(typeof utils.getValue).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('object access helpers', () => {
|
||||
it('getValue resolves direct values and callables', () => {
|
||||
expect(utils.getValue(5)).toBe(5)
|
||||
expect(utils.getValue(() => 7)).toBe(7)
|
||||
})
|
||||
|
||||
it('ag returns nested value or default value', () => {
|
||||
const payload = { a: { b: { c: 42 } } }
|
||||
expect(utils.ag(payload, 'a.b.c')).toBe(42)
|
||||
expect(utils.ag(payload, 'a.b.x', 'fallback')).toBe('fallback')
|
||||
expect(utils.ag(payload, 'missing', () => 'fn-default')).toBe('fn-default')
|
||||
})
|
||||
|
||||
it('ag_set sets nested path creating objects as needed', () => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
utils.ag_set(payload, 'a.b.c', 99)
|
||||
expect(payload).toEqual({ a: { b: { c: 99 } } })
|
||||
})
|
||||
|
||||
it('cleanObject removes requested keys', () => {
|
||||
const source = { id: 1, keep: true, drop: false }
|
||||
expect(utils.cleanObject(source, ['drop'])).toEqual({ id: 1, keep: true })
|
||||
expect(utils.cleanObject(source, [])).toEqual(source)
|
||||
})
|
||||
|
||||
it('stripPath removes base prefix and leading slashes', () => {
|
||||
expect(utils.stripPath('/data/downloads', '/data/downloads/video.mp4')).toBe('video.mp4')
|
||||
expect(utils.stripPath('', '/var/files/test.txt')).toBe('/var/files/test.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('string manipulation helpers', () => {
|
||||
it('r replaces tokens with context values', () => {
|
||||
const result = utils.r('Hello {user.name}!', { user: { name: 'YTPTube' } })
|
||||
expect(result).toBe('Hello YTPTube!')
|
||||
})
|
||||
|
||||
it('iTrim trims delimiters at requested positions', () => {
|
||||
expect(utils.iTrim('--value--', '-', 'both')).toBe('value')
|
||||
expect(utils.iTrim('::value', ':', 'start')).toBe('value')
|
||||
expect(utils.iTrim('value::', ':', 'end')).toBe('value')
|
||||
})
|
||||
|
||||
it('eTrim and sTrim delegate to iTrim ends', () => {
|
||||
expect(utils.eTrim('##name##', '#')).toBe('##name')
|
||||
expect(utils.sTrim('##name##', '#')).toBe('name##')
|
||||
})
|
||||
|
||||
it('ucFirst capitalizes first character', () => {
|
||||
expect(utils.ucFirst('ytp')).toBe('Ytp')
|
||||
expect(utils.ucFirst('')).toBe('')
|
||||
})
|
||||
|
||||
it('encodePath safely encodes components', () => {
|
||||
expect(utils.encodePath('folder#1/video name.mp4')).toBe('folder%231/video%20name.mp4')
|
||||
})
|
||||
|
||||
it('removeANSIColors strips escape codes', () => {
|
||||
const sample = '\u001b[31mError\u001b[0m'
|
||||
expect(utils.removeANSIColors(sample)).toBe('Error')
|
||||
})
|
||||
|
||||
it('dec2hex converts to two character hex strings', () => {
|
||||
expect(utils.dec2hex(15)).toBe('0f')
|
||||
expect(utils.dec2hex(255)).toBe('ff')
|
||||
})
|
||||
|
||||
it('basename returns final segment optionally trimming extension', () => {
|
||||
expect(utils.basename('/downloads/video.mp4')).toBe('video.mp4')
|
||||
expect(utils.basename('/downloads/video.mp4', '.mp4')).toBe('video')
|
||||
expect(utils.basename('', '.mp4')).toBe('')
|
||||
})
|
||||
|
||||
it('dirname returns parent directory', () => {
|
||||
expect(utils.dirname('/downloads/video.mp4')).toBe('/downloads')
|
||||
expect(utils.dirname('video.mp4')).toBe('.')
|
||||
expect(utils.dirname('/file')).toBe('/')
|
||||
})
|
||||
|
||||
it('formatBytes returns human readable strings', () => {
|
||||
expect(utils.formatBytes(0)).toBe('0 Bytes')
|
||||
expect(utils.formatBytes(1024)).toBe('1 KiB')
|
||||
})
|
||||
|
||||
it('formatTime renders hh:mm:ss or mm:ss', () => {
|
||||
expect(utils.formatTime(59)).toBe('59')
|
||||
expect(utils.formatTime(90)).toBe('01:30')
|
||||
expect(utils.formatTime(3661)).toBe('01:01:01')
|
||||
})
|
||||
|
||||
it('getSeparatorsName returns human readable label', () => {
|
||||
expect(utils.getSeparatorsName(',')).toContain('Comma')
|
||||
expect(utils.getSeparatorsName('*')).toBe('Unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('data conversion helpers', () => {
|
||||
it('has_data detects arrays, objects, and json strings', () => {
|
||||
expect(utils.has_data({ key: 'value' })).toBe(true)
|
||||
expect(utils.has_data('""')).toBe(false)
|
||||
expect(utils.has_data('[1,2]')).toBe(true)
|
||||
expect(utils.has_data('')).toBe(false)
|
||||
})
|
||||
|
||||
it('encode and decode provide reversible transformation', () => {
|
||||
const payload = { name: 'YTPTube', count: 2 }
|
||||
const encoded = utils.encode(payload)
|
||||
expect(typeof encoded).toBe('string')
|
||||
expect(utils.decode(encoded)).toEqual(payload)
|
||||
})
|
||||
|
||||
it('makePagination builds a ranged pagination list', () => {
|
||||
const pages = utils.makePagination(5, 10, 1)
|
||||
const selected = pages.find((page: any) => page.selected)
|
||||
expect(selected?.page).toBe(5)
|
||||
expect(pages.length).toBeGreaterThan(0)
|
||||
expect(pages[0]?.page).toBe(1)
|
||||
expect(pages[pages.length - 1]?.page).toBe(10)
|
||||
})
|
||||
|
||||
it('getQueryParams parses query strings', () => {
|
||||
expect(utils.getQueryParams('?a=1&b=two')).toEqual({ a: '1', b: 'two' })
|
||||
})
|
||||
|
||||
it('uri prefixes runtime base path', () => {
|
||||
runtimeConfig.app.baseURL = '/base-path'
|
||||
expect(utils.uri('/api/test')).toBe('/base-path/api/test')
|
||||
runtimeConfig.app.baseURL = '/'
|
||||
expect(utils.uri('/api/test')).toBe('/api/test')
|
||||
})
|
||||
|
||||
it('makeDownload builds expected url with folder and filename', () => {
|
||||
runtimeConfig.app.baseURL = '/base-path'
|
||||
const url = utils.makeDownload({}, { folder: 'music', filename: 'song.mp3' })
|
||||
expect(url).toBe('/base-path/api/download/music/song.mp3')
|
||||
})
|
||||
|
||||
it('makeDownload handles m3u8 base path', () => {
|
||||
const url = utils.makeDownload({}, { filename: 'playlist' }, 'm3u8')
|
||||
expect(url).toBe('/base-path/api/player/m3u8/video/playlist.m3u8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dom and browser helpers', () => {
|
||||
it('awaitElement waits until element appears', async () => {
|
||||
vi.useFakeTimers()
|
||||
const callback = vi.fn()
|
||||
utils.awaitElement('#dynamic', callback)
|
||||
|
||||
setTimeout(() => {
|
||||
const el = document.createElement('div')
|
||||
el.id = 'dynamic'
|
||||
document.body.appendChild(el)
|
||||
}, 50)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1)
|
||||
const callArgs = callback.mock.calls[0]
|
||||
expect(callArgs).toBeDefined()
|
||||
expect(Array.isArray(callArgs)).toBe(true)
|
||||
const [element, selector] = callArgs!
|
||||
expect((element as Element).id).toBe('dynamic')
|
||||
expect(selector).toBe('#dynamic')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('dEvent dispatches custom event with detail payload', () => {
|
||||
const detail = { foo: 'bar' }
|
||||
let received: unknown = null
|
||||
const listener = (event: Event) => {
|
||||
received = (event as CustomEvent).detail
|
||||
}
|
||||
|
||||
window.addEventListener('custom-detail', listener)
|
||||
const dispatched = utils.dEvent('custom-detail', detail)
|
||||
window.removeEventListener('custom-detail', listener)
|
||||
|
||||
expect(dispatched).toBe(true)
|
||||
expect(received).toEqual(detail)
|
||||
})
|
||||
|
||||
it('toggleClass adds and removes classes', () => {
|
||||
const el = document.createElement('div')
|
||||
utils.toggleClass(el, 'active')
|
||||
expect(el.classList.contains('active')).toBe(true)
|
||||
utils.toggleClass(el, 'active')
|
||||
expect(el.classList.contains('active')).toBe(false)
|
||||
})
|
||||
|
||||
it('copyText uses clipboard API and notifies success', async () => {
|
||||
utils.copyText('sample')
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(clipboardWriteMock).toHaveBeenCalledWith('sample')
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(notificationMock.success).toHaveBeenCalledWith('Text copied to clipboard.')
|
||||
})
|
||||
|
||||
it('disableOpacity toggles body opacity when enabled', () => {
|
||||
const result = utils.disableOpacity()
|
||||
expect(result).toBe(true)
|
||||
expect(document.body.getAttribute('style')).toBe('opacity: 1.0')
|
||||
})
|
||||
|
||||
it('disableOpacity returns false when background disabled', () => {
|
||||
// Reset any previous style first
|
||||
document.body.removeAttribute('style')
|
||||
|
||||
// The implementation has `if (!bg_enable)` where bg_enable is from useStorage
|
||||
// Since all objects are truthy in JavaScript, this suggests there's a bug in the implementation
|
||||
// or VueUse refs have special behavior. Let's test what the implementation actually does.
|
||||
|
||||
// Clear the storage and set up specific mocks for this test
|
||||
storageMap.clear()
|
||||
|
||||
// Try returning `null` instead of an object - null is falsy
|
||||
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key === 'random_bg') {
|
||||
return null // null is falsy, so !null will be true
|
||||
}
|
||||
// For other keys, return default storage behavior
|
||||
if (!storageMap.has(key)) {
|
||||
storageMap.set(key, { value: defaultValue })
|
||||
}
|
||||
return storageMap.get(key)
|
||||
})
|
||||
|
||||
const result = utils.disableOpacity()
|
||||
expect(result).toBe(false)
|
||||
expect(document.body.getAttribute('style')).toBeNull()
|
||||
})
|
||||
|
||||
it('enableOpacity applies stored opacity value', () => {
|
||||
// Reset the useStorage mock for this test
|
||||
useStorageFn.mockImplementation((key: string, defaultValue: any) => {
|
||||
if (!storageMap.has(key)) {
|
||||
storageMap.set(key, { value: defaultValue })
|
||||
}
|
||||
return storageMap.get(key)
|
||||
})
|
||||
|
||||
storageMap.set('random_bg_opacity', { value: 0.75 })
|
||||
const result = utils.enableOpacity()
|
||||
expect(result).toBe(true)
|
||||
expect(document.body.getAttribute('style')).toBe('opacity: 0.75')
|
||||
})
|
||||
})
|
||||
|
||||
describe('network and id helpers', () => {
|
||||
it('request prefixes relative urls and sets defaults', async () => {
|
||||
const responseMock = { status: 200 } as Response
|
||||
fetchMock.mockResolvedValue(responseMock)
|
||||
|
||||
const response = await utils.request('/api/test')
|
||||
|
||||
expect(response).toBe(responseMock)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/base-path/api/test')
|
||||
expect(options?.method).toBe('GET')
|
||||
expect(options?.credentials).toBe('same-origin')
|
||||
expect((options?.headers as Record<string, string>)['Content-Type']).toBe('application/json')
|
||||
expect((options?.headers as Record<string, string>)['Accept']).toBe('application/json')
|
||||
expect((options as Record<string, unknown>).withCredentials).toBe(true)
|
||||
})
|
||||
|
||||
it('convertCliOptions posts payload and returns parsed json', async () => {
|
||||
const jsonMock = vi.fn().mockResolvedValue({ success: true })
|
||||
const responseMock = { status: 200, json: jsonMock }
|
||||
fetchMock.mockResolvedValue(responseMock)
|
||||
|
||||
const result = await utils.convertCliOptions('--help')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, options] = fetchMock.mock.calls[0]!
|
||||
expect(url).toBe('/base-path/api/yt-dlp/convert')
|
||||
expect(options?.method).toBe('POST')
|
||||
expect(options?.body).toBe(JSON.stringify({ args: '--help' }))
|
||||
expect(jsonMock).toHaveBeenCalled()
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('convertCliOptions throws on non-200 response', async () => {
|
||||
const jsonMock = vi.fn().mockResolvedValue({ error: 'fail' })
|
||||
const responseMock = { status: 400, json: jsonMock }
|
||||
fetchMock.mockResolvedValue(responseMock)
|
||||
|
||||
await expect(utils.convertCliOptions('--bad')).rejects.toThrow('Error: (400): fail')
|
||||
})
|
||||
|
||||
it('makeId uses crypto random values for deterministic id', () => {
|
||||
const id = utils.makeId(4)
|
||||
expect(id).toBe('0101')
|
||||
expect(getRandomValuesMock).toHaveBeenCalled()
|
||||
const typedArray = getRandomValuesMock.mock.calls[0]?.[0] as Uint8Array
|
||||
expect(typedArray).toBeInstanceOf(Uint8Array)
|
||||
expect(typedArray.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('async helpers', () => {
|
||||
it('sleep resolves after specified seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
const promise = utils.sleep(1)
|
||||
const thenSpy = vi.fn()
|
||||
promise.then(thenSpy)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await promise
|
||||
expect(thenSpy).toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('awaiter resolves when test becomes truthy', async () => {
|
||||
// The frequency parameter is passed to sleep() which expects seconds, not milliseconds
|
||||
// So we use 0.01 (10ms) instead of 10 to avoid the bug in the implementation
|
||||
const values = [false, false, 'done']
|
||||
const result = await utils.awaiter(() => values.shift(), 500, 0.01)
|
||||
expect(result).toBe('done')
|
||||
})
|
||||
|
||||
it('awaiter returns false when timeout reached', async () => {
|
||||
// Use a short timeout and small frequency (in seconds, not milliseconds due to implementation bug)
|
||||
const result = await utils.awaiter(() => false, 50, 0.01)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { MatchFilterParser } from './ytdlp'
|
||||
import { MatchFilterParser } from '../../app/utils/ytdlp'
|
||||
|
||||
function normalize(filters: string[]): Set<string> {
|
||||
return new Set(filters.map(f => f.split("&").map(x => x.trim()).sort().join("&")));
|
||||
16
ui/vitest.config.ts
Normal file
16
ui/vitest.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Test files location
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Map ~ to the app directory for imports in tests
|
||||
'~': path.resolve(__dirname, './app'),
|
||||
'#imports': path.resolve(__dirname, './app'),
|
||||
},
|
||||
},
|
||||
})
|
||||
371
uv.lock
371
uv.lock
|
|
@ -279,14 +279,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
version = "8.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -319,6 +319,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/8c/dd/9c40c4e0f4d3cb6cf52eb335e9cc1fa140c1f3a87146fb6987f465b069da/cronsim-2.6-py3-none-any.whl", hash = "sha256:5e153ff8ed64da7ee8d5caac470dbeda8024ab052c3010b1be149772b4801835", size = 13500 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssselect"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curl-cffi"
|
||||
version = "0.13.0"
|
||||
|
|
@ -357,15 +366,19 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "debugpy"
|
||||
version = "1.8.16"
|
||||
version = "1.8.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/d4/722d0bcc7986172ac2ef3c979ad56a1030e3afd44ced136d45f8142b1f4a/debugpy-1.8.16.tar.gz", hash = "sha256:31e69a1feb1cf6b51efbed3f6c9b0ef03bc46ff050679c4be7ea6d2e23540870", size = 1643809 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/66/607ab45cc79e60624df386e233ab64a6d8d39ea02e7f80e19c1d451345bb/debugpy-1.8.16-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:85df3adb1de5258dca910ae0bb185e48c98801ec15018a263a92bb06be1c8787", size = 2496157 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a0/c95baae08a75bceabb79868d663a0736655e427ab9c81fb848da29edaeac/debugpy-1.8.16-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee89e948bc236a5c43c4214ac62d28b29388453f5fd328d739035e205365f0b", size = 4222491 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/2f/1c8db6ddd8a257c3cd2c46413b267f1d5fa3df910401c899513ce30392d6/debugpy-1.8.16-cp313-cp313-win32.whl", hash = "sha256:cf358066650439847ec5ff3dae1da98b5461ea5da0173d93d5e10f477c94609a", size = 5281126 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ba/c3e154ab307366d6c5a9c1b68de04914e2ce7fa2f50d578311d8cc5074b2/debugpy-1.8.16-cp313-cp313-win_amd64.whl", hash = "sha256:b5aea1083f6f50023e8509399d7dc6535a351cc9f2e8827d1e093175e4d9fa4c", size = 5323094 },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ecc9ae29fa5b2d90107cd1d9bf8ed19aacb74b2264d986ae9d44fe9bdf87/debugpy-1.8.16-py2.py3-none-any.whl", hash = "sha256:19c9521962475b87da6f673514f7fd610328757ec993bf7ec0d8c96f9a325f9e", size = 5287700 },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -501,6 +514,59 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/bd/f9d01fd4132d81c6f43ab01983caea69ec9614b913c290a26738431a015d/lxml-6.0.1.tar.gz", hash = "sha256:2b3a882ebf27dd026df3801a87cf49ff791336e0f94b0fad195db77e01240690", size = 4070214 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/c4/cd757eeec4548e6652eff50b944079d18ce5f8182d2b2cf514e125e8fbcb/lxml-6.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:485eda5d81bb7358db96a83546949c5fe7474bec6c68ef3fa1fb61a584b00eea", size = 8405139 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/99/0290bb86a7403893f5e9658490c705fcea103b9191f2039752b071b4ef07/lxml-6.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d12160adea318ce3d118f0b4fbdff7d1225c75fb7749429541b4d217b85c3f76", size = 4585954 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a7/4bb54dd1e626342a0f7df6ec6ca44fdd5d0e100ace53acc00e9a689ead04/lxml-6.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48c8d335d8ab72f9265e7ba598ae5105a8272437403f4032107dbcb96d3f0b29", size = 4944052 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/8d/20f51cd07a7cbef6214675a8a5c62b2559a36d9303fe511645108887c458/lxml-6.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:405e7cf9dbdbb52722c231e0f1257214202dfa192327fab3de45fd62e0554082", size = 5098885 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/63/efceeee7245d45f97d548e48132258a36244d3c13c6e3ddbd04db95ff496/lxml-6.0.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:299a790d403335a6a057ade46f92612ebab87b223e4e8c5308059f2dc36f45ed", size = 5017542 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/5d/92cb3d3499f5caba17f7933e6be3b6c7de767b715081863337ced42eb5f2/lxml-6.0.1-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:48da704672f6f9c461e9a73250440c647638cc6ff9567ead4c3b1f189a604ee8", size = 5347303 },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/f8/606fa16a05d7ef5e916c6481c634f40870db605caffed9d08b1a4fb6b989/lxml-6.0.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21e364e1bb731489e3f4d51db416f991a5d5da5d88184728d80ecfb0904b1d68", size = 5641055 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/01/15d5fc74ebb49eac4e5df031fbc50713dcc081f4e0068ed963a510b7d457/lxml-6.0.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bce45a2c32032afddbd84ed8ab092130649acb935536ef7a9559636ce7ffd4a", size = 5242719 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a5/1b85e2aaaf8deaa67e04c33bddb41f8e73d07a077bf9db677cec7128bfb4/lxml-6.0.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:fa164387ff20ab0e575fa909b11b92ff1481e6876835014e70280769920c4433", size = 4717310 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/23/f3bb1292f55a725814317172eeb296615db3becac8f1a059b53c51fc1da8/lxml-6.0.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7587ac5e000e1594e62278422c5783b34a82b22f27688b1074d71376424b73e8", size = 5254024 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/be/4d768f581ccd0386d424bac615d9002d805df7cc8482ae07d529f60a3c1e/lxml-6.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:57478424ac4c9170eabf540237125e8d30fad1940648924c058e7bc9fb9cf6dd", size = 5055335 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/07/ed61d1a3e77d1a9f856c4fab15ee5c09a2853fb7af13b866bb469a3a6d42/lxml-6.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:09c74afc7786c10dd6afaa0be2e4805866beadc18f1d843cf517a7851151b499", size = 4784864 },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/37/77e7971212e5c38a55431744f79dff27fd751771775165caea096d055ca4/lxml-6.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7fd70681aeed83b196482d42a9b0dc5b13bab55668d09ad75ed26dff3be5a2f5", size = 5657173 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/a3/e98806d483941cd9061cc838b1169626acef7b2807261fbe5e382fcef881/lxml-6.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:10a72e456319b030b3dd900df6b1f19d89adf06ebb688821636dc406788cf6ac", size = 5245896 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/de/9bb5a05e42e8623bf06b4638931ea8c8f5eb5a020fe31703abdbd2e83547/lxml-6.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0fa45fb5f55111ce75b56c703843b36baaf65908f8b8d2fbbc0e249dbc127ed", size = 5267417 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/43/c1cb2a7c67226266c463ef8a53b82d42607228beb763b5fbf4867e88a21f/lxml-6.0.1-cp313-cp313-win32.whl", hash = "sha256:01dab65641201e00c69338c9c2b8a0f2f484b6b3a22d10779bb417599fae32b5", size = 3610051 },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/96/6a6c3b8aa480639c1a0b9b6faf2a63fb73ab79ffcd2a91cf28745faa22de/lxml-6.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:bdf8f7c8502552d7bff9e4c98971910a0a59f60f88b5048f608d0a1a75e94d1c", size = 4009325 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/66/622e8515121e1fd773e3738dae71b8df14b12006d9fb554ce90886689fd0/lxml-6.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6aeca75959426b9fd8d4782c28723ba224fe07cfa9f26a141004210528dcbe2", size = 3670443 },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/e3/b7eb612ce07abe766918a7e581ec6a0e5212352194001fd287c3ace945f0/lxml-6.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:29b0e849ec7030e3ecb6112564c9f7ad6881e3b2375dd4a0c486c5c1f3a33859", size = 8426160 },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8f/ab3639a33595cf284fe733c6526da2ca3afbc5fd7f244ae67f3303cec654/lxml-6.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:02a0f7e629f73cc0be598c8b0611bf28ec3b948c549578a26111b01307fd4051", size = 4589288 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/65/819d54f2e94d5c4458c1db8c1ccac9d05230b27c1038937d3d788eb406f9/lxml-6.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:beab5e54de016e730875f612ba51e54c331e2fa6dc78ecf9a5415fc90d619348", size = 4964523 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4a/d4a74ce942e60025cdaa883c5a4478921a99ce8607fc3130f1e349a83b28/lxml-6.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a08aefecd19ecc4ebf053c27789dd92c87821df2583a4337131cf181a1dffa", size = 5101108 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/48/67f15461884074edd58af17b1827b983644d1fae83b3d909e9045a08b61e/lxml-6.0.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36c8fa7e177649470bc3dcf7eae6bee1e4984aaee496b9ccbf30e97ac4127fa2", size = 5053498 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d4/ec1bf1614828a5492f4af0b6a9ee2eb3e92440aea3ac4fa158e5228b772b/lxml-6.0.1-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:5d08e0f1af6916267bb7eff21c09fa105620f07712424aaae09e8cb5dd4164d1", size = 5351057 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/2b/c85929dacac08821f2100cea3eb258ce5c8804a4e32b774f50ebd7592850/lxml-6.0.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9705cdfc05142f8c38c97a61bd3a29581ceceb973a014e302ee4a73cc6632476", size = 5671579 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/36/cf544d75c269b9aad16752fd9f02d8e171c5a493ca225cb46bb7ba72868c/lxml-6.0.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74555e2da7c1636e30bff4e6e38d862a634cf020ffa591f1f63da96bf8b34772", size = 5250403 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e8/83dbc946ee598fd75fdeae6151a725ddeaab39bb321354a9468d4c9f44f3/lxml-6.0.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:e38b5f94c5a2a5dadaddd50084098dfd005e5a2a56cd200aaf5e0a20e8941782", size = 4696712 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/72/889c633b47c06205743ba935f4d1f5aa4eb7f0325d701ed2b0540df1b004/lxml-6.0.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5ec101a92ddacb4791977acfc86c1afd624c032974bfb6a21269d1083c9bc49", size = 5268177 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/b6/f42a21a1428479b66ea0da7bd13e370436aecaff0cfe93270c7e165bd2a4/lxml-6.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c17e70c82fd777df586c12114bbe56e4e6f823a971814fd40dec9c0de518772", size = 5094648 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b0/5f8c1e8890e2ee1c2053c2eadd1cb0e4b79e2304e2912385f6ca666f48b1/lxml-6.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:45fdd0415a0c3d91640b5d7a650a8f37410966a2e9afebb35979d06166fd010e", size = 4745220 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f9/820b5125660dae489ca3a21a36d9da2e75dd6b5ffe922088f94bbff3b8a0/lxml-6.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d417eba28981e720a14fcb98f95e44e7a772fe25982e584db38e5d3b6ee02e79", size = 5692913 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/8e/a557fae9eec236618aecf9ff35fec18df41b6556d825f3ad6017d9f6e878/lxml-6.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8e5d116b9e59be7934febb12c41cce2038491ec8fdb743aeacaaf36d6e7597e4", size = 5259816 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/fd/b266cfaab81d93a539040be699b5854dd24c84e523a1711ee5f615aa7000/lxml-6.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c238f0d0d40fdcb695c439fe5787fa69d40f45789326b3bb6ef0d61c4b588d6e", size = 5276162 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6c/6f9610fbf1de002048e80585ea4719591921a0316a8565968737d9f125ca/lxml-6.0.1-cp314-cp314-win32.whl", hash = "sha256:537b6cf1c5ab88cfd159195d412edb3e434fee880f206cbe68dff9c40e17a68a", size = 3669595 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/a5/506775e3988677db24dc75a7b03e04038e0b3d114ccd4bccea4ce0116c15/lxml-6.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:911d0a2bb3ef3df55b3d97ab325a9ca7e438d5112c102b8495321105d25a441b", size = 4079818 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/44/9613f300201b8700215856e5edd056d4e58dd23368699196b58877d4408b/lxml-6.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:2834377b0145a471a654d699bdb3a2155312de492142ef5a1d426af2c60a0a31", size = 3753901 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "macholib"
|
||||
version = "1.16.3"
|
||||
|
|
@ -581,6 +647,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outcome"
|
||||
version = "1.3.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
|
|
@ -590,6 +668,22 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parsel"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cssselect" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "lxml" },
|
||||
{ name = "packaging" },
|
||||
{ name = "w3lib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/df/acd504c154c0b9028b0d8491a77fdd5f86e9c06ee04f986abf85e36d9a5f/parsel-1.10.0.tar.gz", hash = "sha256:14f17db9559f51b43357b9dfe43cec870a8efb5ea4857abb624ec6ff80d8a080", size = 51421 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/18/35d1d947553d24909dca37e2ff11720eecb601360d1bac8d7a9a1bc7eb08/parsel-1.10.0-py2.py3-none-any.whl", hash = "sha256:6a0c28bd81f9df34ba665884c88efa0b18b8d2c44c81f64e27f2f0cb37d46169", size = 17266 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pefile"
|
||||
version = "2023.2.7"
|
||||
|
|
@ -708,7 +802,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyinstaller"
|
||||
version = "6.15.0"
|
||||
version = "6.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "altgraph" },
|
||||
|
|
@ -719,19 +813,19 @@ dependencies = [
|
|||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/17/b2bb4de22650adbeef401fa82a1b43028976547a8728602e4d29735b455e/pyinstaller-6.15.0.tar.gz", hash = "sha256:a48fc4644ee4aa2aa2a35e7b51f496f8fbd7eecf6a2150646bbf1613ad07bc2d", size = 4331521 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/94/1f62e95e4a28b64cfbb5b922ef3046f968b47170d37a1e1a029f56ac9cb4/pyinstaller-6.16.0.tar.gz", hash = "sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef", size = 4008473 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/dd/d5c8a127446adda954f68ea7fac22772f7ab8656ad4b06df396d82574ca9/pyinstaller-6.15.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:9f00c71c40148cd1e61695b2c6f1e086693d3bcf9bfa22ab513aa4254c3b966f", size = 1016981 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/2a/7b50593b419db43e48d9bdeebaac0ff92a5fe035f3c30f87ca3e1650d7e2/pyinstaller-6.15.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cbcc8eb77320c60722030ac875883b564e00768fe3ff1721c7ba3ad0e0a277e9", size = 726337 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/83/7f498fba0154c57eb5fc93eb9680a2dbadb9f780a3389fb85b8d79683378/pyinstaller-6.15.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c33e6302bc53db2df1104ed5566bd980b3e0ee7f18416a6e3caa908c12a54542", size = 737539 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/d6/e4477feab7c8379fb49e7ec95c82d0a69ad88f6ccc247f76bef3cb0e3432/pyinstaller-6.15.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:eb902d0fed3bb1f8b7190dc4df5c11f3b59505767e0d56d1ed782b853938bbf3", size = 735426 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/7e/ff25648276f15e2e77fc563d36d8cfcd917e077bf2a172420df3588601b4/pyinstaller-6.15.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b4df862adae7cf1f08eff53c43ace283822447f7f528f72e4f94749062712f15", size = 732210 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3d/267a7dddd0647de95d260780050ccd8228ab29d2b9edea54ed1f56800967/pyinstaller-6.15.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b9ebf16ed0f99016ae8ae5746dee4cb244848a12941539e62ce2eea1df5a3f95", size = 732194 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/61/962b2eb79ef225233e2d6e04600e998935328011dfb2fa775b1dd16b943a/pyinstaller-6.15.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:22193489e6a22435417103f61e7950363bba600ef36ec3ab1487303668c81092", size = 731256 },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/5e/4e20e1c0e5791b09b69bef3ac921fd0cd25551b56879324ad999b92fa045/pyinstaller-6.15.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:18f743069849dbaee3e10900385f35795a5743eabab55e99dcc42f204e40a0db", size = 731148 },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/31/28956c534991f289e2f981c715730b6241e75dc6295737a8cbd050a0cc8c/pyinstaller-6.15.0-py3-none-win32.whl", hash = "sha256:60da8f1b5071766b45c0f607d8bc3d7e59ba2c3b262d08f2e4066ba65f3544a2", size = 1312297 },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/ab/6a45186c7f8e34c422faecd72580116a67d068158c57faa2d2f6d01faa7f/pyinstaller-6.15.0-py3-none-win_amd64.whl", hash = "sha256:cbea297e16eeda30b41c300d6ec2fd2abea4dbd8d8a32650eeec36431c94fcd9", size = 1373091 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/86/72159af032b9db36f2470a3b085f79277ec1c38e7e48f8c5dc1ed16dc4e1/pyinstaller-6.15.0-py3-none-win_arm64.whl", hash = "sha256:f43c035621742cf2d19b84308c60e4e44e72c94786d176b8f6adcde351b5bd98", size = 1314305 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/0a/c42ce6e5d3de287f2e9432a074fb209f1fb72a86a72f3903849fdb5e4829/pyinstaller-6.16.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a", size = 1027899 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d0/f18fedde32835d5a758f464c75924e2154065625f09d5456c3c303527654/pyinstaller-6.16.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0", size = 727990 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/db/c8bb47514ce857b24bf9294cf1ff74844b6a489fa0ab4ef6f923288c4e38/pyinstaller-6.16.0-py3-none-manylinux2014_i686.whl", hash = "sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058", size = 739238 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/3e/451dc784a8fcca0fe9f9b6b802d58555364a95b60f253613a2c83fc6b023/pyinstaller-6.16.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851", size = 737142 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/37/2f457479ef8fa2821cdb448acee2421dfb19fbe908bf5499d1930c164084/pyinstaller-6.16.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3", size = 734133 },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c4/0f7daac4d062a4d1ac2571d8a8b9b5d6812094fcd914d139af591ca5e1ba/pyinstaller-6.16.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0", size = 733817 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/e4/b6127265b42bef883e8873d850becadf748bc5652e5a7029b059328f3c31/pyinstaller-6.16.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454", size = 732912 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/00/c6663107bdf814b2916e71563beabd09f693c47712213bc228994cb2cc65/pyinstaller-6.16.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5", size = 732773 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/14/cabe9bc5f60b95d2e70e7d045ab94b0015ff8f6c8b16e2142d3597e30749/pyinstaller-6.16.0-py3-none-win32.whl", hash = "sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8", size = 1313878 },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/99/2005efbc297e7813c1d6f18484aa94a1a81ce87b6a5b497c563681f4c4ea/pyinstaller-6.16.0-py3-none-win_amd64.whl", hash = "sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41", size = 1374706 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/f4/4dfcf69b86d60fcaae05a42bbff1616d48a91e71726e5ed795d773dae9b3/pyinstaller-6.16.0-py3-none-win_arm64.whl", hash = "sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64", size = 1315923 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -782,6 +876,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pysocks"
|
||||
version = "1.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pysubs2"
|
||||
version = "1.8.0"
|
||||
|
|
@ -920,38 +1023,66 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2025.9.1"
|
||||
version = "2025.9.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/5a/4c63457fbcaf19d138d72b2e9b39405954f98c0349b31c601bfcb151582c/regex-2025.9.1.tar.gz", hash = "sha256:88ac07b38d20b54d79e704e38aa3bd2c0f8027432164226bdee201a1c0c9c9ff", size = 400852 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/d3/eaa0d28aba6ad1827ad1e716d9a93e1ba963ada61887498297d3da715133/regex-2025.9.18.tar.gz", hash = "sha256:c5ba23274c61c6fef447ba6a39333297d0c247f53059dba0bca415cac511edc4", size = 400917 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/25/b2959ce90c6138c5142fe5264ee1f9b71a0c502ca4c7959302a749407c79/regex-2025.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bc6834727d1b98d710a63e6c823edf6ffbf5792eba35d3fa119531349d4142ef", size = 485932 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2e/6507a2a85f3f2be6643438b7bd976e67ad73223692d6988eb1ff444106d3/regex-2025.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c3dc05b6d579875719bccc5f3037b4dc80433d64e94681a0061845bd8863c025", size = 289568 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d8/de4a4b57215d99868f1640e062a7907e185ec7476b4b689e2345487c1ff4/regex-2025.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22213527df4c985ec4a729b055a8306272d41d2f45908d7bacb79be0fa7a75ad", size = 286984 },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/15/e8cb403403a57ed316e80661db0e54d7aa2efcd85cb6156f33cc18746922/regex-2025.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3f6e3c5a5a1adc3f7ea1b5aec89abfc2f4fbfba55dafb4343cd1d084f715b2", size = 797514 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/26/2446f2b9585fed61faaa7e2bbce3aca7dd8df6554c32addee4c4caecf24a/regex-2025.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcb89c02a0d6c2bec9b0bb2d8c78782699afe8434493bfa6b4021cc51503f249", size = 862586 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/b8/82ffbe9c0992c31bbe6ae1c4b4e21269a5df2559102b90543c9b56724c3c/regex-2025.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0e2f95413eb0c651cd1516a670036315b91b71767af83bc8525350d4375ccba", size = 910815 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/d8/7303ea38911759c1ee30cc5bc623ee85d3196b733c51fd6703c34290a8d9/regex-2025.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a41dc039e1c97d3c2ed3e26523f748e58c4de3ea7a31f95e1cf9ff973fff5a", size = 802042 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/0e/6ad51a55ed4b5af512bb3299a05d33309bda1c1d1e1808fa869a0bed31bc/regex-2025.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f0b4258b161094f66857a26ee938d3fe7b8a5063861e44571215c44fbf0e5df", size = 786764 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/d5/394e3ffae6baa5a9217bbd14d96e0e5da47bb069d0dbb8278e2681a2b938/regex-2025.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bf70e18ac390e6977ea7e56f921768002cb0fa359c4199606c7219854ae332e0", size = 856557 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/80/b288d3910c41194ad081b9fb4b371b76b0bbfdce93e7709fc98df27b37dc/regex-2025.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b84036511e1d2bb0a4ff1aec26951caa2dea8772b223c9e8a19ed8885b32dbac", size = 849108 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/cd/5ec76bf626d0d5abdc277b7a1734696f5f3d14fbb4a3e2540665bc305d85/regex-2025.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c2e05dcdfe224047f2a59e70408274c325d019aad96227ab959403ba7d58d2d7", size = 788201 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/36/674672f3fdead107565a2499f3007788b878188acec6d42bc141c5366c2c/regex-2025.9.1-cp313-cp313-win32.whl", hash = "sha256:3b9a62107a7441b81ca98261808fed30ae36ba06c8b7ee435308806bd53c1ed8", size = 264508 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ad/931134539515eb64ce36c24457a98b83c1b2e2d45adf3254b94df3735a76/regex-2025.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:b38afecc10c177eb34cfae68d669d5161880849ba70c05cbfbe409f08cc939d7", size = 275469 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/8c/96d34e61c0e4e9248836bf86d69cb224fd222f270fa9045b24e218b65604/regex-2025.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:ec329890ad5e7ed9fc292858554d28d58d56bf62cf964faf0aa57964b21155a0", size = 268586 },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/b1/453cbea5323b049181ec6344a803777914074b9726c9c5dc76749966d12d/regex-2025.9.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:72fb7a016467d364546f22b5ae86c45680a4e0de6b2a6f67441d22172ff641f1", size = 486111 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/0e/92577f197bd2f7652c5e2857f399936c1876978474ecc5b068c6d8a79c86/regex-2025.9.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c9527fa74eba53f98ad86be2ba003b3ebe97e94b6eb2b916b31b5f055622ef03", size = 289520 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/c6/b472398116cca7ea5a6c4d5ccd0fc543f7fd2492cb0c48d2852a11972f73/regex-2025.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c905d925d194c83a63f92422af7544ec188301451b292c8b487f0543726107ca", size = 287215 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/11/f12ecb0cf9ca792a32bb92f758589a84149017467a544f2f6bfb45c0356d/regex-2025.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74df7c74a63adcad314426b1f4ea6054a5ab25d05b0244f0c07ff9ce640fa597", size = 797855 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/88/bbb848f719a540fb5997e71310f16f0b33a92c5d4b4d72d4311487fff2a3/regex-2025.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f6e935e98ea48c7a2e8be44494de337b57a204470e7f9c9c42f912c414cd6f5", size = 863363 },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/a9/2321eb3e2838f575a78d48e03c1e83ea61bd08b74b7ebbdeca8abc50fc25/regex-2025.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4a62d033cd9ebefc7c5e466731a508dfabee827d80b13f455de68a50d3c2543d", size = 910202 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/07/d1d70835d7d11b7e126181f316f7213c4572ecf5c5c97bdbb969fb1f38a2/regex-2025.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef971ebf2b93bdc88d8337238be4dfb851cc97ed6808eb04870ef67589415171", size = 801808 },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d1/29e4d1bed514ef2bf3a4ead3cb8bb88ca8af94130239a4e68aa765c35b1c/regex-2025.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d936a1db208bdca0eca1f2bb2c1ba1d8370b226785c1e6db76e32a228ffd0ad5", size = 786824 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/27/20d8ccb1bee460faaa851e6e7cc4cfe852a42b70caa1dca22721ba19f02f/regex-2025.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7e786d9e4469698fc63815b8de08a89165a0aa851720eb99f5e0ea9d51dd2b6a", size = 857406 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/fe/60c6132262dc36430d51e0c46c49927d113d3a38c1aba6a26c7744c84cf3/regex-2025.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6b81d7dbc5466ad2c57ce3a0ddb717858fe1a29535c8866f8514d785fdb9fc5b", size = 848593 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/ae/2d4ff915622fabbef1af28387bf71e7f2f4944a348b8460d061e85e29bf0/regex-2025.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4890e184a6feb0ef195338a6ce68906a8903a0f2eb7e0ab727dbc0a3156273", size = 787951 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/37/dc127703a9e715a284cc2f7dbdd8a9776fd813c85c126eddbcbdd1ca5fec/regex-2025.9.1-cp314-cp314-win32.whl", hash = "sha256:34679a86230e46164c9e0396b56cab13c0505972343880b9e705083cc5b8ec86", size = 269833 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bf/4bed4d3d0570e16771defd5f8f15f7ea2311edcbe91077436d6908956c4a/regex-2025.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:a1196e530a6bfa5f4bde029ac5b0295a6ecfaaffbfffede4bbaf4061d9455b70", size = 278742 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/3e/7d7ac6fd085023312421e0d69dfabdfb28e116e513fadbe9afe710c01893/regex-2025.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:f46d525934871ea772930e997d577d48c6983e50f206ff7b66d4ac5f8941e993", size = 271860 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/c7/5c48206a60ce33711cf7dcaeaed10dd737733a3569dc7e1dce324dd48f30/regex-2025.9.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a40f929cd907c7e8ac7566ac76225a77701a6221bca937bdb70d56cb61f57b2", size = 485955 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/be/74fc6bb19a3c491ec1ace943e622b5a8539068771e8705e469b2da2306a7/regex-2025.9.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c90471671c2cdf914e58b6af62420ea9ecd06d1554d7474d50133ff26ae88feb", size = 289583 },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c4/9ceaa433cb5dc515765560f22a19578b95b92ff12526e5a259321c4fc1a0/regex-2025.9.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a351aff9e07a2dabb5022ead6380cff17a4f10e4feb15f9100ee56c4d6d06af", size = 287000 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e6/68bc9393cb4dc68018456568c048ac035854b042bc7c33cb9b99b0680afa/regex-2025.9.18-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc4b8e9d16e20ddfe16430c23468a8707ccad3365b06d4536142e71823f3ca29", size = 797535 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1c/ebae9032d34b78ecfe9bd4b5e6575b55351dc8513485bb92326613732b8c/regex-2025.9.18-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b8cdbddf2db1c5e80338ba2daa3cfa3dec73a46fff2a7dda087c8efbf12d62f", size = 862603 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/74/12332c54b3882557a4bcd2b99f8be581f5c6a43cf1660a85b460dd8ff468/regex-2025.9.18-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a276937d9d75085b2c91fb48244349c6954f05ee97bba0963ce24a9d915b8b68", size = 910829 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/70/ba42d5ed606ee275f2465bfc0e2208755b06cdabd0f4c7c4b614d51b57ab/regex-2025.9.18-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92a8e375ccdc1256401c90e9dc02b8642894443d549ff5e25e36d7cf8a80c783", size = 802059 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/c5/fcb017e56396a7f2f8357412638d7e2963440b131a3ca549be25774b3641/regex-2025.9.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0dc6893b1f502d73037cf807a321cdc9be29ef3d6219f7970f842475873712ac", size = 786781 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ee/21c4278b973f630adfb3bcb23d09d83625f3ab1ca6e40ebdffe69901c7a1/regex-2025.9.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a61e85bfc63d232ac14b015af1261f826260c8deb19401c0597dbb87a864361e", size = 856578 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/0b/de51550dc7274324435c8f1539373ac63019b0525ad720132866fff4a16a/regex-2025.9.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ef86a9ebc53f379d921fb9a7e42b92059ad3ee800fcd9e0fe6181090e9f6c23", size = 849119 },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/52/383d3044fc5154d9ffe4321696ee5b2ee4833a28c29b137c22c33f41885b/regex-2025.9.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d3bc882119764ba3a119fbf2bd4f1b47bc56c1da5d42df4ed54ae1e8e66fdf8f", size = 788219 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/bd/2614fc302671b7359972ea212f0e3a92df4414aaeacab054a8ce80a86073/regex-2025.9.18-cp313-cp313-win32.whl", hash = "sha256:3810a65675845c3bdfa58c3c7d88624356dd6ee2fc186628295e0969005f928d", size = 264517 },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/0f/ab5c1581e6563a7bffdc1974fb2d25f05689b88e2d416525271f232b1946/regex-2025.9.18-cp313-cp313-win_amd64.whl", hash = "sha256:16eaf74b3c4180ede88f620f299e474913ab6924d5c4b89b3833bc2345d83b3d", size = 275481 },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/22/ee47672bc7958f8c5667a587c2600a4fba8b6bab6e86bd6d3e2b5f7cac42/regex-2025.9.18-cp313-cp313-win_arm64.whl", hash = "sha256:4dc98ba7dd66bd1261927a9f49bd5ee2bcb3660f7962f1ec02617280fc00f5eb", size = 268598 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/83/6887e16a187c6226cb85d8301e47d3b73ecc4505a3a13d8da2096b44fd76/regex-2025.9.18-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fe5d50572bc885a0a799410a717c42b1a6b50e2f45872e2b40f4f288f9bce8a2", size = 489765 },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/c5/e2f7325301ea2916ff301c8d963ba66b1b2c1b06694191df80a9c4fea5d0/regex-2025.9.18-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b9d9a2d6cda6621551ca8cf7a06f103adf72831153f3c0d982386110870c4d3", size = 291228 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/60/7d229d2bc6961289e864a3a3cfebf7d0d250e2e65323a8952cbb7e22d824/regex-2025.9.18-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:13202e4c4ac0ef9a317fff817674b293c8f7e8c68d3190377d8d8b749f566e12", size = 289270 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/b4f06868ee2958ff6430df89857fbf3d43014bbf35538b6ec96c2704e15d/regex-2025.9.18-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874ff523b0fecffb090f80ae53dc93538f8db954c8bb5505f05b7787ab3402a0", size = 806326 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e4/bca99034a8f1b9b62ccf337402a8e5b959dd5ba0e5e5b2ead70273df3277/regex-2025.9.18-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d13ab0490128f2bb45d596f754148cd750411afc97e813e4b3a61cf278a23bb6", size = 871556 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/df/e06ffaf078a162f6dd6b101a5ea9b44696dca860a48136b3ae4a9caf25e2/regex-2025.9.18-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:05440bc172bc4b4b37fb9667e796597419404dbba62e171e1f826d7d2a9ebcef", size = 913817 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/05/25b05480b63292fd8e84800b1648e160ca778127b8d2367a0a258fa2e225/regex-2025.9.18-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5514b8e4031fdfaa3d27e92c75719cbe7f379e28cacd939807289bce76d0e35a", size = 811055 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/97/7bc7574655eb651ba3a916ed4b1be6798ae97af30104f655d8efd0cab24b/regex-2025.9.18-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:65d3c38c39efce73e0d9dc019697b39903ba25b1ad45ebbd730d2cf32741f40d", size = 794534 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c2/d5da49166a52dda879855ecdba0117f073583db2b39bb47ce9a3378a8e9e/regex-2025.9.18-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ae77e447ebc144d5a26d50055c6ddba1d6ad4a865a560ec7200b8b06bc529368", size = 866684 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2d/0a5c4e6ec417de56b89ff4418ecc72f7e3feca806824c75ad0bbdae0516b/regex-2025.9.18-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e3ef8cf53dc8df49d7e28a356cf824e3623764e9833348b655cfed4524ab8a90", size = 853282 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/8e/d656af63e31a86572ec829665d6fa06eae7e144771e0330650a8bb865635/regex-2025.9.18-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9feb29817df349c976da9a0debf775c5c33fc1c8ad7b9f025825da99374770b7", size = 797830 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ce/06edc89df8f7b83ffd321b6071be4c54dc7332c0f77860edc40ce57d757b/regex-2025.9.18-cp313-cp313t-win32.whl", hash = "sha256:168be0d2f9b9d13076940b1ed774f98595b4e3c7fc54584bba81b3cc4181742e", size = 267281 },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/9a/2b5d9c8b307a451fd17068719d971d3634ca29864b89ed5c18e499446d4a/regex-2025.9.18-cp313-cp313t-win_amd64.whl", hash = "sha256:d59ecf3bb549e491c8104fea7313f3563c7b048e01287db0a90485734a70a730", size = 278724 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/70/177d31e8089a278a764f8ec9a3faac8d14a312d622a47385d4b43905806f/regex-2025.9.18-cp313-cp313t-win_arm64.whl", hash = "sha256:dbef80defe9fb21310948a2595420b36c6d641d9bea4c991175829b2cc4bc06a", size = 269771 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b7/3b4663aa3b4af16819f2ab6a78c4111c7e9b066725d8107753c2257448a5/regex-2025.9.18-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c6db75b51acf277997f3adcd0ad89045d856190d13359f15ab5dda21581d9129", size = 486130 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/5b/4533f5d7ac9c6a02a4725fe8883de2aebc713e67e842c04cf02626afb747/regex-2025.9.18-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8f9698b6f6895d6db810e0bda5364f9ceb9e5b11328700a90cae573574f61eea", size = 289539 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8d/5ab6797c2750985f79e9995fad3254caa4520846580f266ae3b56d1cae58/regex-2025.9.18-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29cd86aa7cb13a37d0f0d7c21d8d949fe402ffa0ea697e635afedd97ab4b69f1", size = 287233 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/95afcb02ba8d3a64e6ffeb801718ce73471ad6440c55d993f65a4a5e7a92/regex-2025.9.18-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c9f285a071ee55cd9583ba24dde006e53e17780bb309baa8e4289cd472bcc47", size = 797876 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/fb/720b1f49cec1f3b5a9fea5b34cd22b88b5ebccc8c1b5de9cc6f65eed165a/regex-2025.9.18-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5adf266f730431e3be9021d3e5b8d5ee65e563fec2883ea8093944d21863b379", size = 863385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ca/e0d07ecf701e1616f015a720dc13b84c582024cbfbb3fc5394ae204adbd7/regex-2025.9.18-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1137cabc0f38807de79e28d3f6e3e3f2cc8cfb26bead754d02e6d1de5f679203", size = 910220 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/45/bba86413b910b708eca705a5af62163d5d396d5f647ed9485580c7025209/regex-2025.9.18-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cc9e5525cada99699ca9223cce2d52e88c52a3d2a0e842bd53de5497c604164", size = 801827 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/740fbd9fcac31a1305a8eed30b44bf0f7f1e042342be0a4722c0365ecfca/regex-2025.9.18-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bbb9246568f72dce29bcd433517c2be22c7791784b223a810225af3b50d1aafb", size = 786843 },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a7/0579e8560682645906da640c9055506465d809cb0f5415d9976f417209a6/regex-2025.9.18-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6a52219a93dd3d92c675383efff6ae18c982e2d7651c792b1e6d121055808743", size = 857430 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/9b/4dc96b6c17b38900cc9fee254fc9271d0dde044e82c78c0811b58754fde5/regex-2025.9.18-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ae9b3840c5bd456780e3ddf2f737ab55a79b790f6409182012718a35c6d43282", size = 848612 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/6a/6f659f99bebb1775e5ac81a3fb837b85897c1a4ef5acffd0ff8ffe7e67fb/regex-2025.9.18-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d488c236ac497c46a5ac2005a952c1a0e22a07be9f10c3e735bc7d1209a34773", size = 787967 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/35/9e35665f097c07cf384a6b90a1ac11b0b1693084a0b7a675b06f760496c6/regex-2025.9.18-cp314-cp314-win32.whl", hash = "sha256:0c3506682ea19beefe627a38872d8da65cc01ffa25ed3f2e422dffa1474f0788", size = 269847 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/64/27594dbe0f1590b82de2821ebfe9a359b44dcb9b65524876cd12fabc447b/regex-2025.9.18-cp314-cp314-win_amd64.whl", hash = "sha256:57929d0f92bebb2d1a83af372cd0ffba2263f13f376e19b1e4fa32aec4efddc3", size = 278755 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a3/0cd8d0d342886bd7d7f252d701b20ae1a3c72dc7f34ef4b2d17790280a09/regex-2025.9.18-cp314-cp314-win_arm64.whl", hash = "sha256:6a4b44df31d34fa51aa5c995d3aa3c999cec4d69b9bd414a8be51984d859f06d", size = 271873 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/cb/8a1ab05ecf404e18b54348e293d9b7a60ec2bd7aa59e637020c5eea852e8/regex-2025.9.18-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b176326bcd544b5e9b17d6943f807697c0cb7351f6cfb45bf5637c95ff7e6306", size = 489773 },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/3b/6543c9b7f7e734d2404fa2863d0d710c907bef99d4598760ed4563d634c3/regex-2025.9.18-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0ffd9e230b826b15b369391bec167baed57c7ce39efc35835448618860995946", size = 291221 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/91/e9fdee6ad6bf708d98c5d17fded423dcb0661795a49cba1b4ffb8358377a/regex-2025.9.18-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec46332c41add73f2b57e2f5b642f991f6b15e50e9f86285e08ffe3a512ac39f", size = 289268 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a6/bc3e8a918abe4741dadeaeb6c508e3a4ea847ff36030d820d89858f96a6c/regex-2025.9.18-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b80fa342ed1ea095168a3f116637bd1030d39c9ff38dc04e54ef7c521e01fc95", size = 806659 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/71/ea62dbeb55d9e6905c7b5a49f75615ea1373afcad95830047e4e310db979/regex-2025.9.18-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4d97071c0ba40f0cf2a93ed76e660654c399a0a04ab7d85472239460f3da84b", size = 871701 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/90/fbe9dedb7dad24a3a4399c0bae64bfa932ec8922a0a9acf7bc88db30b161/regex-2025.9.18-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0ac936537ad87cef9e0e66c5144484206c1354224ee811ab1519a32373e411f3", size = 913742 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/1c/47e4a8c0e73d41eb9eb9fdeba3b1b810110a5139a2526e82fd29c2d9f867/regex-2025.9.18-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dec57f96d4def58c422d212d414efe28218d58537b5445cf0c33afb1b4768571", size = 811117 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/da/435f29fddfd015111523671e36d30af3342e8136a889159b05c1d9110480/regex-2025.9.18-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48317233294648bf7cd068857f248e3a57222259a5304d32c7552e2284a1b2ad", size = 794647 },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/66/df5e6dcca25c8bc57ce404eebc7342310a0d218db739d7882c9a2b5974a3/regex-2025.9.18-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:274687e62ea3cf54846a9b25fc48a04459de50af30a7bd0b61a9e38015983494", size = 866747 },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/42/94392b39b531f2e469b2daa40acf454863733b674481fda17462a5ffadac/regex-2025.9.18-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a78722c86a3e7e6aadf9579e3b0ad78d955f2d1f1a8ca4f67d7ca258e8719d4b", size = 853434 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/f8/dcc64c7f7bbe58842a8f89622b50c58c3598fbbf4aad0a488d6df2c699f1/regex-2025.9.18-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06104cd203cdef3ade989a1c45b6215bf42f8b9dd705ecc220c173233f7cba41", size = 798024 },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/8d/edf1c5d5aa98f99a692313db813ec487732946784f8f93145e0153d910e5/regex-2025.9.18-cp314-cp314t-win32.whl", hash = "sha256:2e1eddc06eeaffd249c0adb6fafc19e2118e6308c60df9db27919e96b5656096", size = 273029 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/24/02d4e4f88466f17b145f7ea2b2c11af3a942db6222429c2c146accf16054/regex-2025.9.18-cp314-cp314t-win_amd64.whl", hash = "sha256:8620d247fb8c0683ade51217b459cb4a1081c0405a3072235ba43a40d355c09a", size = 282680 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a3/c64894858aaaa454caa7cc47e2f225b04d3ed08ad649eacf58d45817fad2/regex-2025.9.18-cp314-cp314t-win_arm64.whl", hash = "sha256:b7531a8ef61de2c647cdf68b3229b071e46ec326b3138b2180acb4275f470b01", size = 273034 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -984,28 +1115,45 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631 },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751 },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613 },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554 },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178 },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267 },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448 },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selenium"
|
||||
version = "4.35.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "trio" },
|
||||
{ name = "trio-websocket" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "urllib3", extra = ["socks"] },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/75/67/9016942b5781843cfea6f5bc1383cea852d9fa08f85f55a0547874525b5c/selenium-4.35.0.tar.gz", hash = "sha256:83937a538afb40ef01e384c1405c0863fa184c26c759d34a1ebbe7b925d3481c", size = 907991 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ef/d0e033e1b3f19a0325ce03863b68d709780908381135fc0f9436dea76a7b/selenium-4.35.0-py3-none-any.whl", hash = "sha256:90bb6c6091fa55805785cf1660fa1e2176220475ccdb466190f654ef8eef6114", size = 9602106 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1048,12 +1196,52 @@ wheels = [
|
|||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
name = "sortedcontainers"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trio"
|
||||
version = "0.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" },
|
||||
{ name = "idna" },
|
||||
{ name = "outcome" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/c1/68d582b4d3a1c1f8118e18042464bb12a7c1b75d64d75111b297687041e3/trio-0.30.0.tar.gz", hash = "sha256:0781c857c0c81f8f51e0089929a26b5bb63d57f927728a5586f7e36171f064df", size = 593776 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/8e/3f6dfda475ecd940e786defe6df6c500734e686c9cd0a0f8ef6821e9b2f2/trio-0.30.0-py3-none-any.whl", hash = "sha256:3bf4f06b8decf8d3cf00af85f40a89824669e2d033bb32469d34840edcfc22a5", size = 499194 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trio-websocket"
|
||||
version = "0.12.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "outcome" },
|
||||
{ name = "trio" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1086,6 +1274,29 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
socks = [
|
||||
{ name = "pysocks" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "w3lib"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/7d/1172cfaa1e29beb9bf938e484c122b3bdc82e8e37b17a4f753ba6d6e009f/w3lib-2.3.1.tar.gz", hash = "sha256:5c8ac02a3027576174c2b61eb9a2170ba1b197cae767080771b6f1febda249a4", size = 49531 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/dd/56f0d8af71e475ed194d702f8b4cf9cea812c95e82ad823d239023c6558c/w3lib-2.3.1-py3-none-any.whl", hash = "sha256:9ccd2ae10c8c41c7279cd8ad4fe65f834be894fe7bfdd7304b991fd69325847b", size = 21751 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wsproto"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1176,8 +1387,10 @@ dependencies = [
|
|||
{ name = "defusedxml" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-curl-cffi" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "multidict" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "parsel" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pyjson5" },
|
||||
|
|
@ -1187,6 +1400,7 @@ dependencies = [
|
|||
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
|
||||
{ name = "python-socketio" },
|
||||
{ name = "regex" },
|
||||
{ name = "selenium" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "yt-dlp" },
|
||||
{ name = "zipstream-ng" },
|
||||
|
|
@ -1223,8 +1437,10 @@ requires-dist = [
|
|||
{ name = "defusedxml", specifier = ">=0.7.1" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
|
||||
{ name = "jmespath", specifier = ">=1.0.1" },
|
||||
{ name = "multidict", specifier = "==6.5.1" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "parsel", specifier = ">=1.10.0" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pycryptodome", specifier = ">=3.23.0" },
|
||||
{ name = "pyinstaller", marker = "extra == 'installer'" },
|
||||
|
|
@ -1235,6 +1451,7 @@ requires-dist = [
|
|||
{ name = "python-magic-bin", marker = "sys_platform == 'win32'" },
|
||||
{ name = "python-socketio", specifier = ">=5.11.1" },
|
||||
{ name = "regex" },
|
||||
{ name = "selenium", specifier = ">=4.35.0" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "yt-dlp" },
|
||||
{ name = "zipstream-ng", specifier = ">=1.8.0" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue