Merge pull request #397 from arabcoders/dev

Major refactor
This commit is contained in:
Abdulmohsen 2025-08-30 02:29:02 +03:00 committed by GitHub
commit aaab5a72e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2029 additions and 1499 deletions

View file

@ -112,6 +112,7 @@
"timespec",
"tmpfilename",
"ungroup",
"unmark",
"unnegated",
"unpickleable",
"Unraid",

286
API.md
View file

@ -25,8 +25,11 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/history/{id}](#post-apihistoryid)
- [GET /api/history/{id}](#get-apihistoryid)
- [GET /api/history](#get-apihistory)
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [GET /api/tasks](#get-apitasks)
- [PUT /api/tasks](#put-apitasks)
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
@ -35,16 +38,26 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
- [GET /api/yt-dlp/archive/recheck](#get-apiyt-dlparchiverecheck)
- [POST /api/file/action/{path:.\*}](#post-apifileactionpath)
- [POST /api/file/download](#post-apifiledownload)
- [GET /api/file/download/{token}](#get-apifiledownloadtoken)
- [GET /api/random/background](#get-apirandombackground)
- [GET /api/presets](#get-apipresets)
- [GET /api/dl\_fields](#get-apidl_fields)
- [PUT /api/dl\_fields](#put-apidl_fields)
- [PUT /api/presets](#put-apipresets)
- [GET /api/conditions](#get-apiconditions)
- [PUT /api/conditions](#put-apiconditions)
- [POST /api/conditions/test](#post-apiconditionstest)
- [GET /api/logs](#get-apilogs)
- [GET /api/notifications](#get-apinotifications)
- [PUT /api/notifications](#put-apinotifications)
- [POST /api/yt-dlp/archive\_id/](#post-apiyt-dlparchive_id)
- [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [POST /api/system/shutdown](#post-apisystemshutdown)
- [GET /api/dev/loop](#get-apidevloop)
- [GET /api/dev/pip](#get-apidevpip)
- [Error Responses](#error-responses)
---
@ -348,6 +361,46 @@ or an error:
---
### DELETE /api/history/{id}/archive
**Purpose**: Remove an item from archive file, allowing it to be re-downloaded.
**Path Parameter**:
- `id`: Item ID from the history.
**Response**:
```json
{ "message": "item '<title>' removed from '<archive_file>' archive." }
```
or an error:
```json
{ "error": "text" }
```
- `400 Bad Request` if archive is not configured or parameters are invalid.
- `404 Not Found` if the item or archive entry is not found.
---
### POST /api/history/{id}/archive
**Purpose**: Add item to the archive file preventing it from being downloaded.
**Path Parameter**:
- `id`: Item ID from the history.
**Response**:
```json
{ "message": "item '<archive_id>' archived in file '<archive_file>'." }
```
or an error:
```json
{ "error": "text" }
```
- `404 Not Found` if the item or archive file does not exist.
- `409 Conflict` if the item is already archived.
---
### GET /api/tasks
**Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager.
@ -420,6 +473,26 @@ or on error
---
### POST /api/tasks/{id}/mark
**Purpose**: Mark all entries associated with a scheduled task as downloaded.
**Path Parameter**:
- `id`: Task ID.
**Response**:
```json
{ "message": "..." }
```
or
```json
{ "error": "..." }
```
- `400 Bad Request` if id is missing or invalid.
- `404 Not Found` if the task does not exist.
---
### GET /api/player/playlist/{file:.*}.m3u8
**Purpose**: Generate a playlist for a given local media file.
@ -577,22 +650,60 @@ Binary image data with the appropriate `Content-Type`.
---
### GET /api/yt-dlp/archive/recheck
**Purpose**: Recheck manual archive entries to see if become available or not.
### POST /api/file/action/{path:.*}
**Purpose**: Perform a file browser action on a file or directory.
**Path Parameter**:
- `path`: Base path (relative to `download_path`) to operate under. Use `/` for root.
**Body**:
```json
{ "action": "rename|delete|move|directory", ... }
```
Actions and required fields:
- `rename`: `{ "new_name": "<name>" }`
- `delete`: no extra fields
- `move`: `{ "new_path": "<dir-relative-to-download_path>" }`
- `directory`: `{ "new_dir": "<subdir/to/create>" }`
**Response**: `200 OK` with empty body.
or an error:
```json
{ "error": "text" }
```
- `403 Forbidden` if browser or actions are disabled.
- `400/404` for invalid paths or parameters.
---
### POST /api/file/download
**Purpose**: Prepare a ZIP download of selected files (and detected sidecars). Returns a short-lived token.
**Body**:
```json
[ "relative/path/file1.ext", "relative/path/file2.ext" ]
```
**Response**:
```json
[
{
"id": "youtube_video_id",
"url": "https://youtube.com/watch?v=...",
"status": "available|unavailable|error",
"info": { ... } // video info if available
},
...
]
{ "token": "<uuid>", "files": ["relative/path/file1.ext", "..."] }
```
- Returns `404 Not Found` if manual archive is not enabled or file doesn't exist.
- `400 Bad Request` if the body is not a JSON array or contains no valid files.
---
### GET /api/file/download/{token}
**Purpose**: Stream a ZIP file for the previously prepared download token.
**Path Parameter**:
- `token`: Token returned by POST `/api/file/download`.
**Response**:
- `200 OK` streaming response with `Content-Type: application/zip` and `Content-Disposition: attachment`.
- JSON error with `400 Bad Request` if the token is invalid/expired or no files available.
---
@ -633,6 +744,47 @@ Binary image data with appropriate `Content-Type` header.
---
### GET /api/dl_fields
**Purpose**: Retrieve the list of configured download fields.
**Query Parameters (optional)**:
- `filter`: Comma-separated list of field names to include in each object.
**Response**:
```json
[
{ "id": "<uuid>", "name": "...", ... },
...
]
```
---
### PUT /api/dl_fields
**Purpose**: Save the list of download fields. Replaces existing entries.
**Body**: Array of objects. Required per-item fields: `name`. `id` is auto-generated if missing or invalid.
```json
[
{ "name": "...", "id": "<uuid>", ... },
{ "name": "..." }
]
```
**Response**:
```json
[
{ "id": "<uuid>", "name": "...", ... },
...
]
```
or an error:
```json
{ "error": "text" }
```
---
### PUT /api/presets
**Purpose**: Save/update download presets.
@ -716,6 +868,27 @@ Binary image data with appropriate `Content-Type` header.
---
### POST /api/conditions/test
**Purpose**: Evaluate a condition expression against info extracted from a URL.
**Body**:
```json
{ "url": "https://...", "condition": "yt:duration > 600", "preset": "<optional-preset>" }
```
**Response**:
```json
{
"status": true,
"condition": "...",
"data": { ... } // sanitized, possibly large
}
```
- `400 Bad Request` for invalid body, missing fields, or extractor failures.
---
### GET /api/logs
**Purpose**: Retrieve recent application logs (if file logging is enabled).
@ -824,6 +997,41 @@ Binary image data with appropriate `Content-Type` header.
"allowedTypes": ["added", "completed", "error", "cancelled", "cleared", "log_info", "log_success", ...]
}
```
---
### POST /api/yt-dlp/archive_id/
**Purpose**: Get the archive ID for a given URLs.
**Body**: Array of URLs.
```json
[
"https://youtube.com/...",
"https://..."
}
```
**Response**:
```json
[
{
"index": "index_of_the_url_in_request_array",
"url": "the_url",
"id": "the_video_id_or_null_if_not_found",
"ie_key": "the_extractor_key_or_null_if_not_found",
"archive_id": "the_archive_id_or_null_if_not_found",
"error": "error_message_if_any_or_null"
},
...
]
```
or an error:
```json
{
"error": "text"
}
```
- If the body is not a valid JSON array, returns `400 Bad Request`.
---
@ -840,6 +1048,58 @@ Binary image data with appropriate `Content-Type` header.
---
### GET /api/yt-dlp/options
**Purpose**: Get the current yt-dlp CLI options as a JSON object.
**Response**:
```json
[
{
"description": "Description of the option",
"flags":[ "--option", "-o" ],
"group": "Option Group",
"ignored": false, // true if this option is ignored by ytptube.
},
...
]
```
---
### POST /api/system/shutdown
**Purpose**: Gracefully shut down the application (native mode only).
**Response**:
```json
{ "message": "The application shutting down." }
```
- `400 Bad Request` if not running in native mode.
---
### GET /api/dev/loop
**Purpose**: Development-only. Show event loop details and running tasks.
**Response**:
```json
{ "total_tasks": 1, "loop": "...", "tasks": [ { "task": "...", "stack": ["..."] } ] }
```
- `403 Forbidden` if not in development mode.
---
### GET /api/dev/pip
**Purpose**: Development-only. Return installed versions for configured pip packages.
**Response**:
```json
{ "package": "version-or-null", "...": null }
```
---
## Error Responses
Most endpoints return standard error codes (`400`, `403`, `404`, `500`, etc.) and a JSON body on failure. For example:

24
FAQ.md
View file

@ -18,7 +18,6 @@ or the `environment:` section in `compose.yaml` file.
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` |
@ -27,10 +26,8 @@ or the `environment:` section in `compose.yaml` file.
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
@ -197,27 +194,6 @@ you and also enable the fallback by using the follow extractor args
Use this alternative extractor args in case the extractor fails to get the pot tokens from the bgutil provider server.
For more information please visit [bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider) project.
# How to set global settings for yt-dlp?
You can create a file named `ytdlp.cli` in the `/config` folder, and add your desired global settings there.
> [!IMPORTANT]
> We strongly recommend to use presets instead of global settings, as presets are more convenient.
The `ytdlp.cli` file usage is deprecated and likely will be removed in `v1.0.0` release.
# Updating yt-dlp
The engine which powers the actual video downloads in YTPTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video
sites regularly updated, frequent updates of yt-dlp are required to keep up.
We have added the `YTP_YTDLP_AUTO_UPDATE` environment variable, which is enabled by default. This feature allows the
container to automatically update `yt-dlp` to the latest version whenever the container starts. If a new version is
available, it will be downloaded and applied automatically. To disable this automatic update, set the
`YTP_YTDLP_AUTO_UPDATE` environment variable to `false`.
We will no longer release new versions of YTPTube for every new version of yt-dlp.
# Troubleshooting and submitting issues
Before asking a question or submitting an issue for YTPTube, please remember that YTPTube is only a thin wrapper for

View file

@ -121,8 +121,8 @@ class DataStore:
return items
def put(self, value: Download) -> Download:
if "error" == value.info.status:
def put(self, value: Download, no_notify: bool = False) -> Download:
if "error" == value.info.status and not no_notify:
from app.library.Events import EventBus, Events
asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error")
@ -181,16 +181,18 @@ class DataStore:
except AttributeError:
pass
encoded: str = stored.json()
self._connection.execute(
sqlStatement.strip(),
(
stored._id,
str(type),
stored.url,
stored.json(),
encoded,
str(type),
stored.url,
stored.json(),
encoded,
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)

View file

@ -19,7 +19,6 @@ from .ffprobe import ffprobe
from .ItemDTO import ItemDTO
from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies
from .ytdlp import YTDLP
from .YTDLPOpts import YTDLPOpts
class Terminator:
@ -27,19 +26,19 @@ class Terminator:
class NestedLogger:
debug_messages = ["[debug] ", "[download] "]
debug_messages: list[str] = ["[debug] ", "[download] "]
def __init__(self, logger: logging.Logger):
self.logger = logger
def __init__(self, logger: logging.Logger) -> None:
self.logger: logging.Logger = logger
def debug(self, msg: str):
levelno = logging.DEBUG if any(msg.startswith(x) for x in self.debug_messages) else logging.INFO
def debug(self, msg: str) -> None:
levelno: int = logging.DEBUG if any(msg.startswith(x) for x in self.debug_messages) else logging.INFO
self.logger.log(level=levelno, msg=re.sub(r"^\[(debug|info)\] ", "", msg, flags=re.IGNORECASE))
def error(self, msg):
def error(self, msg) -> None:
self.logger.error(msg)
def warning(self, msg):
def warning(self, msg) -> None:
self.logger.warning(msg)
@ -110,7 +109,6 @@ class Download:
self.template = info.template
self.template_chapter = info.template_chapter
self.download_info_expires = int(config.download_info_expires)
self.preset = info.preset
self.info = info
self.id = info._id
self.debug = bool(config.debug)
@ -125,14 +123,14 @@ class Download:
self.temp_disabled = bool(config.temp_disabled)
self.is_live = bool(info.is_live) or info.live_in is not None
self.info_dict = info_dict
self.logger = logging.getLogger(f"Download.{info.id if info.id else info._id}")
self.logger: logging.Logger = logging.getLogger(f"Download.{info.id if info.id else info._id}")
self.started_time = 0
self.queue_time = datetime.now(tz=UTC)
self.queue_time: datetime = datetime.now(tz=UTC)
self.logs = logs if logs else []
def _progress_hook(self, data: dict):
if self.debug:
d_copy = deepcopy(data)
d_copy: dict = deepcopy(data)
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
d_copy["info_dict"].pop(k, None)
@ -148,7 +146,7 @@ class Download:
def _postprocessor_hook(self, data: dict):
if self.debug:
d_copy = deepcopy(data)
d_copy: dict = deepcopy(data)
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
d_copy["info_dict"].pop(k, None)
@ -181,10 +179,7 @@ class Download:
try:
params: dict = (
YTDLPOpts.get_instance()
.preset(self.preset)
.add({"break_on_existing": True})
.add_cli(args=self.info.cli, from_user=True)
self.info.get_ytdlp_opts()
.add(
config={
"color": "no_color",
@ -263,31 +258,30 @@ class Download:
self.info_dict = info
if self.is_live:
hasDeletedOptions = False
deletedOpts: list = []
for opt in self.bad_live_options:
if opt in params:
params.pop(opt, None)
hasDeletedOptions = True
deletedOpts.append(opt)
if hasDeletedOptions:
if len(deletedOpts) > 0:
self.logger.warning(
f"Live stream detected for '{self.info.title}', The following opts '{deletedOpts=}' have been deleted."
)
if isinstance(self.info_dict, dict) and len(self.info_dict.get("formats", [])) < 1:
msg = f"Failed to extract any formats for '{self.info.url}'."
msg: str = f"Failed to extract any formats for '{self.info.url}'."
if filtered_logs := extract_ytdlp_logs(self.logs):
msg += " " + ", ".join(filtered_logs)
raise ValueError(msg) # noqa: TRY301
self.logger.info(
f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" cookies="{bool(params.get("cookiefile"))}" started.'
f'Task {self.info.name()}, preset="{self.info.preset}", cookies="{bool(params.get("cookiefile"))}" started.'
)
self.logger.debug(f"Params before passing to yt-dlp. {params}")
if self.debug:
self.logger.debug(f"Params before passing to yt-dlp. {params}")
params["logger"] = NestedLogger(self.logger)
@ -337,7 +331,7 @@ class Download:
self.logger.error(f"Failed to delete cookie file: {cookie_file}. {e}")
self.logger.info(
f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
)
async def start(self):
@ -361,7 +355,9 @@ class Download:
ret = await asyncio.get_running_loop().run_in_executor(None, self.proc.join)
if self.final_update:
if self.final_update or self.cancelled:
if self.cancelled:
self.info.status = "cancelled"
return ret
self.status_queue.put(Terminator())
@ -373,9 +369,11 @@ class Download:
for i in range(drain_count):
try:
self.logger.debug(f"(50/{i}) Draining the status queue...")
if self.debug:
self.logger.debug(f"(50/{i}) Draining the status queue...")
if self.final_update:
self.logger.debug("(50/{i}) Draining stopped. Final update received.")
if self.debug:
self.logger.debug("(50/{i}) Draining stopped. Final update received.")
break
next_status = self.status_queue.get(timeout=0.1)
if next_status is None or isinstance(next_status, Terminator):
@ -499,12 +497,10 @@ class Download:
return
if str(tmp_dir) == str(self.temp_dir):
self.logger.warning(
f"Attempted to delete video temp folder '{self.temp_path}', but it is the same as main temp folder."
)
self.logger.warning(f"Refusing to delete video temp folder '{self.temp_path}' as it's temp root.")
return
status = delete_dir(tmp_dir)
status: bool = delete_dir(tmp_dir)
if by_pass:
tmp_dir.mkdir(parents=True, exist_ok=True)
self.logger.info(f"Temp folder '{self.temp_path}' emptied.")
@ -523,7 +519,7 @@ class Download:
await self._notify.emit(Events.ITEM_UPDATED, data=self.info)
return
self.tmpfilename = status.get("tmpfilename")
self.tmpfilename: str | None = status.get("tmpfilename")
fl = None
if "final_name" in status:
@ -560,7 +556,7 @@ class Download:
if "downloaded_bytes" in status and status.get("downloaded_bytes", 0) > 0:
self.info.downloaded_bytes = status.get("downloaded_bytes")
total = status.get("total_bytes") or status.get("total_bytes_estimate")
total: float | None = status.get("total_bytes") or status.get("total_bytes_estimate")
if total:
try:
self.info.percent = status["downloaded_bytes"] / total * 100
@ -617,7 +613,7 @@ class Download:
return False
if self.started_time < 1:
self.logger.debug(f"Download task '{self.info.title}: {self.info.id}' not started yet.")
self.logger.debug(f"Download task '{self.info.name()}' not started yet.")
return False
if int(time.time()) - self.started_time < 300:
@ -633,7 +629,7 @@ class Download:
return True
if self.info.status not in ["finished", "error", "cancelled", "downloading", "postprocessing"]:
status = self.info.status if self.info.status else "unknown"
status: str = self.info.status if self.info.status else "unknown"
self.logger.warning(
f"Download task '{self.info.title}: {self.info.id}' has been stuck in '{status}' state for '{int(time.time()) - self.started_time}' seconds."
)

View file

@ -29,17 +29,15 @@ from .Utils import (
dt_delta,
extract_info,
extract_ytdlp_logs,
is_downloaded,
load_cookies,
str_to_dt,
ytdlp_reject,
)
from .YTDLPOpts import YTDLPOpts
if TYPE_CHECKING:
from app.library.Presets import Preset
LOG = logging.getLogger("DownloadQueue")
LOG: logging.Logger = logging.getLogger("DownloadQueue")
class DownloadQueue(metaclass=Singleton):
@ -87,7 +85,7 @@ class DownloadQueue(metaclass=Singleton):
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
@staticmethod
def get_instance():
def get_instance() -> "DownloadQueue":
"""
Get the instance of the DownloadQueue.
@ -100,7 +98,7 @@ class DownloadQueue(metaclass=Singleton):
return DownloadQueue._instance
def attach(self, _: web.Application):
def attach(self, _: web.Application) -> None:
"""
Attach the download queue to the application.
@ -136,13 +134,11 @@ class DownloadQueue(metaclass=Singleton):
await self.done.test()
return True
async def initialize(self):
async def initialize(self) -> None:
"""
Initialize the download queue.
"""
LOG.info(
f"Using '{self.config.max_workers}' worker/s for downloading. Can be configured via `YTP_MAX_WORKERS` environment variable."
)
LOG.info(f"Using '{self.config.max_workers}' workers for downloading.")
asyncio.create_task(self._download_pool(), name="download_pool")
async def start_items(self, ids: list[str]) -> dict[str, str]:
@ -158,11 +154,11 @@ class DownloadQueue(metaclass=Singleton):
"""
status: dict[str, str] = {"status": "ok"}
started = False
tasks = []
tasks: list = []
for item_id in ids:
try:
item = self.queue.get(key=item_id)
item: Download = self.queue.get(key=item_id)
except KeyError as e:
status[item_id] = f"not found: {e!s}"
status["status"] = "error"
@ -175,7 +171,7 @@ class DownloadQueue(metaclass=Singleton):
continue
item.info.auto_start = True
updated = self.queue.put(item)
updated: Download = self.queue.put(item)
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
tasks.append(
self._notify.emit(
@ -209,11 +205,11 @@ class DownloadQueue(metaclass=Singleton):
"""
status: dict[str, str] = {"status": "ok"}
tasks = []
tasks: list = []
for item_id in ids:
try:
item = self.queue.get(key=item_id)
item: Download = self.queue.get(key=item_id)
except KeyError as e:
status[item_id] = f"not found: {e!s}"
status["status"] = "error"
@ -231,7 +227,7 @@ class DownloadQueue(metaclass=Singleton):
continue
item.info.auto_start = False
updated = self.queue.put(item)
updated: Download = self.queue.put(item)
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
tasks.append(
self._notify.emit(
@ -416,20 +412,20 @@ class DownloadQueue(metaclass=Singleton):
LOG.debug(f"Entry id '{entry.get('id')}' url '{entry.get('webpage_url')} - {entry.get('url')}'.")
try:
_item = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url"))
if _item is not None:
err_msg = f"Item '{_item.info.id}' - '{_item.info.title}' already exists. Removing from history."
LOG.warning(err_msg)
await self.clear([_item.info._id], remove_file=False)
_item: Download = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url"))
err_msg: str = f"Removing {_item.info.name()} from history list."
LOG.warning(err_msg)
await self.clear([_item.info._id], remove_file=False)
except KeyError:
pass
try:
_item = self.queue.get(key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url")))
if _item is not None:
err_msg = f"Item ID '{_item.info.id}' - '{_item.info.title}' already in download queue."
LOG.info(err_msg)
return {"status": "error", "msg": err_msg}
_item: Download = self.queue.get(
key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url"))
)
err_msg: str = f"Item {_item.info.name()} is already in download queue."
LOG.info(err_msg)
return {"status": "error", "msg": err_msg}
except KeyError:
pass
@ -494,7 +490,7 @@ class DownloadQueue(metaclass=Singleton):
nEvent = Events.ITEM_MOVED
nStore = "history"
nTitle = "Upcoming Premiere" if is_premiere else "Upcoming Live Stream"
nMessage = f"{'Premiere video' if is_premiere else 'Stream' } '{dlInfo.info.title}' is not available yet. {text_logs}"
nMessage = f"{'Premiere video' if is_premiere else 'Stream'} '{dlInfo.info.title}' is not available yet. {text_logs}"
dlInfo.info.status = "not_live"
dlInfo.info.msg = nMessage.replace(f" '{dlInfo.info.title}'", "")
@ -535,7 +531,7 @@ class DownloadQueue(metaclass=Singleton):
LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}")
dlInfo.info.error += f" Failed to parse live_in date '{release_in}'."
else:
dlInfo.info.error += f" Delaying download by '{300+dl.extras.get('duration',0)}' seconds."
dlInfo.info.error += f" Delaying download by '{300 + dl.extras.get('duration', 0)}' seconds."
nMessage = f"'{dlInfo.info.title}': '{dlInfo.info.error.strip()}'."
@ -602,10 +598,10 @@ class DownloadQueue(metaclass=Singleton):
if event_type.startswith("url"):
return await self.add(item=item.new_with(url=entry.get("url")), already=already)
if not event_type.startswith("video"):
return {"status": "error", "msg": f'Unsupported event type "{event_type}".'}
if event_type.startswith("video"):
return await self._add_video(entry=entry, item=item, logs=logs)
return await self._add_video(entry=entry, item=item, logs=logs)
return {"status": "error", "msg": f'Unsupported event type "{event_type}".'}
async def add(self, item: Item, already: set | None = None):
"""
@ -658,16 +654,15 @@ class DownloadQueue(metaclass=Singleton):
"level": logging.WARNING,
"name": "callback-logger",
},
**YTDLPOpts.get_instance().preset(name=item.preset).add_cli(args=item.cli, from_user=True).get_all(),
**item.get_ytdlp_opts().get_all(),
}
if yt_conf.get("external_downloader"):
LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.")
item.extras.update({"external_downloader": True})
downloaded, id_dict = self._is_downloaded(file=yt_conf.get("download_archive", None), url=item.url)
if downloaded is True and id_dict:
message = f"'{id_dict.get('id')}': The URL '{item.url}' is already downloaded and recorded in archive."
if item.is_archived():
message: str = f"The URL '{item.url}' is already downloaded and recorded in archive."
LOG.error(message)
await self._notify.emit(Events.LOG_INFO, title="Already Downloaded", message=message)
return {"status": "error", "msg": message}
@ -686,7 +681,7 @@ class DownloadQueue(metaclass=Singleton):
LOG.info(f"Checking '{item.url}' with {'cookies' if yt_conf.get('cookiefile') else 'no cookies'}.")
entry = await asyncio.wait_for(
entry: dict | None = await asyncio.wait_for(
fut=asyncio.get_running_loop().run_in_executor(
None,
functools.partial(
@ -980,9 +975,9 @@ class DownloadQueue(metaclass=Singleton):
self._active[entry.info._id] = entry
await entry.start()
if entry.info.status not in ("finished", "skip"):
if entry.info.status not in ("finished", "skip", "cancelled"):
if not entry.info.error:
entry.info.error = f"Download failed with status '{entry.info.status}'."
entry.info.error = f"Download ended with unexpected status '{entry.info.status}'."
entry.info.status = "error"
except Exception as e:
entry.info.status = "error"
@ -1010,9 +1005,12 @@ class DownloadQueue(metaclass=Singleton):
if entry.info.status == "finished" and entry.info.filename:
nTitle = "Download Completed"
nMessage = f"Completed '{entry.info.title}' download."
if entry.info.is_archivable and not entry.info.is_archived:
entry.info.is_archived = True
_tasks.append(self._notify.emit(Events.ITEM_COMPLETED, data=entry.info, title=nTitle, message=nMessage))
self.done.put(value=entry)
self.done.put(entry)
_tasks.append(
self._notify.emit(
Events.ITEM_MOVED,
@ -1029,23 +1027,6 @@ class DownloadQueue(metaclass=Singleton):
if self.event:
self.event.set()
def _is_downloaded(self, url: str, file: str | None = None) -> tuple[bool, dict | None]:
"""
Check if the url has been downloaded already.
Args:
url (str): The url to check.
file (str | None): The archive file to check.
Returns:
tuple: A tuple with the status of the operation and the id of the downloaded item.
"""
if not url or not file:
return False, None
return is_downloaded(file, url)
async def _check_for_stale(self):
"""
Monitor pool for stale downloads and cancel them if needed.
@ -1074,7 +1055,8 @@ class DownloadQueue(metaclass=Singleton):
if self.is_paused() or self.done.empty():
return
LOG.debug("Checking history queue for queued live stream links.")
if self.config.debug:
LOG.debug("Checking history queue for queued live stream links.")
time_now = datetime.now(tz=UTC)

View file

@ -97,7 +97,7 @@ class HttpAPI:
app.on_shutdown.append(self.on_shutdown)
def add_routes(self, app: web.Application):
def add_routes(self, app: web.Application) -> None:
"""
Add the routes to the application.
@ -133,7 +133,8 @@ class HttpAPI:
elif "" == base_path or not routePath.rstrip("/").startswith(base_path.rstrip("/")):
route.path = f"{base_path}/{route.path.lstrip('/')}"
LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.")
if self.config.debug:
LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.")
app.router.add_route(route.method, route.path, handler=_handle(route.handler), name=route.name)

View file

@ -110,9 +110,10 @@ class HttpSocket:
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
for route in get_routes(RouteType.SOCKET).values():
LOG.debug(
f"Add ({route.name}) {route.method.value if isinstance(route.method,RouteType) else route.method}: {route.path}."
)
if self.config.debug:
LOG.debug(
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
)
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path))
@staticmethod

View file

@ -6,9 +6,10 @@ from dataclasses import dataclass, field
from email.utils import formatdate
from typing import Any
from app.library.Utils import clean_item
from app.library.Utils import archive_add, archive_delete, archive_read, clean_item, get_archive_id
from app.library.YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("ItemDTO")
LOG: logging.Logger = logging.getLogger("ItemDTO")
@dataclass(kw_only=True)
@ -92,29 +93,6 @@ class Item:
"""
return Item.format({**self.serialize(), **kwargs})
def __repr__(self):
from .config import Config
from .Utils import calc_download_path, strip_newline
data = {}
for k, v in self.serialize().items():
if not v and k not in ("auto_start"):
continue
if k == "cli":
data[k] = strip_newline(v)
elif k == "extras":
data[k] = f"{len(v)} items"
elif k == "cookies":
data[k] = f"{len(v)}/chars"
elif k == "folder":
data[k] = calc_download_path(base_path=Config.get_instance().download_path, folder=v)
else:
data[k] = v
items = "".join(f'{k}="{v}", ' for k, v in data.items() if v)
return f"Item({items.strip(', ')})"
@staticmethod
def format(item: dict) -> "Item":
"""
@ -137,16 +115,14 @@ class Item:
msg = "url param is required."
raise ValueError(msg)
data = {
"url": url,
}
data: dict[str, str] = {"url": url}
preset = item.get("preset")
preset: str | None = item.get("preset")
if preset and isinstance(preset, str) and preset != Item._default_preset():
from .Presets import Presets
if not Presets.get_instance().has(preset):
msg = f"Preset '{preset}' does not exist."
msg: str = f"Preset '{preset}' does not exist."
raise ValueError(msg)
data["preset"] = preset
@ -163,19 +139,19 @@ class Item:
if "auto_start" in item and isinstance(item.get("auto_start"), bool):
data["auto_start"] = bool(item.get("auto_start"))
extras = item.get("extras")
extras: dict | None = item.get("extras")
if extras and isinstance(extras, dict) and len(extras) > 0:
data["extras"] = extras
if item.get("requeued") and isinstance(item.get("requeued"), bool):
data["requeued"] = item.get("requeued")
cli = item.get("cli")
cli: str | None = item.get("cli")
if cli and len(cli) > 2:
from .Utils import arg_converter
try:
removed_options = []
removed_options: list = []
arg_converter(args=cli, level=True, removed_options=removed_options)
if len(removed_options) > 0:
LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url)
@ -187,6 +163,55 @@ class Item:
return Item(**data)
def get_archive_id(self) -> str | None:
if not self.url:
return None
idDict: dict = get_archive_id(self.url)
return idDict.get("archive_id")
def get_ytdlp_opts(self) -> YTDLPOpts:
params: YTDLPOpts = YTDLPOpts.get_instance()
if self.preset:
params = params.preset(name=self.preset)
if self.cli:
params = params.add_cli(self.cli, from_user=True)
return params
def get_archive_file(self) -> str | None:
return self.get_ytdlp_opts().get_all().get("download_archive")
def is_archived(self) -> bool:
archive_id: str | None = self.get_archive_id()
archive_file: str | None = self.get_archive_file()
return len(archive_read(archive_file, [archive_id])) > 0 if archive_file and archive_id else False
def __repr__(self) -> str:
from .config import Config
from .Utils import calc_download_path, strip_newline
data: dict = {}
for k, v in self.serialize().items():
if not v and k not in ("auto_start"):
continue
if k == "cli":
data[k] = strip_newline(v)
elif k == "extras":
data[k] = f"{len(v)} items"
elif k == "cookies":
data[k] = f"{len(v)}/chars"
elif k == "folder":
data[k] = calc_download_path(base_path=Config.get_instance().download_path, folder=v)
else:
data[k] = v
items: str = "".join(f'{k}="{v}", ' for k, v in data.items() if v is not None)
return f"Item({items.strip(', ')})"
@dataclass(kw_only=True)
class ItemDTO:
@ -196,30 +221,55 @@ class ItemDTO:
"""
_id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
""" Unique identifier for the item. """
error: str | None = None
""" Error message if the item failed. """
id: str
""" The ID of the item yt-dlp """
title: str
""" The title of the item. """
url: str
quality: str | None = None
format: str | None = None
""" The URL of the item. """
preset: str = "default"
""" The preset to be used for the item. """
folder: str
""" The folder to save the item to. """
download_dir: str | None = None
""" The full path to the download directory. """
temp_dir: str | None = None
""" The full path to the temporary directory. """
status: str | None = None
""" The status of the item. """
cookies: str | None = None
""" The cookies to be used for the item. """
template: str | None = None
""" The output template to be used for the item. """
template_chapter: str | None = None
""" The output template for chapters to be used for the item. """
timestamp: float = field(default_factory=lambda: time.time_ns())
""" The timestamp of the item. """
is_live: bool | None = None
""" If the item is a live stream. """
datetime: str = field(default_factory=lambda: str(formatdate(time.time())))
""" The datetime of the item. """
live_in: str | None = None
""" The time until the live stream starts. """
file_size: int | None = None
""" The file size of the item. """
options: dict = field(default_factory=dict)
""" The options used for the item. """
extras: dict = field(default_factory=dict)
""" Extra data associated with the item. """
cli: str = ""
""" The command options for yt-dlp to be used for this download. """
auto_start: bool = True
""" If the item should be started automatically. """
is_archivable: bool | None = None
""" If the item can be archived. """
is_archived: bool | None = None
""" If the item has been archived. """
archive_id: str | None = None
""" The archive ID of the item. """
# yt-dlp injected fields.
tmpfilename: str | None = None
@ -232,7 +282,13 @@ class ItemDTO:
speed: str | None = None
eta: str | None = None
_recomputed: bool = False
_archive_file: str | None = None
def serialize(self) -> dict:
if "finished" == self.status and not self._recomputed:
self.archive_status()
item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields())
return item
@ -248,6 +304,104 @@ class ItemDTO:
def name(self) -> str:
return f'id="{self.id}", title="{self.title}"'
def get_archive_id(self) -> str | None:
"""
Get the archive ID for the download URL.
Returns:
str | None: The archive ID if available, None otherwise.
"""
if self.archive_id:
return self.archive_id
if not self.url:
return None
idDict: dict = get_archive_id(self.url)
self.archive_id = idDict.get("archive_id")
return self.archive_id
def get_ytdlp_opts(self) -> YTDLPOpts:
"""
Get the yt-dlp options for the item.
Returns:
YTDLPOpts: The yt-dlp options for the item.
"""
params: YTDLPOpts = YTDLPOpts.get_instance()
if self.preset:
params = params.preset(name=self.preset)
if self.cli:
params = params.add_cli(self.cli, from_user=True)
return params
def archive_status(self, force: bool = False) -> None:
if not force and (self._recomputed or not self.archive_id):
return
if "finished" == self.status:
self._recomputed = True
self.is_archivable = bool(self._archive_file)
if not self.is_archivable:
self.is_archived = False
else:
self.is_archived = len(archive_read(self._archive_file, [self.archive_id])) > 0
def get_archive_file(self) -> str | None:
"""
Get the archive file path from the yt-dlp options.
Returns:
str | None: The archive file path if available, None otherwise.
"""
if self._archive_file or self._recomputed or not self.archive_id:
return self._archive_file
self._archive_file = self.get_ytdlp_opts().get_all().get("download_archive")
if self._archive_file:
self._archive_file = self._archive_file.strip()
return self._archive_file
def archive_add(self) -> bool:
"""
Archive the item by adding its archive ID to the download archive file.
Returns:
bool: True if the item was archived, False otherwise.
"""
if self.is_archived or not self.is_archivable or not self.archive_id or not self._archive_file:
return False
self.is_archived = archive_add(self._archive_file, [self.archive_id])
return self.is_archived
def archive_delete(self) -> bool:
"""
Remove the item's archive ID from the download archive file.
Returns:
bool: True if the item was removed from the archive, False otherwise.
"""
if not self.is_archivable or not self.is_archived or not self.archive_id or not self._archive_file:
return False
archive_delete(self._archive_file, [self.archive_id])
self.is_archived = False
return True
@staticmethod
def removed_fields() -> tuple:
"""Fields that once existed but are no longer used."""
@ -261,4 +415,11 @@ class ItemDTO:
"output_template_chapter",
"config",
"temp_path",
"_recomputed",
"_archive_file",
)
def __post_init__(self):
self.get_archive_id()
self.get_archive_file()
self.archive_status()

View file

@ -8,7 +8,6 @@ from typing import Any
from aiohttp import web
from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events
from .Singleton import Singleton
from .Utils import arg_converter, init_class
@ -92,6 +91,7 @@ class Preset:
return self.__dict__
def json(self) -> str:
from .encoder import Encoder
return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:

View file

@ -126,7 +126,7 @@ class Scheduler(metaclass=Singleton):
self._jobs[job_id] = job
LOG.debug(f"Added '{job_id}' to the scheduler.")
LOG.debug(f"Added '{job_id}' to the scheduler to run on '{timer}'.")
return job_id

View file

@ -19,7 +19,7 @@ from .ItemDTO import Item
from .Scheduler import Scheduler
from .Services import Services
from .Singleton import Singleton
from .Utils import extract_info, init_class, validate_url
from .Utils import archive_add, archive_delete, extract_info, init_class, validate_url
from .YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger("tasks")
@ -47,51 +47,69 @@ class Task:
def get(self, key: str, default: Any = None) -> Any:
return self.serialize().get(key, default)
def mark(self) -> tuple[bool, str]:
if not self.url:
return False, "No URL found in task parameters."
def get_ytdlp_opts(self) -> YTDLPOpts:
params: YTDLPOpts = YTDLPOpts.get_instance()
if self.preset:
params = params.preset(name=self.preset)
params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=self.preset)
if self.cli:
params.add_cli(self.cli, from_user=True)
params = params.add_cli(self.cli, from_user=True)
params = params.get_all()
if not (_archive := params.get("download_archive", None)):
return False, "No archive file found in task parameters."
return params
_archive: Path = Path(_archive)
if not _archive.parent.exists():
_archive.parent.mkdir(parents=True, exist_ok=True)
def mark(self) -> tuple[bool, str]:
ret = self._mark_logic()
if isinstance(ret, tuple):
return ret
if not _archive.exists():
_archive.touch()
archive_file: Path = ret.get("file")
items: set[str] = ret.get("items", set())
_info = extract_info(params, self.url, no_archive=True, follow_redirect=True)
if not _info or not isinstance(_info, dict):
return False, "Failed to extract information from URL."
if len(items) < 1 or not archive_add(archive_file, list(items)):
return (True, "No new items to mark as downloaded.")
if "playlist" != _info.get("_type"):
return False, "Expected a playlist type from extract_info."
return (True, f"Task '{self.name}' items marked as downloaded.")
archived_items: list[str] = []
with _archive.open() as f:
for line in f:
line = line.strip()
if not line or not isinstance(line, str) or line.startswith("#"):
continue
def unmark(self) -> tuple[bool, str]:
ret: tuple[bool, str] | set[tuple[Path, set[str]]] = self._mark_logic()
if isinstance(ret, tuple):
return ret
archived_items.append(line)
archive_file: Path = ret.get("file")
items: set[str] = ret.get("items", set())
def _process(item: dict) -> bool:
status = False
if len(items) < 1 or not archive_delete(archive_file, list(items)):
return (True, "No items to remove from archive file.")
return (True, f"Removed '{self.name}' items from archive file.")
def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]:
if not self.url:
return (False, "No URL found in task parameters.")
params: dict = self.get_ytdlp_opts().get_all()
if not (archive_file := params.get("download_archive")):
return (False, "No archive file found.")
archive_file: Path = Path(archive_file)
ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=True)
if not ie_info or not isinstance(ie_info, dict):
return (False, "Failed to extract information from URL.")
if "playlist" != ie_info.get("_type"):
return (False, "Expected a playlist type from extract_info.")
items: set[str] = set()
def _process(item: dict):
for entry in item.get("entries", []):
if not isinstance(entry, dict):
continue
if "playlist" == entry.get("_type"):
_status = _process(entry)
if status is False:
status = _status
_process(entry)
continue
if entry.get("_type") not in ("video", "url"):
@ -100,26 +118,13 @@ class Task:
if not entry.get("id") or not entry.get("ie_key"):
continue
archive_id = f'{entry.get("ie_key","").lower()} {entry.get("id")}'
archive_id: str = f"{entry.get('ie_key', '').lower()} {entry.get('id')}"
if archive_id in archived_items:
continue
items.add(archive_id)
archived_items.append(archive_id)
status = True
_process(ie_info)
return status
updated = _process(_info)
if not updated:
return True, "No new items to mark as downloaded."
with _archive.open("a") as f:
for item in archived_items:
f.write(f"{item}\n")
return True, f"Task '{self.name}' marked as downloaded. Updated archive file '{_archive}'."
return {"file": archive_file, "items": items}
class Tasks(metaclass=Singleton):
@ -417,6 +422,7 @@ class Tasks(metaclass=Singleton):
"folder": folder,
"template": template,
"cli": cli,
"auto_start": task.auto_start,
}
)
)
@ -544,7 +550,11 @@ class HandleTask:
if handler is None:
return None
return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs)
try:
return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs)
except Exception as e:
LOG.exception(e)
raise
def _discover(self) -> list[type]:
import importlib

View file

@ -13,7 +13,7 @@ from datetime import UTC, datetime, timedelta
from functools import lru_cache
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from typing import TypeVar
from typing import Any, TypeVar
from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted, match_str
@ -63,7 +63,7 @@ REMOVE_KEYS: list = [
YTDLP_INFO_CLS: YTDLP = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
FILES_TYPE: list = [
{"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"},
@ -203,7 +203,7 @@ def extract_info(
if no_archive and "download_archive" in params:
del params["download_archive"]
data = YTDLP(params=params).extract_info(url, download=False)
data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False)
if data and follow_redirect and "_type" in data and "url" == data["_type"]:
return extract_info(
@ -263,112 +263,6 @@ def merge_dict(source: dict, destination: dict) -> dict:
return destination_copy
def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
"""
Check if the video is already downloaded.
Args:
archive_file (str): Archive file path.
url (str): URL to check.
Returns:
bool: True if the video is already downloaded.
dict: Video information.
"""
idDict = {"id": None, "ie_key": None, "archive_id": None}
if not url or not archive_file:
return (False, idDict)
if not Path(archive_file).exists():
return (False, idDict)
idDict = get_archive_id(url=url)
if idDict.get("archive_id"):
with open(archive_file) as f:
for line in f:
if idDict["archive_id"] in line:
return (True, idDict)
return (False, idDict)
def remove_from_archive(archive_file: str | Path, url: str) -> bool:
"""
Remove the downloaded video record from the archive file.
Args:
archive_file (str): Archive file path.
url (str): URL to check and remove.
Returns:
bool: True if the record removed, False otherwise.
"""
if not url or not archive_file:
return False
archive_path: Path = Path(archive_file) if not isinstance(archive_file, Path) else archive_file
if not archive_path.exists():
return False
idDict = get_archive_id(url=url)
archive_id: str | None = idDict.get("archive_id")
if not archive_id:
return False
lines: list[str] = archive_path.read_text(encoding="utf-8").splitlines()
new_lines: list[str] = [line for line in lines if archive_id not in line]
if len(lines) == len(new_lines):
return False
archive_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
return True
def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
"""
Load a JSON or JSON5 file and return the contents as a dictionary
Args:
file (str): File path
check_type (type): Type to check the loaded file against.
Returns tuple:
dict|list: Dictionary or list of the file contents. Empty dict if the file could not be loaded.
bool: True if the file was loaded successfully.
str: Error message if the file could not be loaded.
"""
try:
with open(file) as json_data:
opts = json.load(json_data)
if check_type:
assert isinstance(opts, check_type)
return (opts, True, "")
except Exception:
with open(file) as json_data:
from pyjson5 import load as json5_load
try:
opts = json5_load(json_data)
if check_type:
assert isinstance(opts, check_type)
return (opts, True, "")
except AssertionError:
return ({}, False, f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.")
except Exception as e:
return ({}, False, f"{e}")
def check_id(file: Path) -> bool | str:
"""
Check if we are able to get an id from the file name.
@ -405,13 +299,9 @@ def check_id(file: Path) -> bool | str:
@lru_cache(maxsize=512)
def is_private_address(hostname: str) -> bool:
try:
ip = socket.gethostbyname(hostname)
ip_obj = ipaddress.ip_address(ip)
return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local
except socket.gaierror:
# Could not resolve - treat as invalid or restricted
return True
ip = socket.gethostbyname(hostname)
ip_obj = ipaddress.ip_address(ip)
return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local
def validate_url(url: str, allow_internal: bool = False) -> bool:
@ -447,10 +337,20 @@ def validate_url(url: str, allow_internal: bool = False) -> bool:
raise ValueError(msg)
hostname: str | None = parsed_url.host
if allow_internal is False and (not hostname or is_private_address(hostname)):
msg = "Access to internal urls or private networks is not allowed."
if not hostname:
msg = "Invalid hostname."
raise ValueError(msg)
if allow_internal is False:
try:
if is_private_address(hostname):
msg = "Access to internal urls or private networks is not allowed."
raise ValueError(msg)
except socket.gaierror as e:
LOG.error(f"Error resolving hostname '{hostname}': {e!s}")
msg = "Invalid hostname."
raise ValueError(msg) from e
return True
@ -489,6 +389,10 @@ def arg_converter(
default_opts = _default_opts([]).ydl_opts
if args:
# important to ignore external config files.
args = "--ignore-config " + args
opts = yt_dlp.parse_options(shlex.split(args)).ydl_opts
diff = {k: v for k, v in opts.items() if default_opts[k] != v} if not keep_defaults else opts.items()
if "postprocessors" in diff:
@ -1101,7 +1005,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
raise ValueError(msg) from e
def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
def get_archive_id(url: str) -> dict[str, str | None]:
"""
Get the archive ID for a given URL.
@ -1109,8 +1013,11 @@ def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | N
url (str): URL to check.
Returns:
bool: True if the video is already downloaded.
dict: Video information.
dict: {
"id": str | None,
"ie_key": str | None,
"archive_id": str | None
}
"""
global YTDLP_INFO_CLS # noqa: PLW0603
@ -1279,14 +1186,11 @@ def load_modules(root_path: Path, directory: Path):
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
LOG.debug(f"Loading routes from '{directory}' with package name '{package_name}'.")
for _, name, _ in pkgutil.iter_modules([directory]):
full_name: str = f"{package_name}.{name}"
if name.startswith("_"):
continue
try:
LOG.debug(f"Loading module '{full_name}'.")
importlib.import_module(full_name)
except ImportError as e:
LOG.error(f"Failed to import module '{full_name}': {e}")
@ -1449,3 +1353,145 @@ def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]:
folders.extend(list_folders(entry, base, depth_limit))
return folders
def archive_add(file: str | Path, ids: list[str], skip_check: bool = False) -> bool:
"""
Add IDs to an archive file.
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to add.
skip_check (bool): If True, skip checking for existing IDs.
"""
if not ids or not file:
return False
path: Path = Path(file) if not isinstance(file, Path) else file
existing_ids: set[str] = set()
if not skip_check and path.exists():
with path.open("r", encoding="utf-8") as f:
for line in f:
s = line.strip()
if not s or len(s.split()) < 2:
continue
existing_ids.add(s)
new_ids: list[str] = []
for raw in ids:
s: str = str(raw).strip()
if not s or len(s.split()) < 2 or s in existing_ids or s in new_ids:
continue
new_ids.append(s)
if not new_ids:
return False
try:
if not path.parent.exists():
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write("".join(f"{x}\n" for x in new_ids))
return True
except OSError as e:
LOG.error(f"Failed to write to archive file '{path!s}'. {e!s}")
return False
def archive_read(file: str | Path, ids: list[str] | None = None) -> list[str]:
"""
Read IDs from an archive file with optional filtering.
- If `ids` is empty, return all IDs present in the archive file.
- If `ids` is provided, return only the ids that exist in the archive file,
Args:
file (str|Path): The archive file path.
ids (list[str]): Optional list of IDs to query; empty list returns all.
Returns:
list[str]: List of ids found in the archive file filtered by `ids` if provided.
"""
if not file:
return []
path: Path = Path(file) if not isinstance(file, Path) else file
if not file or not path.exists():
return []
ids_set: set[str] | None = (
{s.strip() for s in ids if str(s).strip() and len(str(s).strip().split()) >= 2} if ids else None
)
found: list[str] = []
with path.open("r", encoding="utf-8") as f:
for line in f:
s: str = line.strip()
if not s or len(s.split()) < 2:
continue
if ids_set is None or s in ids_set:
found.append(s)
return found
def archive_delete(file: str | Path, ids: list[str]) -> bool:
"""
Delete the given IDs from an archive file.
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to remove.
Returns:
bool: True if deletion succeeded (or nothing to do), False on error.
"""
if not file or not ids:
return False
path: Path = Path(file) if not isinstance(file, Path) else file
if not path.exists():
return False
remove_ids: set[str] = {x.strip() for x in ids if str(x).strip() and len(str(x).strip().split()) >= 2}
if not remove_ids:
return True
changed = False
kept_lines: list[str] = []
removed_ids: list[str] = []
with path.open("r", encoding="utf-8") as f:
for line in f:
s: str = line.strip()
if not s or len(s.split()) < 2:
changed = True
continue
if s in remove_ids:
changed = True
removed_ids.append(s)
continue
kept_lines.append(line)
if not changed:
return True
with path.open("w", encoding="utf-8") as f:
f.writelines(kept_lines)
return True

View file

@ -200,7 +200,8 @@ class YTDLPOpts:
if data["format"] == "-best":
data["format"] = data["format"][1:]
LOG.debug(f"Final yt-dlp options: '{data!s}'.")
if self._config.debug:
LOG.debug(f"Final yt-dlp options: '{data!s}'.")
return data

View file

@ -103,9 +103,6 @@ class Config:
archive_file: str = "{config_path}/archive.log"
"""The path to the download archive file."""
manual_archive: str = "{config_path}/archive.manual.log"
"""The path to the manual archive file."""
apprise_config: str = "{config_path}/apprise.yml"
"""The path to the Apprise configuration file."""
@ -387,34 +384,20 @@ class Config:
except Exception as e:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
ytdl_options = {}
opts_file: Path = Path(self.config_path) / "ytdlp.cli"
if opts_file.exists() and opts_file.stat().st_size > 2:
LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.")
LOG.warning(
"Usage of 'ytdlp.cli' global config file is deprecated and will be removed in future releases. please migrate to presets."
)
with open(opts_file) as f:
self.ytdlp_cli = f.read().strip()
if self.ytdlp_cli:
self._ytdlp_cli_mutable = self.ytdlp_cli
try:
removed_options = []
ytdl_options = arg_converter(args=self.ytdlp_cli, level=True, removed_options=removed_options)
try:
LOG.debug("Parsed yt-dlp cli options '%s'.", ytdl_options)
except Exception:
pass
if len(removed_options) > 0:
LOG.warning(
"Removed the following options: '%s' from '%s'", ", ".join(removed_options), opts_file
)
arg_converter(args=self.ytdlp_cli, level=True)
except Exception as e:
msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'."
raise ValueError(msg) from e
else:
LOG.warning(f"Empty yt-dlp custom options file '{opts_file}'.")
else:
LOG.info(f"No yt-dlp custom options found at '{opts_file}'.")
self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}"
@ -424,7 +407,7 @@ class Config:
LOG.info(f"Creating archive file '{archive_file}'.")
archive_file.touch(exist_ok=True)
LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}'.")
LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}' by default.")
self._ytdlp_cli_mutable += f"\n--download-archive {archive_file.as_posix()!s}"
if self.temp_keep:
@ -519,6 +502,16 @@ class Config:
return "production" == self.app_env
def get_ytdlp_args(self) -> dict:
"""
Get the yt-dlp command line options as a dictionary.
Returns:
dict: The yt-dlp command line options.
Deprecated:
Usage of global ytdlp.cli file is deprecated, please use presets instead.
"""
try:
return arg_converter(args=self._ytdlp_cli_mutable, level=True)
except Exception as e:
@ -537,6 +530,7 @@ class Config:
ytdlp_args = self.get_ytdlp_args()
# TODO: this doesn't make sense, as each item might have it's own archive file or none at all.
if not data.get("keep_archive", False) and ytdlp_args.get("download_archive", None):
data["keep_archive"] = True
@ -544,7 +538,7 @@ class Config:
return data
@staticmethod
def _ytdlp_version():
def _ytdlp_version() -> str:
try:
from yt_dlp.version import __version__ as YTDLP_VERSION

View file

@ -5,8 +5,6 @@ from pathlib import Path
from yt_dlp.networking.impersonate import ImpersonateTarget
from yt_dlp.utils import DateRange
from .ItemDTO import ItemDTO
class Encoder(json.JSONEncoder):
"""
@ -16,6 +14,8 @@ class Encoder(json.JSONEncoder):
"""
def default(self, o):
from .ItemDTO import ItemDTO
if isinstance(o, Path):
return str(o)

View file

@ -1,18 +1,19 @@
import asyncio
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element
from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item, ItemDTO
from app.library.Tasks import Task
from app.library.Utils import is_downloaded
from app.library.YTDLPOpts import YTDLPOpts
from app.library.Utils import archive_read
if TYPE_CHECKING:
from xml.etree.ElementTree import Element
from app.library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__)
@ -25,24 +26,22 @@ EventBus.get_instance().subscribe(
class YoutubeHandler:
queued_ids: set[str] = set()
queued: set[str] = set()
failure_count: dict[str, int] = {}
FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
FEED_PLAYLIST = "https://www.youtube.com/feeds/videos.xml?playlist_id={id}"
CHANNEL_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$")
PLAYLIST_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:playlist\?list=(?P<id>[A-Za-z0-9_-]+)|).*$")
@staticmethod
def can_handle(task: Task, config: Config) -> bool:
has, _ = YoutubeHandler.has_archive(task, config)
if not has:
LOG.debug(f"Task '{task.id}: {task.name}' does not have an archive file configured.")
def can_handle(task: Task) -> bool:
if not task.get_ytdlp_opts().get_all().get("download_archive"):
LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
return False
LOG.debug(f"Checking if task '{task.id}: {task.name}' can handle YouTube URL: {task.url}")
LOG.debug(f"Checking if task '{task.name}' is using parsable YouTube URL: {task.url}")
return YoutubeHandler.parse(task.url) is not None
@staticmethod
@ -58,24 +57,24 @@ class YoutubeHandler:
queue (DownloadQueue): The download queue instance.
"""
archive_file, params = YoutubeHandler.has_archive(task, config)
if not archive_file:
LOG.error(f"Task '{task.id}: {task.name}' does not have an archive file configured.")
params: dict = task.get_ytdlp_opts().get_all()
if not (archive_file := params.get("download_archive")):
LOG.error(f"Task '{task.name}' does not have an archive file.")
return
import httpx
from defusedxml.ElementTree import fromstring
parsed = YoutubeHandler.parse(task.url)
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
if not parsed:
LOG.error(f"Cannot parse '{task.id}: {task.name}' URL: {task.url}")
LOG.error(f"Cannot parse '{task.name}' URL: {task.url}")
return
feed_url = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
LOG.debug(f"Fetching '{task.id}: {task.name}' feed.")
opts = {
"proxy": params.get("proxy", None),
LOG.debug(f"Fetching '{task.name}' feed.")
opts: dict[str, Any] = {
"proxy": params.get("proxy"),
"headers": {
"User-Agent": params.get(
"user_agent",
@ -84,109 +83,111 @@ class YoutubeHandler:
},
}
try:
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
opts["transport"] = AsyncCurlTransport(
impersonate="chrome",
default_headers=True,
curl_options={CurlOpt.FRESH_CONNECT: True},
)
opts.pop("headers", None)
except Exception:
pass
items: list = []
has_items = False
async with httpx.AsyncClient(**opts) as client:
response = await client.request(method="GET", url=feed_url, timeout=120)
response: httpx.Response = await client.request(method="GET", url=feed_url, timeout=120)
response.raise_for_status()
root = fromstring(response.text)
ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"}
root: Element[str] = fromstring(response.text)
ns: dict[str, str] = {
"atom": "http://www.w3.org/2005/Atom",
"yt": "http://www.youtube.com/xml/schemas/2015",
}
for entry in root.findall("atom:entry", ns):
vid_elem = entry.find("yt:videoId", ns)
title_elem = entry.find("atom:title", ns)
pub_elem = entry.find("atom:published", ns)
vid = vid_elem.text if vid_elem is not None else ""
title = title_elem.text if title_elem is not None else ""
published = pub_elem.text if pub_elem is not None else ""
items.append(
{
"id": vid,
"url": f"https://www.youtube.com/watch?v={vid}",
"title": title,
"published": published,
}
)
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
vid: str | None = vid_elem.text if vid_elem is not None else ""
if not vid:
LOG.warning(f"Entry in '{task.name}' feed is missing a video ID. Skipping entry.")
continue
archive_id: str = f"youtube {vid}"
url: str = f"https://www.youtube.com/watch?v={vid}"
title_elem: Element[str] | None = entry.find("atom:title", ns)
title: str | None = title_elem.text if title_elem is not None else ""
pub_elem: Element[str] | None = entry.find("atom:published", ns)
published: str | None = pub_elem.text if pub_elem is not None else ""
has_items = True
if archive_id in YoutubeHandler.queued:
continue
items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id})
if len(items) < 1:
LOG.warning(f"No entries found in '{task.id}: {task.name}' feed. URL: {feed_url}")
if not has_items:
LOG.warning(f"No entries found in '{task.name}' feed. URL: {feed_url}")
else:
LOG.debug(f"No new items found in '{task.name}' feed.")
return
filtered: list = []
downloaded: list[str] = archive_read(archive_file, [item["archive_id"] for item in items])
for item in items:
status, _ = is_downloaded(archive_file, url=item["url"])
if status is True or item["id"] in YoutubeHandler.queued_ids:
YoutubeHandler.queued.add(item["archive_id"])
if item["archive_id"] in downloaded:
continue
if queue.queue.exists(url=item["url"]):
LOG.debug(f"Item '{item['id']}' exists in the queue.")
YoutubeHandler.queued_ids.add(item["id"])
continue
try:
done: Download = queue.done.get(url=item["url"])
if "error" != done.info.status:
LOG.debug(f"Item '{item['id']}' exists in the download history.")
YoutubeHandler.queued_ids.add(item["id"])
continue
except KeyError:
pass
YoutubeHandler.queued_ids.add(item["id"])
if item["archive_id"] not in YoutubeHandler.failure_count:
YoutubeHandler.failure_count[item["archive_id"]] = 0
filtered.append(item)
if len(filtered) < 1:
LOG.debug(f"No new items found in '{task.id}: {task.name}' feed.")
LOG.debug(f"No new items found in '{task.name}' feed.")
return
LOG.info(f"Found '{len(filtered)}' new items from '{task.id}: {task.name}' feed.")
LOG.info(f"Found '{len(filtered)}' new items from '{task.name}' feed.")
preset: str = str(task.preset or config.default_preset)
folder: str = task.folder if task.folder else ""
template: str = task.template if task.template else ""
cli: str = task.cli if task.cli else ""
rItem: Item = Item.format(
{
"url": feed_url,
"preset": str(task.preset or config.default_preset),
"folder": task.folder if task.folder else "",
"template": task.template if task.template else "",
"cli": task.cli if task.cli else "",
"auto_start": task.auto_start,
"extras": {"source_task": task.id},
}
)
try:
await asyncio.gather(
*[
notify.emit(
Events.ADD_URL,
data=Item.format(
{"url": item["url"], "preset": preset, "folder": folder, "template": template, "cli": cli}
).serialize(),
)
for item in filtered
]
*[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered]
)
except Exception as e:
LOG.error(f"Error while adding items from '{task.id}: {task.name}'. {e!s}")
LOG.exception(e)
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")
return
@staticmethod
def has_archive(task: Task, config: Config) -> tuple[Path | None, dict]:
archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None
params: YTDLPOpts = YTDLPOpts.get_instance()
if task.preset:
params.preset(name=task.preset)
if task.cli:
params.add_cli(task.cli, from_user=True)
params = params.get_all()
if user_archive_file := params.get("download_archive", None):
archive_file = Path(user_archive_file)
if not archive_file:
return (None, params)
if not archive_file.exists():
archive_file.parent.mkdir(parents=True, exist_ok=True)
archive_file.touch(exist_ok=True)
return (archive_file, params)
@staticmethod
def parse(url: str) -> dict[str, str] | None:
"""
@ -222,29 +223,33 @@ class YoutubeHandler:
if not item or not isinstance(item, ItemDTO):
return
cls.queued_ids.add(item.id)
if item.id not in cls.queued_ids:
if not item.archive_id or not cls.failure_count.get(item.archive_id, None):
LOG.debug(f"Item '{item.name()}' not queued by the handler.")
return
currentFailureCount: int = cls.failure_count.get(item.id, 0)
failCount: int = int(cls.failure_count.get(item.archive_id, 0))
LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{currentFailureCount + 1}'.")
cls.queued_ids.remove(item.id)
LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{failCount + 1}'.")
if item.archive_id in cls.queued:
cls.queued.remove(item.archive_id)
cls.failure_count[item.id] = cls.failure_count.get(item.id, 0) + 1
cls.failure_count[item.archive_id] = 1 + failCount
@staticmethod
def tests() -> list[str]:
def tests() -> list[tuple[str, bool]]:
"""
Return a list of test URLs to validate the parsing logic.
Test cases for the URL parser.
Returns:
list[tuple[str, bool]]: A list of tuples containing the URL and expected result.
"""
return [
"https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN",
"https://youtube.com/c/MyCustomName",
"https://youtube.com/user/SomeUser123",
"https://youtube.com/@SomeHandle",
"https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ",
"https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ",
"https://youtube.com/watch?v=foo",
("https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN", True),
("https://youtube.com/c/MyCustomName", False),
("https://youtube.com/user/SomeUser123", False),
("https://youtube.com/@SomeHandle", False),
("https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ", True),
("https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ", True),
("https://youtube.com/watch?v=foo", False),
]

View file

@ -1,168 +0,0 @@
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
import anyio
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue
from app.library.router import route
from app.library.Utils import is_downloaded, remove_from_archive
from app.library.YTDLPOpts import YTDLPOpts
if TYPE_CHECKING:
from library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__)
@route("DELETE", r"api/archive/{id}", "archive.remove")
async def archive_remove(request: Request, queue: DownloadQueue, config: Config) -> Response:
"""
Remove an item from the archive.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
config (Config): The configuration instance.
Returns:
Response: The response object.
"""
item = None
try:
data: dict | None = await request.json()
except Exception:
data = {}
title: str = ""
url: str | None = data.get("url", None) if data else None
if not url:
id: str = request.match_info.get("id")
if not id:
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
url = item.info.url
title = f" '{item.info.title}'"
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
if config.manual_archive:
remove_from_archive(archive_file=Path(config.manual_archive), url=url)
archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None
if item:
params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset)
if item.info.cli:
params.add_cli(item.info.cli, from_user=True)
params = params.get_all()
if user_file := params.get("download_archive", None):
archive_file = Path(user_file)
if not archive_file:
return web.json_response(
data={
"error": "Archive file is not configured." if not config.keep_archive else "Archive file is not set."
},
status=web.HTTPBadRequest.status_code,
)
if not archive_file.exists():
return web.json_response(
data={"error": f"Archive file '{archive_file}' does not exist."},
status=web.HTTPNotFound.status_code,
)
if not remove_from_archive(archive_file=archive_file, url=url):
return web.json_response(
data={"error": f"item{title} not found in '{archive_file}' archive."},
status=web.HTTPNotFound.status_code,
)
return web.json_response(
data={"message": f"item{title} removed from '{archive_file}' archive."},
status=web.HTTPOk.status_code,
)
@route("POST", r"api/archive/{id}", "archive.item")
async def archive_item(request: Request, queue: DownloadQueue, config: Config):
"""
Manually mark an item as archived.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
config (Config): The configuration instance.
Returns:
Response: The response object.
"""
id: str = request.match_info.get("id")
if not id:
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
if config.manual_archive:
manual_archive = Path(config.manual_archive)
if manual_archive.exists():
exists, idDict = is_downloaded(manual_archive, item.info.url)
if exists is False and idDict.get("archive_id"):
async with await anyio.open_file(manual_archive, "a") as f:
await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n")
params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=item.info.preset)
if item.info.cli:
params.add_cli(item.info.cli, from_user=True)
params = params.get_all()
user_file: str | None = params.get("download_archive", None)
archive_file: Path = Path(user_file) if user_file else Path(config.archive_file)
if not archive_file.exists():
return web.json_response(
data={"error": f"Archive file '{archive_file}' does not exist."},
status=web.HTTPNotFound.status_code,
)
exists, idDict = is_downloaded(archive_file, item.info.url)
item_id: str | None = idDict.get("archive_id")
if not item_id:
return web.json_response(
data={"error": "item does not have an archive ID."}, status=web.HTTPBadRequest.status_code
)
if exists is True:
return web.json_response(
data={"error": f"item '{item_id}' already archived in file '{archive_file}'."},
status=web.HTTPConflict.status_code,
)
async with await anyio.open_file(archive_file, "a") as f:
await f.write(f"{item_id}\n")
return web.json_response(
data={"message": f"item '{item_id}' archived in file '{archive_file}'."},
status=web.HTTPOk.status_code,
)

View file

@ -7,6 +7,7 @@ from aiohttp import web
from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
@ -98,7 +99,7 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, co
if not item.info:
return web.json_response(data={"error": "item has no info."}, status=web.HTTPNotFound.status_code)
info = {
info: dict = {
**item.info.serialize(),
"ffprobe": {},
}
@ -256,3 +257,119 @@ async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) ->
response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")})
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", r"api/history/{id}/archive", "history.item.archive.add")
async def item_archive_add(request: Request, queue: DownloadQueue, notify: EventBus) -> Response:
"""
Manually mark an item as archived.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
if not item.info.is_archivable:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive file."},
status=web.HTTPBadRequest.status_code,
)
if not item.info.archive_id:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if item.info.is_archived:
return web.json_response(
data={"error": f"item '{item.info.title}' already archived."},
status=web.HTTPConflict.status_code,
)
if not item.info.archive_add():
return web.json_response(
data={"error": f"item '{item.info.title}' could not be added to archive."},
status=web.HTTPInternalServerError.status_code,
)
item.info.archive_status(force=True)
queue.done.put(item, no_notify=True)
await notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data={"message": f"item '{item.info.title}' archived."},
status=web.HTTPOk.status_code,
)
@route("DELETE", r"api/history/{id}/archive", "history.item.archive.delete")
async def item_archive_delete(request: Request, queue: DownloadQueue, notify: EventBus) -> Response:
"""
Remove an item from the archive.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
if not item.info.is_archivable:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive file."},
status=web.HTTPBadRequest.status_code,
)
if not item.info.archive_id:
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if not item.info.is_archived:
return web.json_response(
data={"error": f"item '{item.info.title}' not archived."},
status=web.HTTPConflict.status_code,
)
if not item.info.archive_delete():
return web.json_response(
data={"error": f"item '{item.info.title}' not found in archive file."},
status=web.HTTPInternalServerError.status_code,
)
item.info.archive_status(force=True)
queue.done.put(item, no_notify=True)
await notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data={"message": f"item '{item.info.title}' removed from archive."},
status=web.HTTPOk.status_code,
)

View file

@ -43,19 +43,19 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
try:
ytdlp_args = config.get_ytdlp_args()
opts = {
"proxy": ytdlp_args.get("proxy", None),
"headers": {
"User-Agent": ytdlp_args.get(
"user_agent", request.headers.get("User-Agent", random_user_agent())
),
"User-Agent": ytdlp_args.get("user_agent", request.headers.get("User-Agent", random_user_agent())),
},
}
async with httpx.AsyncClient(**opts) as client:
LOG.debug(f"Fetching thumbnail from '{url}'.")
response = await client.request(method="GET", url=url)
response = await client.request(method="GET", url=url, follow_redirects=True)
return web.Response(
body=response.content,
headers={
@ -122,6 +122,17 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
"User-Agent": ytdlp_args.get("user_agent", random_user_agent()),
},
}
try:
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
opts["transport"] = AsyncCurlTransport(
impersonate="chrome",
default_headers=True,
curl_options={CurlOpt.FRESH_CONNECT: True},
)
opts.pop("headers", None)
except Exception:
pass
async with httpx.AsyncClient(**opts) as client:
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):

View file

@ -1,11 +1,13 @@
import asyncio
import logging
import time
from aiohttp import web
from aiohttp.web import Request, Response
from aiohttp.web_runner import GracefulExit
from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.router import route
@ -13,10 +15,80 @@ from app.library.router import route
LOG: logging.Logger = logging.getLogger(__name__)
@route("POST", "api/system/pause", "system.pause")
async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
"""
Pause non-active downloads.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
if queue.is_paused():
return web.json_response(
{"message": "Non-active downloads are already paused."},
status=web.HTTPNotAcceptable.status_code,
dumps=encoder.encode,
)
queue.pause()
msg = "Non-active downloads have been paused."
await notify.emit(
Events.PAUSED,
data={"paused": True, "at": time.time()},
title="Downloads Paused",
message=msg,
)
return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/system/resume", "system.resume")
async def downloads_resume(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
"""
Resume non-active downloads.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns:
Response: The response object.
"""
if not queue.is_paused():
return web.json_response(
{"message": "Non-active downloads are not paused."},
status=web.HTTPNotAcceptable.status_code,
dumps=encoder.encode,
)
queue.resume()
msg = "Resumed all downloads."
await notify.emit(
Events.RESUMED,
data={"paused": False, "at": time.time()},
title="Downloads Resumed",
message=msg,
)
return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/system/shutdown", "system.shutdown")
async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response:
"""
Get the presets.
Initiate application shutdown.
Args:
request (Request): The request object.

View file

@ -94,7 +94,7 @@ async def tasks_add(request: Request, encoder: Encoder) -> Response:
@route("POST", "api/tasks/{id}/mark", "tasks_mark")
async def mark_task(request: Request, encoder: Encoder) -> Response:
async def task_mark(request: Request, encoder: Encoder) -> Response:
"""
Mark all items from task as downloaded.
@ -111,9 +111,9 @@ async def mark_task(request: Request, encoder: Encoder) -> Response:
if not task_id:
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
tasks = Tasks.get_instance()
tasks: Tasks = Tasks.get_instance()
try:
task = tasks.get(task_id)
task: Task | None = tasks.get(task_id)
if not task:
return web.json_response(
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
@ -126,3 +126,38 @@ async def mark_task(request: Request, encoder: Encoder) -> Response:
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
@route("DELETE", "api/tasks/{id}/mark", "tasks_unmark")
async def task_unmark(request: Request, encoder: Encoder) -> Response:
"""
Remove All tasks items from download archive.
Args:
request (Request): The request object.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object
"""
task_id: str = request.match_info.get("id", None)
if not task_id:
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
tasks: Tasks = Tasks.get_instance()
try:
task: Task | None = tasks.get(task_id)
if not task:
return web.json_response(
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
)
_status, _message = task.unmark()
if not _status:
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)

View file

@ -1,12 +1,9 @@
import asyncio
import json
import logging
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any
import anyio
from aiohttp import web
from aiohttp.web import Request, Response
@ -14,7 +11,14 @@ from app.library.cache import Cache
from app.library.config import Config
from app.library.Presets import Presets
from app.library.router import route
from app.library.Utils import REMOVE_KEYS, arg_converter, extract_info, validate_url
from app.library.Utils import (
REMOVE_KEYS,
archive_read,
arg_converter,
extract_info,
get_archive_id,
validate_url,
)
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)
@ -199,6 +203,14 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
"expires": time.time() + 300,
}
is_archived = False
if (archive_file := ytdlp_opts.get("download_archive")) and (
archive_id := get_archive_id(url=url).get("archive_id")
):
is_archived: bool = len(archive_read(archive_file, [archive_id])) > 0
data["is_archived"] = is_archived
data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))
cache.set(key=key, value=data, ttl=300)
@ -217,87 +229,6 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
)
@route("GET", "api/yt-dlp/archive/recheck/", "archive_recheck")
async def archive_recheck(cache: Cache) -> Response:
"""
Recheck the manual archive entries.
Args:
cache (Cache): The cache instance.
Returns:
Response: The response object
"""
config: Config = Config.get_instance()
if not config.manual_archive:
return web.json_response(data={"error": "Manual archive is not enabled."}, status=web.HTTPNotFound.status_code)
manual_archive = Path(config.manual_archive)
if not manual_archive.exists():
return web.json_response(
data={"error": "Manual archive file not found.", "file": manual_archive},
status=web.HTTPNotFound.status_code,
)
tasks: list = []
response: list = []
def info_wrapper(id: str, url: str) -> tuple[str, dict]:
try:
return (
id,
extract_info(
config={
"proxy": config.get_ytdlp_args().get("proxy", None),
"simulate": True,
"dump_single_json": True,
},
url=url,
no_archive=True,
),
)
except Exception as e:
return (id, {"error": str(e)})
async with await anyio.open_file(manual_archive) as f:
# line format is "youtube ID - at: ISO8601"
async for line in f:
line = line.strip()
if not line or not line.startswith("youtube"):
continue
id = line.split(" ")[1].strip()
if not id:
continue
url = f"https://www.youtube.com/watch?v={id}"
key = cache.hash(id)
if cache.has(key):
data = cache.get(key)
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
continue
tasks.append(
asyncio.get_event_loop().run_in_executor(None, lambda i=id, url=url: info_wrapper(id=i, url=url))
)
if len(tasks) > 0:
results = await asyncio.gather(*tasks)
for data in results:
if not data:
continue
id, info = data
cache.set(key=cache.hash(id), value=info, ttl=3600 * 6)
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
return web.json_response(data=response, status=web.HTTPOk.status_code)
@route("GET", "api/yt-dlp/options/", "get_options")
async def get_options() -> Response:
"""
@ -309,6 +240,37 @@ async def get_options() -> Response:
"""
from app.library.ytdlp import ytdlp_options
return web.json_response(
body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code
)
return web.json_response(body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code)
@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids")
async def get_archive_ids(request: Request) -> Response:
"""
Get the yt-dlp CLI options.
Returns:
Response: The response object with the yt-dlp CLI options.
"""
from app.library.Utils import get_archive_id
data = (await request.json()) if request.body_exists else None
if not data or not isinstance(data, list):
return web.json_response(
data={"error": "Invalid request. expecting list with URLs."},
status=web.HTTPBadRequest.status_code,
)
response = []
for i, url in enumerate(data):
dct = {"index": i, "url": url}
try:
validate_url(url)
dct.update(get_archive_id(url))
except ValueError as e:
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)})
response.append(dct)
return web.json_response(data=response, status=web.HTTPOk.status_code)

View file

@ -1,43 +1,13 @@
import logging
import time
from datetime import UTC, datetime
from pathlib import Path
import anyio
from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item
from app.library.router import RouteType, route
from app.library.Utils import is_downloaded
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)
@route(RouteType.SOCKET, "pause", "pause_downloads")
async def pause(notify: EventBus, queue: DownloadQueue):
queue.pause()
await notify.emit(
Events.PAUSED,
data={"paused": True, "at": time.time()},
title="Downloads Paused",
message="Non-active downloads have been paused.",
)
@route(RouteType.SOCKET, "resume", "resume_downloads")
async def resume(notify: EventBus, queue: DownloadQueue):
queue.resume()
await notify.emit(
Events.RESUMED,
data={"paused": False, "at": time.time()},
title="Downloads Resumed",
message="Resumed all downloads.",
)
@route(RouteType.SOCKET, "add_url", "add_url")
async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
data = data if isinstance(data, dict) else {}
@ -79,57 +49,6 @@ async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: di
await queue.clear([id], remove_file=bool(data.get("remove_file", False)))
@route(RouteType.SOCKET, "archive_item", "archive_item")
async def archive_item(config: Config, data: dict):
if not isinstance(data, dict) or "url" not in data:
return
params: YTDLPOpts = YTDLPOpts.get_instance()
if "preset" in data and isinstance(data["preset"], str):
params.preset(name=data["preset"])
if "cli" in data and isinstance(data["cli"], str) and len(data["cli"]) > 1:
params.add_cli(data["cli"], from_user=True)
params = params.get_all()
file: str = params.get("download_archive", None)
if not file:
return
exists, idDict = is_downloaded(file, data["url"])
if exists or "archive_id" not in idDict or idDict["archive_id"] is None:
return
async with await anyio.open_file(file, "a") as f:
await f.write(f"{idDict['archive_id']}\n")
manual_archive: str = config.manual_archive
if not manual_archive:
return
manual_archive = Path(manual_archive)
if not manual_archive.exists():
manual_archive.touch(exist_ok=True)
previouslyArchived = False
async with await anyio.open_file(manual_archive) as f:
async for line in f:
if idDict["archive_id"] in line:
previouslyArchived = True
break
if not previouslyArchived:
async with await anyio.open_file(manual_archive, "a") as f:
await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n")
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
else:
LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.")
@route(RouteType.SOCKET, "item_start", "item_start")
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data:

View file

@ -39,6 +39,7 @@ dependencies = [
"apprise>=1.9.3",
"bgutil-ytdlp-pot-provider>=1.2.1",
"pycryptodome>=3.23.0",
"httpx-curl-cffi>=0.1.4",
]
[tool.ruff]

View file

@ -0,0 +1,67 @@
<template>
<Message :message_class="messageClass" :title="title" :icon="icon" :useClose="true" @close="() => dismissed = true"
v-if="!isDismissed">
<slot />
</Message>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useStorage } from '@vueuse/core'
import Message from "~/components/Message.vue";
const props = withDefaults(defineProps<{
version: string
storageKey?: string
title?: string
icon?: string
tone?: 'warning' | 'danger' | 'info' | 'success'
}>(), {
storageKey: 'deprecated-notice',
title: 'Deprecated Feature',
icon: 'fas fa-exclamation-triangle',
tone: 'warning',
})
const config = useConfigStore()
const isDev = computed(() => 'development' === config.app?.app_env)
const storageKeyComputed = computed<string>(() => `${props.storageKey}:${props.version}`)
const dismissed = useStorage<boolean>(storageKeyComputed, false)
const isDismissed = computed(() => dismissed.value)
const messageClass = computed(() => {
switch (props.tone) {
case 'danger':
return 'is-danger has-background-danger-90 has-text-dark'
case 'info':
return 'is-info has-background-info-90 has-text-dark'
case 'success':
return 'is-success has-background-success-90 has-text-dark'
case 'warning':
default:
return 'is-warning has-background-warning-90 has-text-dark'
}
})
onMounted(() => {
if (!isDev.value) {
return
}
document.addEventListener('keydown', handle_event)
})
onBeforeUnmount(() => {
if (!isDev.value) {
return
}
document.removeEventListener('keydown', handle_event)
})
const handle_event = (e: KeyboardEvent) => {
if (e.ctrlKey && e.altKey && 'd' === e.key.toLowerCase()) {
e.preventDefault()
dismissed.value = !dismissed.value
}
}
</script>

View file

@ -47,6 +47,7 @@ const emitter = defineEmits<{ (e: 'closeModel'): void }>()
const props = defineProps<{
link?: string
preset?: string
cli?: string
useUrl?: boolean
externalModel?: boolean
}>()
@ -71,13 +72,16 @@ onMounted(async (): Promise<void> => {
if (props.preset) {
params.append('preset', props.preset)
}
if (props.cli) {
params.append('args', props.cli)
}
params.append('url', props.link || '')
url += '?' + params.toString()
}
try {
isLoading.value = true
const response = await request(url, { credentials: 'include' })
const response = await request(url)
const body = await response.text()
try {

View file

@ -118,7 +118,8 @@
</span>
</div>
<div v-if="showThumbnails && item.extras.thumbnail">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
<FloatingImage
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
:title="`[${item.preset}] - ${item.title}`">
<div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
@ -138,11 +139,8 @@
</div>
</td>
<td class="is-vcentered has-text-centered is-unselectable">
<span class="icon-text">
<span class="icon" :class="setIconColor(item)"><i
:class="[setIcon(item), is_queued(item)]" /></span>
<span>{{ setStatus(item) }}</span>
</span>
<span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span>
<span>{{ setStatus(item) }}</span>
</td>
<td class="is-vcentered has-text-centered is-unselectable">
<span class="user-hint" :date-datetime="item.datetime"
@ -195,7 +193,7 @@
</NuxtLink>
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)">
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
@ -205,25 +203,23 @@
<span>Local Information</span>
</NuxtLink>
<template v-if="item.status != 'finished' || !item.filename">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Add to download form</span>
</NuxtLink>
</template>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Add to download form</span>
</NuxtLink>
<template v-if="'finished' !== item.status && config.app?.keep_archive">
<template v-if="item.is_archivable && !item.is_archived">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Archive Item</span>
<span>Add to archive</span>
</NuxtLink>
</template>
<template v-if="'finished' === item.status && item.filename && config.app?.keep_archive">
<template v-if="item.is_archivable && item.is_archived">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
<NuxtLink class="dropdown-item has-text-danger" @click="removeFromArchiveDialog(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Remove from archive</span>
</NuxtLink>
@ -276,20 +272,22 @@
<figure class="image is-3by1">
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
<div class="play-icon"></div>
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
<img @load="e => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" />
</span>
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay">
<div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
<img @load="e => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" />
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" />
</template>
</figure>
@ -297,10 +295,8 @@
<div class="card-content">
<div class="columns is-mobile is-multiline">
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon-text">
<span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span>
<span>{{ setStatus(item) }}</span>
</span>
<span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span>
<span>{{ setStatus(item) }}</span>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
@ -370,7 +366,7 @@
<hr class="dropdown-divider" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
v-if="!config.app.basic_mode">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
@ -382,15 +378,13 @@
<span>Local Information</span>
</NuxtLink>
<template v-if="item.status != 'finished' || !item.filename">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Add to download form</span>
</NuxtLink>
</template>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Add to download form</span>
</NuxtLink>
<template v-if="'finished' !== item.status && config.app?.keep_archive && !config.app.basic_mode">
<template v-if="item.is_archivable && !item.is_archived">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
@ -398,10 +392,9 @@
</NuxtLink>
</template>
<template
v-if="'finished' === item.status && item.filename && config.app?.keep_archive && !config.app.basic_mode">
<template v-if="item.is_archivable && item.is_archived">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
<NuxtLink class="dropdown-item has-text-danger" @click="removeFromArchiveDialog(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Remove from archive</span>
</NuxtLink>
@ -468,7 +461,7 @@ import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store'
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string): void
(e: 'getInfo', url: string, preset: string, cli: string): void
(e: 'add_new', item: Partial<StoreItem>): void
(e: 'getItemInfo', id: string): void
(e: 'clear_search'): void
@ -613,9 +606,6 @@ const deleteSelectedItems = () => {
continue
}
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
if ('finished' === item.status) {
socket.emit('archive_item', item)
}
socket.emit('item_delete', {
id: item._id,
remove_file: config.app.remove_files,
@ -739,10 +729,7 @@ const addArchiveDialog = (item: StoreItem) => {
const archiveItem = async (item: StoreItem, opts = {}) => {
try {
const req = await request(`/api/archive/${item._id}`, {
credentials: 'include',
method: 'POST',
})
const req = await request(`/api/history/${item._id}/archive`, { method: 'POST' })
const data = await req.json()
dialog_confirm.value.visible = false
if (!req.ok) {
@ -834,8 +821,7 @@ const downloadSelected = async () => {
try {
const response = await request('/api/file/download', {
method: 'POST',
credentials: 'include',
body: JSON.stringify(files_list),
body: JSON.stringify(files_list)
})
const json = await response.json()
if (!response.ok) {
@ -871,12 +857,8 @@ const removeFromArchiveDialog = (item: StoreItem) => {
}
const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean }) => {
console.log('Removing from archive:', item, opts)
try {
const req = await request(`/api/archive/${item._id}`, {
credentials: 'include',
method: 'DELETE',
})
const req = await request(`/api/history/${item._id}/archive`, { method: 'DELETE' })
const data = await req.json()
if (!req.ok) {
toast.error(data.error)

View file

@ -50,7 +50,7 @@ onMounted(async () => {
try {
isLoading.value = true
const imgRequest = await request(url, { credentials: 'include' })
const imgRequest = await request(url)
if (200 !== imgRequest.status) {
return
}

View file

@ -63,8 +63,9 @@
</label>
</div>
<div class="control is-expanded">
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder" placeholder="Default"
:disabled="!socket.isConnected || addInProgress" list="folders">
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder"
:placeholder="get_download_folder()" :disabled="!socket.isConnected || addInProgress"
list="folders">
</div>
</div>
</div>
@ -166,16 +167,11 @@
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset)">
<NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset, form.cli)">
<span class="icon has-text-info"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="removeFromArchive(form.url)">
<span class="icon has-text-warning"><i class="fa-solid fa-box-archive" /></span>
<span>Remove from archive</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
@ -193,7 +189,8 @@
</button>
</div>
<div class="control">
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)"
<button type="button" class="button is-info"
@click="emitter('getInfo', form.url, form.preset, form.cli)"
:class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-info" /></span>
@ -201,15 +198,6 @@
</button>
</div>
<div class="control">
<button type="button" class="button is-warning" @click="removeFromArchive(form.url)"
:class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Remove from archive</span>
</button>
</div>
<div class="control">
<button type="button" class="button is-danger" @click="resetConfig"
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
@ -247,7 +235,7 @@ import type { AutoCompleteOptions } from '~/types/autocomplete';
const props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string | undefined): void
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
(e: 'clear_form'): void
(e: 'remove_archive', url: string): void
}>()
@ -363,7 +351,6 @@ const addDownload = async () => {
try {
addInProgress.value = true
const response = await request('/api/history', {
credentials: 'include',
method: 'POST',
body: JSON.stringify(request_data),
})
@ -496,27 +483,6 @@ const filter_presets = (flag: boolean = true) => config.presets.filter(item => i
const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name)
const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap'])
const removeFromArchive = async (url: string) => {
try {
const req = await request(`/api/archive/0`, {
credentials: 'include',
method: 'DELETE',
body: JSON.stringify({ url }),
})
const data = await req.json()
if (!req.ok) {
toast.error(data.error)
return
}
toast.success(data.message ?? `Removed item from archive.`)
} catch (e: any) {
toast.error(`Error: ${e.message}`)
}
}
const get_output_template = () => {
if (form.value.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset)
@ -527,5 +493,15 @@ const get_output_template = () => {
return config.app.output_template || '%(title)s.%(ext)s'
}
const get_download_folder = (): string => {
if (form.value.preset && false === hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset)
if (preset?.folder) {
return preset.folder.replace(config.app.download_path, '')
}
}
return '/'
}
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
</script>

View file

@ -84,7 +84,8 @@
</span>
</div>
<div v-if="showThumbnails && item.extras?.thumbnail">
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
<FloatingImage
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
:title="item.title">
<div class="is-text-overflow">
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
@ -98,12 +99,10 @@
</template>
</td>
<td class="has-text-centered is-text-overflow is-unselectable">
<span class="icon-text">
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
</td>
<td>
<div class="progress-bar is-unselectable">
@ -151,7 +150,7 @@
<template v-if="!config.app.basic_mode">
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)">
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
</NuxtLink>
@ -206,13 +205,14 @@
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
class="play-overlay">
<div class="play-icon embed-icon"></div>
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
<img @load="e => pImg(e)"
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
v-if="item.extras?.thumbnail" />
<img v-else src="/images/placeholder.png" />
</span>
<template v-else>
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail"
:src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" />
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
<img v-else src="/images/placeholder.png" />
</template>
</figure>
@ -226,12 +226,10 @@
</div>
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon-text">
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
<span class="icon" :class="setIconColor(item)">
<i class="fas fa-solid" :class="setIcon(item)" />
</span>
<span v-text="setStatus(item)" />
</div>
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
@ -277,7 +275,7 @@
<hr class="dropdown-divider" v-if="!config.app.basic_mode" />
</template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
v-if="!config.app.basic_mode">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
@ -321,7 +319,7 @@ import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store'
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string): void
(e: 'getInfo', url: string, preset: string, cli: string): void
(e: 'getItemInfo', id: string): void
(e: 'clear_search'): void
}>()

View file

@ -510,7 +510,7 @@ const convert_url = async (url: string): Promise<string> => {
try {
convertInProgress.value = true
const resp = await request('/api/yt-dlp/url/info?' + params.toString(), { credentials: 'include' })
const resp = await request('/api/yt-dlp/url/info?' + params.toString())
const body = await resp.json()
const channel_id = ag(body, 'channel_id', null)
console.log('convert_url', { url, channel_id, body })

View file

@ -170,7 +170,7 @@ const filters = reactive({
const reload = async (): Promise<void> => {
try {
isLoading.value = true
const resp = await request('/api/yt-dlp/options', { credentials: 'include' })
const resp = await request('/api/yt-dlp/options')
if (!resp.ok) {
return
}

View file

@ -29,8 +29,7 @@
<span class="icon"><i class="fas fa-pause" /></span>
<span v-if="!isMobile">Pause</span>
</button>
<button class="button is-danger" @click="socket.emit('resume', {})" v-else
v-tooltip.bottom="'Resume downloading.'">
<button class="button is-danger" @click="resumeDownload" v-else v-tooltip.bottom="'Resume downloading.'">
<span class="icon"><i class="fas fa-play" /></span>
<span v-if="!isMobile">Resume</span>
</button>
@ -63,16 +62,50 @@
</div>
</div>
<div v-if="config.is_loaded" class="columns is-multiline">
<div class="column is-12">
<DeprecatedNotice :version="config.app.app_version" title="Deprecation Notice" tone="warning"
icon="fas fa-exclamation-triangle fa-fade fa-spin-10">
<p>
The following environment variables and features are deprecated and will be removed in future releases:
</p>
<ul>
<li>
The environment variables <code>YTP_KEEP_ARCHIVE</code> and <code>YTP_SOCKET_TIMEOUT</code> will no
longer be user-configurable. Their behavior will be part of the <strong>default presets</strong>. To keep
your current behavior <strong>and avoid re-downloading</strong>, please add the following <strong>Command
options for yt-dlp</strong> to your presets:
<code>--socket-timeout 30 --download-archive /config/archive.log</code>
</li>
<li>
The global yt-dlp config file <code>/config/ytdlp.cli</code> is deprecated and will be removed. Please
migrate any global options into your presets.
</li>
<li>
The <strong>Basic mode</strong> (which limited the interface to the new download form) is being removed.
Everything except what is available behind configurable flag will become part of the standard interface.
</li>
<li>The <strong>archive.manual.log</strong> feature has been removed.</li>
</ul>
<p>
These changes help reduce confusion from multiple sources of truth. Going forward, <strong>presets</strong>
and the <strong>Command options for yt-dlp</strong> will be the single source of truth.
</p>
</DeprecatedNotice>
</div>
</div>
<NewDownload v-if="config.showForm || config.app.basic_mode"
@getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :item="item_form"
@clear_form="item_form = {}" @remove_archive="" />
<Queue @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :thumbnails="show_thumbnail"
:query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
<History @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)"
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:item="item_form" @clear_form="item_form = {}" @remove_archive="" />
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''" />
<History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
@add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :useUrl="info_view.useUrl"
@closeModel="close_info()" />
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli"
:useUrl="info_view.useUrl" @closeModel="close_info()" />
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" />
@ -81,6 +114,7 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import DeprecatedNotice from '~/components/DeprecatedNotice.vue'
import type { item_request } from '~/types/item'
import type { StoreItem } from '~/types/store'
@ -95,8 +129,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const info_view = ref({
url: '',
preset: '',
cli: '',
useUrl: false,
}) as Ref<{ url: string, preset: string, useUrl: boolean }>
}) as Ref<{ url: string, preset: string, cli: string, useUrl: boolean }>
const item_form = ref<item_request | object>({})
const query = ref()
const toggleFilter = ref(false)
@ -139,6 +174,8 @@ watch(() => stateStore.queue, () => {
}, { deep: true })
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' })
const pauseDownload = () => {
dialog_confirm.value.visible = true
dialog_confirm.value.html_message = `
@ -153,8 +190,8 @@ const pauseDownload = () => {
<li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li>
</ul>
</span>`
dialog_confirm.value.confirm = () => {
socket.emit('pause', {})
dialog_confirm.value.confirm = async () => {
await request('/api/system/pause', { method: 'POST' })
dialog_confirm.value.visible = false
}
}
@ -165,10 +202,11 @@ const close_info = () => {
info_view.value.useUrl = false
}
const view_info = (url: string, useUrl: boolean = false, preset: string = '') => {
const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => {
info_view.value.url = url
info_view.value.useUrl = useUrl
info_view.value.preset = preset
info_view.value.cli = cli
}
watch(() => info_view.value.url, v => {

View file

@ -194,9 +194,14 @@
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)">
<NuxtLink class="dropdown-item" @click="archiveAll(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Archive all</span>
<span>Archive All</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="unarchiveAll(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Unarchive All</span>
</NuxtLink>
<hr class="dropdown-divider" />
@ -313,9 +318,14 @@
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)">
<NuxtLink class="dropdown-item" @click="archiveAll(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Archive all</span>
<span>Archive All</span>
</NuxtLink>
<NuxtLink class="dropdown-item" @click="unarchiveAll(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Unarchive All</span>
</NuxtLink>
</Dropdown>
</div>
@ -370,6 +380,7 @@ const box = useConfirm()
const toast = useNotification()
const config = useConfigStore()
const socket = useSocketStore()
const { confirmDialog: cDialog } = useDialog()
const display_style = useStorage<string>("tasks_display_style", "cards")
const tasks = ref<Array<task_item>>([])
@ -428,7 +439,7 @@ watch(() => config.app.basic_mode, async v => {
return
}
await navigateTo('/')
},{ immediate: true })
}, { immediate: true })
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
@ -764,17 +775,18 @@ const get_tags = (name: string): Array<string> => {
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
const archiveItems = async (item: task_item) => {
dialog_confirm.value.visible = true
dialog_confirm.value.title = 'Archive All videos'
dialog_confirm.value.message = `Archive all items for '${item.name}' task? This will mark all items as downloaded and update the archive file.`
dialog_confirm.value.confirm = async () => await archiveAll(item)
}
const archiveAll = async (item: task_item) => {
try {
dialog_confirm.value.visible = false
const { status } = await cDialog({
message: `Mark all '${item.name}' items as downloaded in download archive?`
})
if (true !== status) {
return;
}
item.in_progress = true
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'POST' })
const data = await response.json()
@ -791,4 +803,33 @@ const archiveAll = async (item: task_item) => {
item.in_progress = false
}
}
const unarchiveAll = async (item: task_item) => {
try {
const { status } = await cDialog({
message: `Remove all '${item.name}' items from download archive?`
})
if (true !== status) {
return;
}
item.in_progress = true
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'DELETE' })
const data = await response.json()
if (data?.error) {
toast.error(data.error)
return
}
toast.success(data.message)
} catch (e: any) {
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
return
} finally {
item.in_progress = false
}
}
</script>

View file

@ -45,7 +45,9 @@ type AppConfig = {
/** App branch name, e.g. "main" or "develop" */
app_branch: string
/** When the app started */
started: number
started: number,
/** Application environment, e.g. "production", "development" */
app_env: string
}
type Preset = {

View file

@ -82,6 +82,12 @@ type StoreItem = {
speed?: number | null
/** Time remaining for the item download if available */
eta?: number | null
/** If the item can be archived */
is_archivable?: boolean
/** If the item is archived */
is_archived?: boolean
/** Item archive ID */
archive_id?: string | null
}
export type { ItemStatus, StoreItem }

View file

@ -12,20 +12,20 @@
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.11.2",
"@sentry/nuxt": "^10.5.0",
"@vueuse/core": "^13.7.0",
"@vueuse/nuxt": "^13.7.0",
"@sentry/nuxt": "^10.8.0",
"@vueuse/core": "^13.8.0",
"@vueuse/nuxt": "^13.8.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.0",
"cronstrue": "^3.2.0",
"floating-vue": "^5.2.2",
"hls.js": "^1.6.10",
"hls.js": "^1.6.11",
"moment": "^2.30.1",
"nuxt": "^4.0.3",
"pinia": "^3.0.3",
"socket.io-client": "^4.8.1",
"vue": "^3.5.19",
"vue": "^3.5.20",
"vue-router": "^4.5.1",
"vue-toastification": "2.0.0-rc.5"
},

File diff suppressed because it is too large Load diff

156
uv.lock
View file

@ -591,6 +591,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "httpx-curl-cffi"
version = "0.1.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "curl-cffi" },
{ name = "httpx" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/e9/42542f3f4bf9ba457f10dda6620dc3f56e77480722c67579a35956c5d2e2/httpx_curl_cffi-0.1.4.tar.gz", hash = "sha256:1e00b741b121b3781942d45781288f89842f41fe32a743e27672d7fc542c1cd1", size = 7907 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/25/7a093edd86d0ea2cc0ff5570f74b00c603ce100303031a77f7d9db9aa80a/httpx_curl_cffi-0.1.4-py3-none-any.whl", hash = "sha256:bbd6e89abbf26a37c436d9a3d2c55ae646b78b0dcf4bc1be2f2451912e39d7b6", size = 8913 },
]
[[package]]
name = "humanfriendly"
version = "10.0"
@ -744,11 +758,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.3.8"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 }
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 },
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654 },
]
[[package]]
@ -1103,66 +1117,66 @@ wheels = [
[[package]]
name = "regex"
version = "2025.7.34"
version = "2025.8.29"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/de/e13fa6dc61d78b30ba47481f99933a3b49a57779d625c392d8036770a60d/regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a", size = 400714 }
sdist = { url = "https://files.pythonhosted.org/packages/e4/10/2d333227cf5198eb3252f2d50c8ade5cd2015f11c22403f0c9e3d529e81a/regex-2025.8.29.tar.gz", hash = "sha256:731ddb27a0900fa227dfba976b4efccec8c1c6fba147829bb52e71d49e91a5d7", size = 400817 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/85/f497b91577169472f7c1dc262a5ecc65e39e146fc3a52c571e5daaae4b7d/regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8", size = 484594 },
{ url = "https://files.pythonhosted.org/packages/1c/c5/ad2a5c11ce9e6257fcbfd6cd965d07502f6054aaa19d50a3d7fd991ec5d1/regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a", size = 289294 },
{ url = "https://files.pythonhosted.org/packages/8e/01/83ffd9641fcf5e018f9b51aa922c3e538ac9439424fda3df540b643ecf4f/regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68", size = 285933 },
{ url = "https://files.pythonhosted.org/packages/77/20/5edab2e5766f0259bc1da7381b07ce6eb4401b17b2254d02f492cd8a81a8/regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78", size = 792335 },
{ url = "https://files.pythonhosted.org/packages/30/bd/744d3ed8777dce8487b2606b94925e207e7c5931d5870f47f5b643a4580a/regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719", size = 858605 },
{ url = "https://files.pythonhosted.org/packages/99/3d/93754176289718d7578c31d151047e7b8acc7a8c20e7706716f23c49e45e/regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33", size = 905780 },
{ url = "https://files.pythonhosted.org/packages/ee/2e/c689f274a92deffa03999a430505ff2aeace408fd681a90eafa92fdd6930/regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083", size = 798868 },
{ url = "https://files.pythonhosted.org/packages/0d/9e/39673688805d139b33b4a24851a71b9978d61915c4d72b5ffda324d0668a/regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3", size = 781784 },
{ url = "https://files.pythonhosted.org/packages/18/bd/4c1cab12cfabe14beaa076523056b8ab0c882a8feaf0a6f48b0a75dab9ed/regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d", size = 852837 },
{ url = "https://files.pythonhosted.org/packages/cb/21/663d983cbb3bba537fc213a579abbd0f263fb28271c514123f3c547ab917/regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd", size = 844240 },
{ url = "https://files.pythonhosted.org/packages/8e/2d/9beeeb913bc5d32faa913cf8c47e968da936af61ec20af5d269d0f84a100/regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a", size = 787139 },
{ url = "https://files.pythonhosted.org/packages/eb/f5/9b9384415fdc533551be2ba805dd8c4621873e5df69c958f403bfd3b2b6e/regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1", size = 264019 },
{ url = "https://files.pythonhosted.org/packages/18/9d/e069ed94debcf4cc9626d652a48040b079ce34c7e4fb174f16874958d485/regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a", size = 276047 },
{ url = "https://files.pythonhosted.org/packages/fd/cf/3bafbe9d1fd1db77355e7fbbbf0d0cfb34501a8b8e334deca14f94c7b315/regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0", size = 268362 },
{ url = "https://files.pythonhosted.org/packages/ff/f0/31d62596c75a33f979317658e8d261574785c6cd8672c06741ce2e2e2070/regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50", size = 485492 },
{ url = "https://files.pythonhosted.org/packages/d8/16/b818d223f1c9758c3434be89aa1a01aae798e0e0df36c1f143d1963dd1ee/regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f", size = 290000 },
{ url = "https://files.pythonhosted.org/packages/cd/70/69506d53397b4bd6954061bae75677ad34deb7f6ca3ba199660d6f728ff5/regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130", size = 286072 },
{ url = "https://files.pythonhosted.org/packages/b0/73/536a216d5f66084fb577bb0543b5cb7de3272eb70a157f0c3a542f1c2551/regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46", size = 797341 },
{ url = "https://files.pythonhosted.org/packages/26/af/733f8168449e56e8f404bb807ea7189f59507cbea1b67a7bbcd92f8bf844/regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4", size = 862556 },
{ url = "https://files.pythonhosted.org/packages/19/dd/59c464d58c06c4f7d87de4ab1f590e430821345a40c5d345d449a636d15f/regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0", size = 910762 },
{ url = "https://files.pythonhosted.org/packages/37/a8/b05ccf33ceca0815a1e253693b2c86544932ebcc0049c16b0fbdf18b688b/regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b", size = 801892 },
{ url = "https://files.pythonhosted.org/packages/5f/9a/b993cb2e634cc22810afd1652dba0cae156c40d4864285ff486c73cd1996/regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01", size = 786551 },
{ url = "https://files.pythonhosted.org/packages/2d/79/7849d67910a0de4e26834b5bb816e028e35473f3d7ae563552ea04f58ca2/regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77", size = 856457 },
{ url = "https://files.pythonhosted.org/packages/91/c6/de516bc082524b27e45cb4f54e28bd800c01efb26d15646a65b87b13a91e/regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da", size = 848902 },
{ url = "https://files.pythonhosted.org/packages/7d/22/519ff8ba15f732db099b126f039586bd372da6cd4efb810d5d66a5daeda1/regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282", size = 788038 },
{ url = "https://files.pythonhosted.org/packages/3f/7d/aabb467d8f57d8149895d133c88eb809a1a6a0fe262c1d508eb9dfabb6f9/regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588", size = 264417 },
{ url = "https://files.pythonhosted.org/packages/3b/39/bd922b55a4fc5ad5c13753274e5b536f5b06ec8eb9747675668491c7ab7a/regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62", size = 275387 },
{ url = "https://files.pythonhosted.org/packages/f7/3c/c61d2fdcecb754a40475a3d1ef9a000911d3e3fc75c096acf44b0dfb786a/regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176", size = 268482 },
{ url = "https://files.pythonhosted.org/packages/15/16/b709b2119975035169a25aa8e4940ca177b1a2e25e14f8d996d09130368e/regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5", size = 485334 },
{ url = "https://files.pythonhosted.org/packages/94/a6/c09136046be0595f0331bc58a0e5f89c2d324cf734e0b0ec53cf4b12a636/regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd", size = 289942 },
{ url = "https://files.pythonhosted.org/packages/36/91/08fc0fd0f40bdfb0e0df4134ee37cfb16e66a1044ac56d36911fd01c69d2/regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b", size = 285991 },
{ url = "https://files.pythonhosted.org/packages/be/2f/99dc8f6f756606f0c214d14c7b6c17270b6bbe26d5c1f05cde9dbb1c551f/regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad", size = 797415 },
{ url = "https://files.pythonhosted.org/packages/62/cf/2fcdca1110495458ba4e95c52ce73b361cf1cafd8a53b5c31542cde9a15b/regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59", size = 862487 },
{ url = "https://files.pythonhosted.org/packages/90/38/899105dd27fed394e3fae45607c1983e138273ec167e47882fc401f112b9/regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415", size = 910717 },
{ url = "https://files.pythonhosted.org/packages/ee/f6/4716198dbd0bcc9c45625ac4c81a435d1c4d8ad662e8576dac06bab35b17/regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f", size = 801943 },
{ url = "https://files.pythonhosted.org/packages/40/5d/cff8896d27e4e3dd11dd72ac78797c7987eb50fe4debc2c0f2f1682eb06d/regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1", size = 786664 },
{ url = "https://files.pythonhosted.org/packages/10/29/758bf83cf7b4c34f07ac3423ea03cee3eb3176941641e4ccc05620f6c0b8/regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c", size = 856457 },
{ url = "https://files.pythonhosted.org/packages/d7/30/c19d212b619963c5b460bfed0ea69a092c6a43cba52a973d46c27b3e2975/regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a", size = 849008 },
{ url = "https://files.pythonhosted.org/packages/9e/b8/3c35da3b12c87e3cc00010ef6c3a4ae787cff0bc381aa3d251def219969a/regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0", size = 788101 },
{ url = "https://files.pythonhosted.org/packages/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401 },
{ url = "https://files.pythonhosted.org/packages/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368 },
{ url = "https://files.pythonhosted.org/packages/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482 },
{ url = "https://files.pythonhosted.org/packages/ac/23/6376f3a23cf2f3c00514b1cdd8c990afb4dfbac3cb4a68b633c6b7e2e307/regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a", size = 485385 },
{ url = "https://files.pythonhosted.org/packages/73/5b/6d4d3a0b4d312adbfd6d5694c8dddcf1396708976dd87e4d00af439d962b/regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435", size = 289788 },
{ url = "https://files.pythonhosted.org/packages/92/71/5862ac9913746e5054d01cb9fb8125b3d0802c0706ef547cae1e7f4428fa/regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac", size = 286136 },
{ url = "https://files.pythonhosted.org/packages/27/df/5b505dc447eb71278eba10d5ec940769ca89c1af70f0468bfbcb98035dc2/regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72", size = 797753 },
{ url = "https://files.pythonhosted.org/packages/86/38/3e3dc953d13998fa047e9a2414b556201dbd7147034fbac129392363253b/regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e", size = 863263 },
{ url = "https://files.pythonhosted.org/packages/68/e5/3ff66b29dde12f5b874dda2d9dec7245c2051f2528d8c2a797901497f140/regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751", size = 910103 },
{ url = "https://files.pythonhosted.org/packages/9e/fe/14176f2182125977fba3711adea73f472a11f3f9288c1317c59cd16ad5e6/regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4", size = 801709 },
{ url = "https://files.pythonhosted.org/packages/5a/0d/80d4e66ed24f1ba876a9e8e31b709f9fd22d5c266bf5f3ab3c1afe683d7d/regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98", size = 786726 },
{ url = "https://files.pythonhosted.org/packages/12/75/c3ebb30e04a56c046f5c85179dc173818551037daae2c0c940c7b19152cb/regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7", size = 857306 },
{ url = "https://files.pythonhosted.org/packages/b1/b2/a4dc5d8b14f90924f27f0ac4c4c4f5e195b723be98adecc884f6716614b6/regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47", size = 848494 },
{ url = "https://files.pythonhosted.org/packages/0d/21/9ac6e07a4c5e8646a90b56b61f7e9dac11ae0747c857f91d3d2bc7c241d9/regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e", size = 787850 },
{ url = "https://files.pythonhosted.org/packages/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730 },
{ url = "https://files.pythonhosted.org/packages/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640 },
{ url = "https://files.pythonhosted.org/packages/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757 },
{ url = "https://files.pythonhosted.org/packages/ef/a2/e9b9ce5407af9147dc39a7de4f161fd72804c095ea398ab472e8dbc65533/regex-2025.8.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:156f711019968ffb3512723a38b06d94d379675c296bdb6104d1abb6e57374c6", size = 484663 },
{ url = "https://files.pythonhosted.org/packages/f1/7c/5b2cf5f1350c1c218542fb0be89cf28d8375ebe240cb5769f108325eb285/regex-2025.8.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9082c0db8d43c696fac70b5b0592934f21533940f0118239b5c32fa23e51ed1a", size = 289365 },
{ url = "https://files.pythonhosted.org/packages/1c/27/44733d2aa3b0c9532580872e9ed2df6a86fe7b975b75dc1f1733f6751e55/regex-2025.8.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3535b9a69a818735ebac392876dae4b215fe28c13b145353a2dac468ebae16", size = 286007 },
{ url = "https://files.pythonhosted.org/packages/b9/ac/2d4f6904422b95f22d1548d8655b288837f3218b54853c6050de61a87b7e/regex-2025.8.29-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c460628f6098cf8916b2d62fb39a37a39e49cca0279ac301ff9d94f7e75033e", size = 792412 },
{ url = "https://files.pythonhosted.org/packages/a1/61/8f67415c0ad59abf8f4dd24ad9de504eb37c363318f757be35c42b537d66/regex-2025.8.29-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dad3ce46390fe3d81ae1c131e29179f010925fa164e15b918fb037effdb7ad9", size = 858682 },
{ url = "https://files.pythonhosted.org/packages/fb/31/c3552278e507ab255c51dce4dda0072252e78c801a16697085e71595b1c7/regex-2025.8.29-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f89e5beb3012d3c36c526fd4af163ada24011a0b417378f726b17c2fb382a35d", size = 905855 },
{ url = "https://files.pythonhosted.org/packages/ab/84/5150fdffe83df17a7b869930c06d8007b890be3fdf6eb509b849431cabeb/regex-2025.8.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40eeff06bbcfa69201b60488f3f3aa38ad3c92c7c0ab2cfc7c9599abfdf24262", size = 798943 },
{ url = "https://files.pythonhosted.org/packages/89/bc/695f94a6fada1838adc75312512843f8d9d94eda71c253958fb40bba5083/regex-2025.8.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d7a9bc68610d22735b6ac01a3c3ef5b03d9303a18bd3e2249340213389f273dc", size = 781859 },
{ url = "https://files.pythonhosted.org/packages/11/8e/641b228837f551c129bc03005a158c48aebb353a1f6a34dfcea025b5e4bc/regex-2025.8.29-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e785e40f7edfc19ff0b81b27f25eefdb0251cfd2ac4a9fa1eea03f5129e93758", size = 852914 },
{ url = "https://files.pythonhosted.org/packages/0c/49/b8d55dffd138369ee8378830b3bad4f7b815517df5ad16212031521f966f/regex-2025.8.29-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba1deae2ceaa0b181ac9fd4cb8f04d6ba1494f3c8d053c8999f7c0dadb93497b", size = 844314 },
{ url = "https://files.pythonhosted.org/packages/f7/73/48b6b616fdc1b6dc75a00c2670da7038400796c855b7bd0fbd4dad18c26c/regex-2025.8.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15869e4f36de7091342e1dae90216aafa3746e3a069f30b34503a36931036f95", size = 787215 },
{ url = "https://files.pythonhosted.org/packages/65/af/38af20de8ea862c5275da67d5a0e63023a92cc5df344ad9a80fc1fcd448e/regex-2025.8.29-cp311-cp311-win32.whl", hash = "sha256:aef62e0b08b0e3c2616783a9f75a02f001254695a0a1d28b829dc9fb6a3603e4", size = 264088 },
{ url = "https://files.pythonhosted.org/packages/84/d9/f765e5d9eaaa67e10267662002aea786334176c2b22066437df6d73a6424/regex-2025.8.29-cp311-cp311-win_amd64.whl", hash = "sha256:fd347592a4811ba1d246f99fb53db82a1898a5aebb511281ac0c2d81632e1789", size = 276119 },
{ url = "https://files.pythonhosted.org/packages/87/cd/44da9fae9a0c1af09f7171facc8d6313b1cbdfeea9f3526607495a28bdd7/regex-2025.8.29-cp311-cp311-win_arm64.whl", hash = "sha256:d93801012bb23901df403ae0adf528abfd50041c9e1136a303937d45c14466e0", size = 268429 },
{ url = "https://files.pythonhosted.org/packages/e3/a0/8c37d276a80ffda94f7e019e50cc88f898015512c7f104e49f1a0a6d3c59/regex-2025.8.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd61f18dc4446bc3a2904559a61f32e98091cef7fb796e06fa35b9bfefe4c0c5", size = 485565 },
{ url = "https://files.pythonhosted.org/packages/5d/34/baf5963bec36ac250fa242f0f0e7670f013de5004db6caa31c872981df42/regex-2025.8.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f21b416be10a8348a7313ba8c610569a1ab4bf8ec70731750540842a4551cd3d", size = 290073 },
{ url = "https://files.pythonhosted.org/packages/24/29/c5c18143cd60b736d7ff8acece126118fe5649f45a7a8db18e308f5f813d/regex-2025.8.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:008947a7fa92f4cb3b28201c9aa7becc0a44c31a7c2fcb934356e1877baccc09", size = 286144 },
{ url = "https://files.pythonhosted.org/packages/86/7c/0d90b687d2a33fe28b201f85ddfde6b378bf41677aedbe23eb7dc79385aa/regex-2025.8.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78ab1b3e68b890d7ebd69218cfbfe4a09dc00b8a47be8648510b81b932d55ff", size = 797417 },
{ url = "https://files.pythonhosted.org/packages/fb/67/c391c899e5ef274c4dd4ede029ffb853ddf5ba77aa251be02cfe3810574c/regex-2025.8.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a848368797515bc141d3fad5fd2d81bf9e8a6a22d9ac1a4be4690dd22e997854", size = 862630 },
{ url = "https://files.pythonhosted.org/packages/08/20/ae749a68da3496a133836c8724649bd2e004fc176c7c6647d9cb269cc975/regex-2025.8.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8eaf3ea6631f804efcf0f5bd0e4ab62ba984fd9b70e3aef44b05cc6b951cc728", size = 910837 },
{ url = "https://files.pythonhosted.org/packages/e2/80/bc4244ec79fba4185fd3a29d79f77f79b3b0dc12ee426687501b0b077e2a/regex-2025.8.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4561aeb36b0bf3bb44826e4b61a80c6ace0d8839bf4914d78f061f9ba61444b4", size = 801968 },
{ url = "https://files.pythonhosted.org/packages/ef/bd/a2d75042bb1d3c9997e22bc0051cb9791a405589d6293c874f7c2ba487e7/regex-2025.8.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:93e077d1fbd24033fa427eab43d80ad47e449d25700cda78e8cac821a30090bf", size = 786626 },
{ url = "https://files.pythonhosted.org/packages/24/ab/19cec75bf7d335cc7595d4857591455de118f6bfb563e6731c31f4fe33c3/regex-2025.8.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d92379e53d782bdb773988687300e3bccb91ad38157b754b04b1857aaeea16a3", size = 856532 },
{ url = "https://files.pythonhosted.org/packages/b6/3d/517cd0b0f4b8330164d03ef0eafdd61ee839f82b891fcd8c571d5c727117/regex-2025.8.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d41726de2040c2a487bbac70fdd6e3ff2f1aa47dc91f0a29f6955a6dfa0f06b6", size = 848977 },
{ url = "https://files.pythonhosted.org/packages/ae/fc/b57e2644d87d038d7302f359f4042bf7092bd8259a3ae999adf236e6fbc0/regex-2025.8.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1915dfda52bd4d466f3a66b66988db1f647ee1d9c605858640ceeb779cffd908", size = 788112 },
{ url = "https://files.pythonhosted.org/packages/a9/2f/70737feddbd33ec9f3f0cb8b38e7fc89304eccc80fd693d79a6f336e2282/regex-2025.8.29-cp312-cp312-win32.whl", hash = "sha256:e2ef0087ad6949918836f215480a9331f6c59ad54912a9a412f08ab1c9ccbc98", size = 264487 },
{ url = "https://files.pythonhosted.org/packages/2f/f5/8832d05ecc5a7f80043e7521ea55adfa2d9b9ac0e646474153e7e13722c2/regex-2025.8.29-cp312-cp312-win_amd64.whl", hash = "sha256:c15d361fe9800bf38ef69c2e0c4b8b961ae4ce2f076fcf4f28e1fc9ea127f55a", size = 275455 },
{ url = "https://files.pythonhosted.org/packages/a5/f9/f10ae0c4e5e22db75dda155d83056e2b70c4e87b04ad9838723ff5057e90/regex-2025.8.29-cp312-cp312-win_arm64.whl", hash = "sha256:305577fab545e64fb84d9a24269aa3132dbe05e1d7fa74b3614e93ec598fe6e6", size = 268558 },
{ url = "https://files.pythonhosted.org/packages/42/db/2f0e1fbca855f3c519f3f8198817d14a9569ca939bc0cc86efd4da196d3e/regex-2025.8.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:eed02e5c39f91268ea4ddf68ee19eed189d57c605530b7d32960f54325c52e7a", size = 485405 },
{ url = "https://files.pythonhosted.org/packages/15/ed/52afe839607719750acc87d144ec3db699adb9c1f40ecb6fa9f3700437b6/regex-2025.8.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:630d5c7e0a490db2fee3c7b282c8db973abcbb036a6e4e6dc06c4270965852be", size = 290014 },
{ url = "https://files.pythonhosted.org/packages/da/84/beb3becb129e41ae3e6bacd737aa751228ec0c17c707b9999648f050968c/regex-2025.8.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2206d3a30469e8fc8848139884168127f456efbaca8ae14809c26b98d2be15c6", size = 286059 },
{ url = "https://files.pythonhosted.org/packages/44/31/74476ac68cd5ed46634683cba634ab0885e917624d620c5959f67835554b/regex-2025.8.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394c492c398a9f9e17545e19f770c58b97e65963eedaa25bb879e80a03e2b327", size = 797490 },
{ url = "https://files.pythonhosted.org/packages/3f/97/1a8d109f891c4af31f43295304a51b76bc7aef4ce6d7953e4832f86c85f0/regex-2025.8.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db8b0e05af08ff38d78544950e844b5f159032b66dedda19b3f9b17297248be7", size = 862562 },
{ url = "https://files.pythonhosted.org/packages/1b/a8/13d6ea4b8a0c7eed0e528dcb25cbdc3bc53e26b0928dc48d6c0381516c4a/regex-2025.8.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd7c1821eff911917c476d41030b422791ce282c23ee9e1b8f7681fd0993f1e4", size = 910790 },
{ url = "https://files.pythonhosted.org/packages/10/b3/1c7320c1fdc6569a086949d2c5b7b742696098c28a6c83ca909b8d36d17b/regex-2025.8.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b4d8a7f75da748a2d0c045600259f1899c9dd8dd9d3da1daa50bf534c3fa5ba", size = 802016 },
{ url = "https://files.pythonhosted.org/packages/7a/b5/f3613b70a569b6309cd2a61ae869407b45cff25c9734f5ff179b416e9615/regex-2025.8.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5cd74545c32e0da0d489c2293101a82f4a1b88050c235e45509e4123017673b2", size = 786740 },
{ url = "https://files.pythonhosted.org/packages/e0/8a/9f16babae23011acbd27f886c4817159508f4f3209bcfce4bc2b8f12f2ba/regex-2025.8.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:97b98ea38fc3c1034f3d7bd30288d2c5b3be8cdcd69e2061d1c86cb14644a27b", size = 856533 },
{ url = "https://files.pythonhosted.org/packages/4d/d0/adca6eec8ed79541edadecf8b512d7a3960c2ba983d2e5baf68dbddd7a90/regex-2025.8.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8decb26f271b989d612c5d99db5f8f741dcd63ece51c59029840070f5f9778bf", size = 849083 },
{ url = "https://files.pythonhosted.org/packages/46/cc/37fddb2a17cefffb43b9dfd5f585a6cd6f90ee5b32c821886d0c0c3bc243/regex-2025.8.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62141843d1ec079cd66604424af566e542e7e072b2d9e37165d414d2e6e271dd", size = 788177 },
{ url = "https://files.pythonhosted.org/packages/f5/ea/413fe88ce5ac2418223434aa1603d92134b74deed6007dc6e4c37d83bbcd/regex-2025.8.29-cp313-cp313-win32.whl", hash = "sha256:dd23006c90d9ff0c2e4e5f3eaf8233dcefe45684f2acb330869ec5c2aa02b1fb", size = 264473 },
{ url = "https://files.pythonhosted.org/packages/5a/73/d07bc1d1969e41bf1637a8aad4228da506747f4c94415ef03c534c7d68d6/regex-2025.8.29-cp313-cp313-win_amd64.whl", hash = "sha256:d41a71342819bdfe87c701f073a14ea4bd3f847333d696c7344e9ff3412b7f70", size = 275438 },
{ url = "https://files.pythonhosted.org/packages/86/cd/2e05fc85ebee6fe6c5073c9b0c737a473c226422d75e93903810b247a9fe/regex-2025.8.29-cp313-cp313-win_arm64.whl", hash = "sha256:54018e66344d60b214f4aa151c046e0fa528221656f4f7eba5a787ccc7057312", size = 268553 },
{ url = "https://files.pythonhosted.org/packages/2e/2d/2aa4b98231017994ea52d05c13997778af415f5d7faa7f90988a640dac44/regex-2025.8.29-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c03308757831a8d89e7c007abb75d1d4c9fbca003b5fb32755d4475914535f08", size = 485447 },
{ url = "https://files.pythonhosted.org/packages/b7/b4/ed3241bb99a0783fe650d8511924c7c43f704b720fab3e353393bea8c96a/regex-2025.8.29-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0d4b71791975fc203e0e6c50db974abb23a8df30729c1ac4fd68c9f2bb8c9358", size = 289862 },
{ url = "https://files.pythonhosted.org/packages/ba/f6/5237a7d0b2bd64bb216d06470549bc4cc33de57033772e3018708636a027/regex-2025.8.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:284fcd2dcb613e8b89b22a30cf42998c9a73ee360b8a24db8457d24f5c42282e", size = 286211 },
{ url = "https://files.pythonhosted.org/packages/58/eb/05568fdc4028d1b339fb950fe6b92ade2613edd6423291939c8e29b21e8a/regex-2025.8.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b394b5157701b22cf63699c792bfeed65fbfeacbd94fea717a9e2036a51148ab", size = 797826 },
{ url = "https://files.pythonhosted.org/packages/3d/2a/a3c1c209faa1f6a218e64c5a235e06f6f36c45b5aa924c6bf75241a996f7/regex-2025.8.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea197ac22396faf5e70c87836bb89f94ed5b500e1b407646a4e5f393239611f1", size = 863338 },
{ url = "https://files.pythonhosted.org/packages/dd/66/5e96f217662387742c0d9732e97129850bd3243e019309c1fbdcd62b5421/regex-2025.8.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:decd84f195c08b3d9d0297a7e310379aae13ca7e166473534508c81b95c74bba", size = 910176 },
{ url = "https://files.pythonhosted.org/packages/fc/f2/975e77333267f9652bc2cc926382d8c9d86683eb84d1989459e644ac818b/regex-2025.8.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebaf81f7344dbf1a2b383e35923648de8f78fb262cf04154c82853887ac3e684", size = 801784 },
{ url = "https://files.pythonhosted.org/packages/75/d9/b25dbf9729b5a5958a804e91b376fe8e829ec10c0d7edb4b1ad91070132b/regex-2025.8.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d82fb8a97e5ed8f1d3ed7f8e0e7fe1760faa95846c0d38b314284dfdbe86b229", size = 786799 },
{ url = "https://files.pythonhosted.org/packages/1d/0a/7f8de7ea41d7a3a21dfcb9dcea7b727fdde9e35d74a23e16ef5edcd68005/regex-2025.8.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1dcec2448ed0062f63e82ca02d1d05f74d4127cb6a9d76a73df60e81298d380b", size = 857380 },
{ url = "https://files.pythonhosted.org/packages/f8/40/494600424c394a507070b41fc0666ceaa7dccf62c3220a76833eb11de647/regex-2025.8.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d0ffe4a3257a235f9d39b99c6f1bc53c7a4b11f28565726b1aa00a5787950d60", size = 848570 },
{ url = "https://files.pythonhosted.org/packages/be/d0/6988feb7c15bb3df7b944a10b3b58fb238c94987c70a991ba87e3685e1cd/regex-2025.8.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5421a2d2026e8189500f12375cfd80a9a1914466d446edd28b37eb33c1953b39", size = 787926 },
{ url = "https://files.pythonhosted.org/packages/98/16/d719b131b0577a2a975376b3e673fc7f89b9998d54753f0419d59d33b3a1/regex-2025.8.29-cp314-cp314-win32.whl", hash = "sha256:ceeeaab602978c8eac3b25b8707f21a69c0bcd179d9af72519da93ef3966158f", size = 269805 },
{ url = "https://files.pythonhosted.org/packages/a5/b7/50d3bb5df25ae73e7aee186a2f1e4f1ed5e4d54006bdf5abd558c1ce9e7a/regex-2025.8.29-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f8b0d5b88c33fe4060e6def58001fd8334b03c7ce2126964fa8851ab5d1b", size = 278710 },
{ url = "https://files.pythonhosted.org/packages/0f/34/c723ebe214c33000b53e0eebdc63ad3697d5611c7fa9b388eef2113a5e82/regex-2025.8.29-cp314-cp314-win_arm64.whl", hash = "sha256:7b4a3dc155984f09a55c64b90923cb136cd0dad21ca0168aba2382d90ea4c546", size = 271832 },
]
[[package]]
@ -1234,11 +1248,11 @@ wheels = [
[[package]]
name = "typing-extensions"
version = "4.14.1"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 },
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]
[[package]]
@ -1367,11 +1381,11 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.8.22"
version = "2025.8.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/98/b077bebdc5c759a3f7af3ed3a2a5345ad1145c61963b476469b840ac84ce/yt_dlp-2025.8.22.tar.gz", hash = "sha256:d1846bbb7edbcd2a0d4a2d76c7a2124868de9ea3b3959a8cb8219e3f7cb5c335", size = 3037631 }
sdist = { url = "https://files.pythonhosted.org/packages/f4/d4/d9dd231b03f09fdfb5f0fe70f30de0b5f59454aa54fa6b2b2aea49404988/yt_dlp-2025.8.27.tar.gz", hash = "sha256:ed74768d2a93b29933ab14099da19497ef571637f7aa375140dd3d882b9c1854", size = 3038374 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/06/e9e5e85969bd85142b004577915f33356ee0d5e20b79af8dd40b8bbfc96f/yt_dlp-2025.8.22-py3-none-any.whl", hash = "sha256:b8c71fe4516170dea60c6e5c54e2d45654693b8dc273cad060f22199476ec979", size = 3266783 },
{ url = "https://files.pythonhosted.org/packages/cb/9c/b69fc0c800f80b94ea2f8eff1d1f473fecee6aa337681d297ba7c7c5d3fd/yt_dlp-2025.8.27-py3-none-any.whl", hash = "sha256:0b8fd3bb7c54bc2e7ecb5cdac7d64c30e2503ea4d3dd9ae24d4f09e22aaa95f4", size = 3267059 },
]
[[package]]
@ -1394,6 +1408,7 @@ dependencies = [
{ name = "debugpy" },
{ name = "defusedxml" },
{ name = "httpx" },
{ name = "httpx-curl-cffi" },
{ name = "multidict" },
{ name = "mutagen" },
{ name = "platformdirs" },
@ -1432,6 +1447,7 @@ requires-dist = [
{ name = "debugpy", specifier = ">=1.8.1" },
{ name = "defusedxml", specifier = ">=0.7.1" },
{ name = "httpx" },
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
{ name = "multidict", specifier = "==6.5.1" },
{ name = "mutagen" },
{ name = "platformdirs" },
@ -1452,9 +1468,9 @@ provides-extras = ["installer"]
[[package]]
name = "zipstream-ng"
version = "1.8.0"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/16/5d9224baf640214255c34a0a0e9528c8403d2b89e2ba7df9d7cada58beb1/zipstream_ng-1.8.0.tar.gz", hash = "sha256:b7129d2c15d26934b3e1cb22256593b6bdbd03c553c26f4199a5bf05110642bc", size = 35887 }
sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/81/11ecdfd5370d6c383f0188a6f2fa2842499e1be617e678d1845f972c6821/zipstream_ng-1.8.0-py3-none-any.whl", hash = "sha256:e7196cb845cf924ed12e7a3b38404ef9e82a5a699801295f5f4cf601449e2bf6", size = 23082 },
{ url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852 },
]