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", "timespec",
"tmpfilename", "tmpfilename",
"ungroup", "ungroup",
"unmark",
"unnegated", "unnegated",
"unpickleable", "unpickleable",
"Unraid", "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) - [POST /api/history/{id}](#post-apihistoryid)
- [GET /api/history/{id}](#get-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid)
- [GET /api/history](#get-apihistory) - [GET /api/history](#get-apihistory)
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [GET /api/tasks](#get-apitasks) - [GET /api/tasks](#get-apitasks)
- [PUT /api/tasks](#put-apitasks) - [PUT /api/tasks](#put-apitasks)
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8) - [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8) - [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets) - [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/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath) - [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/random/background](#get-apirandombackground)
- [GET /api/presets](#get-apipresets) - [GET /api/presets](#get-apipresets)
- [GET /api/dl\_fields](#get-apidl_fields)
- [PUT /api/dl\_fields](#put-apidl_fields)
- [PUT /api/presets](#put-apipresets) - [PUT /api/presets](#put-apipresets)
- [GET /api/conditions](#get-apiconditions) - [GET /api/conditions](#get-apiconditions)
- [PUT /api/conditions](#put-apiconditions) - [PUT /api/conditions](#put-apiconditions)
- [POST /api/conditions/test](#post-apiconditionstest)
- [GET /api/logs](#get-apilogs) - [GET /api/logs](#get-apilogs)
- [GET /api/notifications](#get-apinotifications) - [GET /api/notifications](#get-apinotifications)
- [PUT /api/notifications](#put-apinotifications) - [PUT /api/notifications](#put-apinotifications)
- [POST /api/yt-dlp/archive\_id/](#post-apiyt-dlparchive_id)
- [POST /api/notifications/test](#post-apinotificationstest) - [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) - [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 ### GET /api/tasks
**Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager. **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 ### GET /api/player/playlist/{file:.*}.m3u8
**Purpose**: Generate a playlist for a given local media file. **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 ### POST /api/file/action/{path:.*}
**Purpose**: Recheck manual archive entries to see if become available or not. **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**: **Response**:
```json ```json
[ { "token": "<uuid>", "files": ["relative/path/file1.ext", "..."] }
{
"id": "youtube_video_id",
"url": "https://youtube.com/watch?v=...",
"status": "available|unavailable|error",
"info": { ... } // video info if available
},
...
]
``` ```
- 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 ### PUT /api/presets
**Purpose**: Save/update download 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 ### GET /api/logs
**Purpose**: Retrieve recent application logs (if file logging is enabled). **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", ...] "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 ## Error Responses
Most endpoints return standard error codes (`400`, `403`, `404`, `500`, etc.) and a JSON body on failure. For example: 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_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_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_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_HOST | Which IP address to bind to | `0.0.0.0` |
| YTP_PORT | Which port to bind to | `8081` | | YTP_PORT | Which port to bind to | `8081` |
| YTP_LOG_LEVEL | Log level | `info` | | 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_ACCESS_LOG | Whether to log access to the web server | `true` |
| YTP_DEBUG | Whether to turn on debug mode | `false` | | YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` | | 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_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_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_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_PACKAGES | A space separated list of pip packages to install | `empty string` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` | | 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. 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. 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 # Troubleshooting and submitting issues
Before asking a question or submitting an issue for YTPTube, please remember that YTPTube is only a thin wrapper for 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 return items
def put(self, value: Download) -> Download: def put(self, value: Download, no_notify: bool = False) -> Download:
if "error" == value.info.status: if "error" == value.info.status and not no_notify:
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error") asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error")
@ -181,16 +181,18 @@ class DataStore:
except AttributeError: except AttributeError:
pass pass
encoded: str = stored.json()
self._connection.execute( self._connection.execute(
sqlStatement.strip(), sqlStatement.strip(),
( (
stored._id, stored._id,
str(type), str(type),
stored.url, stored.url,
stored.json(), encoded,
str(type), str(type),
stored.url, stored.url,
stored.json(), encoded,
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"), 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 .ItemDTO import ItemDTO
from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies
from .ytdlp import YTDLP from .ytdlp import YTDLP
from .YTDLPOpts import YTDLPOpts
class Terminator: class Terminator:
@ -27,19 +26,19 @@ class Terminator:
class NestedLogger: class NestedLogger:
debug_messages = ["[debug] ", "[download] "] debug_messages: list[str] = ["[debug] ", "[download] "]
def __init__(self, logger: logging.Logger): def __init__(self, logger: logging.Logger) -> None:
self.logger = logger self.logger: logging.Logger = logger
def debug(self, msg: str): def debug(self, msg: str) -> None:
levelno = logging.DEBUG if any(msg.startswith(x) for x in self.debug_messages) else logging.INFO 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)) 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) self.logger.error(msg)
def warning(self, msg): def warning(self, msg) -> None:
self.logger.warning(msg) self.logger.warning(msg)
@ -110,7 +109,6 @@ class Download:
self.template = info.template self.template = info.template
self.template_chapter = info.template_chapter self.template_chapter = info.template_chapter
self.download_info_expires = int(config.download_info_expires) self.download_info_expires = int(config.download_info_expires)
self.preset = info.preset
self.info = info self.info = info
self.id = info._id self.id = info._id
self.debug = bool(config.debug) self.debug = bool(config.debug)
@ -125,14 +123,14 @@ class Download:
self.temp_disabled = bool(config.temp_disabled) self.temp_disabled = bool(config.temp_disabled)
self.is_live = bool(info.is_live) or info.live_in is not None self.is_live = bool(info.is_live) or info.live_in is not None
self.info_dict = info_dict 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.started_time = 0
self.queue_time = datetime.now(tz=UTC) self.queue_time: datetime = datetime.now(tz=UTC)
self.logs = logs if logs else [] self.logs = logs if logs else []
def _progress_hook(self, data: dict): def _progress_hook(self, data: dict):
if self.debug: if self.debug:
d_copy = deepcopy(data) d_copy: dict = deepcopy(data)
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]: for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
d_copy["info_dict"].pop(k, None) d_copy["info_dict"].pop(k, None)
@ -148,7 +146,7 @@ class Download:
def _postprocessor_hook(self, data: dict): def _postprocessor_hook(self, data: dict):
if self.debug: if self.debug:
d_copy = deepcopy(data) d_copy: dict = deepcopy(data)
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]: for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
d_copy["info_dict"].pop(k, None) d_copy["info_dict"].pop(k, None)
@ -181,10 +179,7 @@ class Download:
try: try:
params: dict = ( params: dict = (
YTDLPOpts.get_instance() self.info.get_ytdlp_opts()
.preset(self.preset)
.add({"break_on_existing": True})
.add_cli(args=self.info.cli, from_user=True)
.add( .add(
config={ config={
"color": "no_color", "color": "no_color",
@ -263,31 +258,30 @@ class Download:
self.info_dict = info self.info_dict = info
if self.is_live: if self.is_live:
hasDeletedOptions = False
deletedOpts: list = [] deletedOpts: list = []
for opt in self.bad_live_options: for opt in self.bad_live_options:
if opt in params: if opt in params:
params.pop(opt, None) params.pop(opt, None)
hasDeletedOptions = True
deletedOpts.append(opt) deletedOpts.append(opt)
if hasDeletedOptions: if len(deletedOpts) > 0:
self.logger.warning( self.logger.warning(
f"Live stream detected for '{self.info.title}', The following opts '{deletedOpts=}' have been deleted." 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: 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): if filtered_logs := extract_ytdlp_logs(self.logs):
msg += " " + ", ".join(filtered_logs) msg += " " + ", ".join(filtered_logs)
raise ValueError(msg) # noqa: TRY301 raise ValueError(msg) # noqa: TRY301
self.logger.info( 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) 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.error(f"Failed to delete cookie file: {cookie_file}. {e}")
self.logger.info( 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): async def start(self):
@ -361,7 +355,9 @@ class Download:
ret = await asyncio.get_running_loop().run_in_executor(None, self.proc.join) 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 return ret
self.status_queue.put(Terminator()) self.status_queue.put(Terminator())
@ -373,9 +369,11 @@ class Download:
for i in range(drain_count): for i in range(drain_count):
try: 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: 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 break
next_status = self.status_queue.get(timeout=0.1) next_status = self.status_queue.get(timeout=0.1)
if next_status is None or isinstance(next_status, Terminator): if next_status is None or isinstance(next_status, Terminator):
@ -499,12 +497,10 @@ class Download:
return return
if str(tmp_dir) == str(self.temp_dir): if str(tmp_dir) == str(self.temp_dir):
self.logger.warning( self.logger.warning(f"Refusing to delete video temp folder '{self.temp_path}' as it's temp root.")
f"Attempted to delete video temp folder '{self.temp_path}', but it is the same as main temp folder."
)
return return
status = delete_dir(tmp_dir) status: bool = delete_dir(tmp_dir)
if by_pass: if by_pass:
tmp_dir.mkdir(parents=True, exist_ok=True) tmp_dir.mkdir(parents=True, exist_ok=True)
self.logger.info(f"Temp folder '{self.temp_path}' emptied.") 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) await self._notify.emit(Events.ITEM_UPDATED, data=self.info)
return return
self.tmpfilename = status.get("tmpfilename") self.tmpfilename: str | None = status.get("tmpfilename")
fl = None fl = None
if "final_name" in status: if "final_name" in status:
@ -560,7 +556,7 @@ class Download:
if "downloaded_bytes" in status and status.get("downloaded_bytes", 0) > 0: if "downloaded_bytes" in status and status.get("downloaded_bytes", 0) > 0:
self.info.downloaded_bytes = status.get("downloaded_bytes") 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: if total:
try: try:
self.info.percent = status["downloaded_bytes"] / total * 100 self.info.percent = status["downloaded_bytes"] / total * 100
@ -617,7 +613,7 @@ class Download:
return False return False
if self.started_time < 1: 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 return False
if int(time.time()) - self.started_time < 300: if int(time.time()) - self.started_time < 300:
@ -633,7 +629,7 @@ class Download:
return True return True
if self.info.status not in ["finished", "error", "cancelled", "downloading", "postprocessing"]: 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( 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." 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, dt_delta,
extract_info, extract_info,
extract_ytdlp_logs, extract_ytdlp_logs,
is_downloaded,
load_cookies, load_cookies,
str_to_dt, str_to_dt,
ytdlp_reject, ytdlp_reject,
) )
from .YTDLPOpts import YTDLPOpts
if TYPE_CHECKING: if TYPE_CHECKING:
from app.library.Presets import Preset from app.library.Presets import Preset
LOG = logging.getLogger("DownloadQueue") LOG: logging.Logger = logging.getLogger("DownloadQueue")
class DownloadQueue(metaclass=Singleton): class DownloadQueue(metaclass=Singleton):
@ -87,7 +85,7 @@ class DownloadQueue(metaclass=Singleton):
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency) self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
@staticmethod @staticmethod
def get_instance(): def get_instance() -> "DownloadQueue":
""" """
Get the instance of the DownloadQueue. Get the instance of the DownloadQueue.
@ -100,7 +98,7 @@ class DownloadQueue(metaclass=Singleton):
return DownloadQueue._instance return DownloadQueue._instance
def attach(self, _: web.Application): def attach(self, _: web.Application) -> None:
""" """
Attach the download queue to the application. Attach the download queue to the application.
@ -136,13 +134,11 @@ class DownloadQueue(metaclass=Singleton):
await self.done.test() await self.done.test()
return True return True
async def initialize(self): async def initialize(self) -> None:
""" """
Initialize the download queue. Initialize the download queue.
""" """
LOG.info( LOG.info(f"Using '{self.config.max_workers}' workers for downloading.")
f"Using '{self.config.max_workers}' worker/s for downloading. Can be configured via `YTP_MAX_WORKERS` environment variable."
)
asyncio.create_task(self._download_pool(), name="download_pool") asyncio.create_task(self._download_pool(), name="download_pool")
async def start_items(self, ids: list[str]) -> dict[str, str]: 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"} status: dict[str, str] = {"status": "ok"}
started = False started = False
tasks = [] tasks: list = []
for item_id in ids: for item_id in ids:
try: try:
item = self.queue.get(key=item_id) item: Download = self.queue.get(key=item_id)
except KeyError as e: except KeyError as e:
status[item_id] = f"not found: {e!s}" status[item_id] = f"not found: {e!s}"
status["status"] = "error" status["status"] = "error"
@ -175,7 +171,7 @@ class DownloadQueue(metaclass=Singleton):
continue continue
item.info.auto_start = True 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(Events.ITEM_UPDATED, data=updated.info))
tasks.append( tasks.append(
self._notify.emit( self._notify.emit(
@ -209,11 +205,11 @@ class DownloadQueue(metaclass=Singleton):
""" """
status: dict[str, str] = {"status": "ok"} status: dict[str, str] = {"status": "ok"}
tasks = [] tasks: list = []
for item_id in ids: for item_id in ids:
try: try:
item = self.queue.get(key=item_id) item: Download = self.queue.get(key=item_id)
except KeyError as e: except KeyError as e:
status[item_id] = f"not found: {e!s}" status[item_id] = f"not found: {e!s}"
status["status"] = "error" status["status"] = "error"
@ -231,7 +227,7 @@ class DownloadQueue(metaclass=Singleton):
continue continue
item.info.auto_start = False 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(Events.ITEM_UPDATED, data=updated.info))
tasks.append( tasks.append(
self._notify.emit( 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')}'.") LOG.debug(f"Entry id '{entry.get('id')}' url '{entry.get('webpage_url')} - {entry.get('url')}'.")
try: try:
_item = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url")) _item: Download = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url"))
if _item is not None: err_msg: str = f"Removing {_item.info.name()} from history list."
err_msg = f"Item '{_item.info.id}' - '{_item.info.title}' already exists. Removing from history." LOG.warning(err_msg)
LOG.warning(err_msg) await self.clear([_item.info._id], remove_file=False)
await self.clear([_item.info._id], remove_file=False)
except KeyError: except KeyError:
pass pass
try: try:
_item = self.queue.get(key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url"))) _item: Download = self.queue.get(
if _item is not None: key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url"))
err_msg = f"Item ID '{_item.info.id}' - '{_item.info.title}' already in download queue." )
LOG.info(err_msg) err_msg: str = f"Item {_item.info.name()} is already in download queue."
return {"status": "error", "msg": err_msg} LOG.info(err_msg)
return {"status": "error", "msg": err_msg}
except KeyError: except KeyError:
pass pass
@ -494,7 +490,7 @@ class DownloadQueue(metaclass=Singleton):
nEvent = Events.ITEM_MOVED nEvent = Events.ITEM_MOVED
nStore = "history" nStore = "history"
nTitle = "Upcoming Premiere" if is_premiere else "Upcoming Live Stream" 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.status = "not_live"
dlInfo.info.msg = nMessage.replace(f" '{dlInfo.info.title}'", "") 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}") 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}'." dlInfo.info.error += f" Failed to parse live_in date '{release_in}'."
else: 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()}'." nMessage = f"'{dlInfo.info.title}': '{dlInfo.info.error.strip()}'."
@ -602,10 +598,10 @@ class DownloadQueue(metaclass=Singleton):
if event_type.startswith("url"): if event_type.startswith("url"):
return await self.add(item=item.new_with(url=entry.get("url")), already=already) return await self.add(item=item.new_with(url=entry.get("url")), already=already)
if not event_type.startswith("video"): if event_type.startswith("video"):
return {"status": "error", "msg": f'Unsupported event type "{event_type}".'} 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): async def add(self, item: Item, already: set | None = None):
""" """
@ -658,16 +654,15 @@ class DownloadQueue(metaclass=Singleton):
"level": logging.WARNING, "level": logging.WARNING,
"name": "callback-logger", "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"): if yt_conf.get("external_downloader"):
LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.") LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.")
item.extras.update({"external_downloader": True}) item.extras.update({"external_downloader": True})
downloaded, id_dict = self._is_downloaded(file=yt_conf.get("download_archive", None), url=item.url) if item.is_archived():
if downloaded is True and id_dict: message: str = f"The URL '{item.url}' is already downloaded and recorded in archive."
message = f"'{id_dict.get('id')}': The URL '{item.url}' is already downloaded and recorded in archive."
LOG.error(message) LOG.error(message)
await self._notify.emit(Events.LOG_INFO, title="Already Downloaded", message=message) await self._notify.emit(Events.LOG_INFO, title="Already Downloaded", message=message)
return {"status": "error", "msg": 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'}.") 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( fut=asyncio.get_running_loop().run_in_executor(
None, None,
functools.partial( functools.partial(
@ -980,9 +975,9 @@ class DownloadQueue(metaclass=Singleton):
self._active[entry.info._id] = entry self._active[entry.info._id] = entry
await entry.start() 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: 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" entry.info.status = "error"
except Exception as e: except Exception as e:
entry.info.status = "error" entry.info.status = "error"
@ -1010,9 +1005,12 @@ class DownloadQueue(metaclass=Singleton):
if entry.info.status == "finished" and entry.info.filename: if entry.info.status == "finished" and entry.info.filename:
nTitle = "Download Completed" nTitle = "Download Completed"
nMessage = f"Completed '{entry.info.title}' download." 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)) _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( _tasks.append(
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, Events.ITEM_MOVED,
@ -1029,23 +1027,6 @@ class DownloadQueue(metaclass=Singleton):
if self.event: if self.event:
self.event.set() 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): async def _check_for_stale(self):
""" """
Monitor pool for stale downloads and cancel them if needed. 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(): if self.is_paused() or self.done.empty():
return 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) time_now = datetime.now(tz=UTC)

View file

@ -97,7 +97,7 @@ class HttpAPI:
app.on_shutdown.append(self.on_shutdown) 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. Add the routes to the application.
@ -133,7 +133,8 @@ class HttpAPI:
elif "" == base_path or not routePath.rstrip("/").startswith(base_path.rstrip("/")): elif "" == base_path or not routePath.rstrip("/").startswith(base_path.rstrip("/")):
route.path = f"{base_path}/{route.path.lstrip('/')}" 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) 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") load_modules(self.rootPath, self.rootPath / "routes" / "socket")
for route in get_routes(RouteType.SOCKET).values(): for route in get_routes(RouteType.SOCKET).values():
LOG.debug( if self.config.debug:
f"Add ({route.name}) {route.method.value if isinstance(route.method,RouteType) else route.method}: {route.path}." 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)) self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path))
@staticmethod @staticmethod

View file

@ -6,9 +6,10 @@ from dataclasses import dataclass, field
from email.utils import formatdate from email.utils import formatdate
from typing import Any 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) @dataclass(kw_only=True)
@ -92,29 +93,6 @@ class Item:
""" """
return Item.format({**self.serialize(), **kwargs}) 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 @staticmethod
def format(item: dict) -> "Item": def format(item: dict) -> "Item":
""" """
@ -137,16 +115,14 @@ class Item:
msg = "url param is required." msg = "url param is required."
raise ValueError(msg) raise ValueError(msg)
data = { data: dict[str, str] = {"url": url}
"url": url,
}
preset = item.get("preset") preset: str | None = item.get("preset")
if preset and isinstance(preset, str) and preset != Item._default_preset(): if preset and isinstance(preset, str) and preset != Item._default_preset():
from .Presets import Presets from .Presets import Presets
if not Presets.get_instance().has(preset): 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) raise ValueError(msg)
data["preset"] = preset data["preset"] = preset
@ -163,19 +139,19 @@ class Item:
if "auto_start" in item and isinstance(item.get("auto_start"), bool): if "auto_start" in item and isinstance(item.get("auto_start"), bool):
data["auto_start"] = bool(item.get("auto_start")) 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: if extras and isinstance(extras, dict) and len(extras) > 0:
data["extras"] = extras data["extras"] = extras
if item.get("requeued") and isinstance(item.get("requeued"), bool): if item.get("requeued") and isinstance(item.get("requeued"), bool):
data["requeued"] = item.get("requeued") data["requeued"] = item.get("requeued")
cli = item.get("cli") cli: str | None = item.get("cli")
if cli and len(cli) > 2: if cli and len(cli) > 2:
from .Utils import arg_converter from .Utils import arg_converter
try: try:
removed_options = [] removed_options: list = []
arg_converter(args=cli, level=True, removed_options=removed_options) arg_converter(args=cli, level=True, removed_options=removed_options)
if len(removed_options) > 0: if len(removed_options) > 0:
LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url) LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url)
@ -187,6 +163,55 @@ class Item:
return Item(**data) 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) @dataclass(kw_only=True)
class ItemDTO: class ItemDTO:
@ -196,30 +221,55 @@ class ItemDTO:
""" """
_id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False) _id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
""" Unique identifier for the item. """
error: str | None = None error: str | None = None
""" Error message if the item failed. """
id: str id: str
""" The ID of the item yt-dlp """
title: str title: str
""" The title of the item. """
url: str url: str
quality: str | None = None """ The URL of the item. """
format: str | None = None
preset: str = "default" preset: str = "default"
""" The preset to be used for the item. """
folder: str folder: str
""" The folder to save the item to. """
download_dir: str | None = None download_dir: str | None = None
""" The full path to the download directory. """
temp_dir: str | None = None temp_dir: str | None = None
""" The full path to the temporary directory. """
status: str | None = None status: str | None = None
""" The status of the item. """
cookies: str | None = None cookies: str | None = None
""" The cookies to be used for the item. """
template: str | None = None template: str | None = None
""" The output template to be used for the item. """
template_chapter: str | None = None 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()) timestamp: float = field(default_factory=lambda: time.time_ns())
""" The timestamp of the item. """
is_live: bool | None = None is_live: bool | None = None
""" If the item is a live stream. """
datetime: str = field(default_factory=lambda: str(formatdate(time.time()))) datetime: str = field(default_factory=lambda: str(formatdate(time.time())))
""" The datetime of the item. """
live_in: str | None = None live_in: str | None = None
""" The time until the live stream starts. """
file_size: int | None = None file_size: int | None = None
""" The file size of the item. """
options: dict = field(default_factory=dict) options: dict = field(default_factory=dict)
""" The options used for the item. """
extras: dict = field(default_factory=dict) extras: dict = field(default_factory=dict)
""" Extra data associated with the item. """
cli: str = "" cli: str = ""
""" The command options for yt-dlp to be used for this download. """
auto_start: bool = True 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. # yt-dlp injected fields.
tmpfilename: str | None = None tmpfilename: str | None = None
@ -232,7 +282,13 @@ class ItemDTO:
speed: str | None = None speed: str | None = None
eta: str | None = None eta: str | None = None
_recomputed: bool = False
_archive_file: str | None = None
def serialize(self) -> dict: def serialize(self) -> dict:
if "finished" == self.status and not self._recomputed:
self.archive_status()
item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields()) item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields())
return item return item
@ -248,6 +304,104 @@ class ItemDTO:
def name(self) -> str: def name(self) -> str:
return f'id="{self.id}", title="{self.title}"' 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 @staticmethod
def removed_fields() -> tuple: def removed_fields() -> tuple:
"""Fields that once existed but are no longer used.""" """Fields that once existed but are no longer used."""
@ -261,4 +415,11 @@ class ItemDTO:
"output_template_chapter", "output_template_chapter",
"config", "config",
"temp_path", "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 aiohttp import web
from .config import Config from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events from .Events import EventBus, Events
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import arg_converter, init_class from .Utils import arg_converter, init_class
@ -92,6 +91,7 @@ class Preset:
return self.__dict__ return self.__dict__
def json(self) -> str: def json(self) -> str:
from .encoder import Encoder
return Encoder().encode(self.serialize()) return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:

View file

@ -126,7 +126,7 @@ class Scheduler(metaclass=Singleton):
self._jobs[job_id] = job 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 return job_id

View file

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

View file

@ -13,7 +13,7 @@ from datetime import UTC, datetime, timedelta
from functools import lru_cache from functools import lru_cache
from http.cookiejar import MozillaCookieJar from http.cookiejar import MozillaCookieJar
from pathlib import Path from pathlib import Path
from typing import TypeVar from typing import Any, TypeVar
from Crypto.Cipher import AES from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted, match_str from yt_dlp.utils import age_restricted, match_str
@ -63,7 +63,7 @@ REMOVE_KEYS: list = [
YTDLP_INFO_CLS: YTDLP = None YTDLP_INFO_CLS: YTDLP = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
FILES_TYPE: list = [ FILES_TYPE: list = [
{"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"}, {"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: if no_archive and "download_archive" in params:
del params["download_archive"] 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"]: if data and follow_redirect and "_type" in data and "url" == data["_type"]:
return extract_info( return extract_info(
@ -263,112 +263,6 @@ def merge_dict(source: dict, destination: dict) -> dict:
return destination_copy 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: def check_id(file: Path) -> bool | str:
""" """
Check if we are able to get an id from the file name. 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) @lru_cache(maxsize=512)
def is_private_address(hostname: str) -> bool: def is_private_address(hostname: str) -> bool:
try: ip = socket.gethostbyname(hostname)
ip = socket.gethostbyname(hostname) ip_obj = ipaddress.ip_address(ip)
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
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
def validate_url(url: str, allow_internal: bool = False) -> bool: 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) raise ValueError(msg)
hostname: str | None = parsed_url.host hostname: str | None = parsed_url.host
if allow_internal is False and (not hostname or is_private_address(hostname)): if not hostname:
msg = "Access to internal urls or private networks is not allowed." msg = "Invalid hostname."
raise ValueError(msg) 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 return True
@ -489,6 +389,10 @@ def arg_converter(
default_opts = _default_opts([]).ydl_opts 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 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() 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: if "postprocessors" in diff:
@ -1101,7 +1005,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
raise ValueError(msg) from e 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. 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. url (str): URL to check.
Returns: Returns:
bool: True if the video is already downloaded. dict: {
dict: Video information. "id": str | None,
"ie_key": str | None,
"archive_id": str | None
}
""" """
global YTDLP_INFO_CLS # noqa: PLW0603 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("/", ".") 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]): for _, name, _ in pkgutil.iter_modules([directory]):
full_name: str = f"{package_name}.{name}" full_name: str = f"{package_name}.{name}"
if name.startswith("_"): if name.startswith("_"):
continue continue
try: try:
LOG.debug(f"Loading module '{full_name}'.")
importlib.import_module(full_name) importlib.import_module(full_name)
except ImportError as e: except ImportError as e:
LOG.error(f"Failed to import module '{full_name}': {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)) folders.extend(list_folders(entry, base, depth_limit))
return folders 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": if data["format"] == "-best":
data["format"] = data["format"][1:] 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 return data

View file

@ -103,9 +103,6 @@ class Config:
archive_file: str = "{config_path}/archive.log" archive_file: str = "{config_path}/archive.log"
"""The path to the download archive file.""" """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" apprise_config: str = "{config_path}/apprise.yml"
"""The path to the Apprise configuration file.""" """The path to the Apprise configuration file."""
@ -387,34 +384,20 @@ class Config:
except Exception as e: except Exception as e:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {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" opts_file: Path = Path(self.config_path) / "ytdlp.cli"
if opts_file.exists() and opts_file.stat().st_size > 2: 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: with open(opts_file) as f:
self.ytdlp_cli = f.read().strip() self.ytdlp_cli = f.read().strip()
if self.ytdlp_cli: if self.ytdlp_cli:
self._ytdlp_cli_mutable = self.ytdlp_cli self._ytdlp_cli_mutable = self.ytdlp_cli
try: try:
removed_options = [] arg_converter(args=self.ytdlp_cli, level=True)
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
)
except Exception as e: except Exception as e:
msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'." msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'."
raise ValueError(msg) from e 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}" 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}'.") LOG.info(f"Creating archive file '{archive_file}'.")
archive_file.touch(exist_ok=True) 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}" self._ytdlp_cli_mutable += f"\n--download-archive {archive_file.as_posix()!s}"
if self.temp_keep: if self.temp_keep:
@ -519,6 +502,16 @@ class Config:
return "production" == self.app_env return "production" == self.app_env
def get_ytdlp_args(self) -> dict: 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: try:
return arg_converter(args=self._ytdlp_cli_mutable, level=True) return arg_converter(args=self._ytdlp_cli_mutable, level=True)
except Exception as e: except Exception as e:
@ -537,6 +530,7 @@ class Config:
ytdlp_args = self.get_ytdlp_args() 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): if not data.get("keep_archive", False) and ytdlp_args.get("download_archive", None):
data["keep_archive"] = True data["keep_archive"] = True
@ -544,7 +538,7 @@ class Config:
return data return data
@staticmethod @staticmethod
def _ytdlp_version(): def _ytdlp_version() -> str:
try: try:
from yt_dlp.version import __version__ as YTDLP_VERSION 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.networking.impersonate import ImpersonateTarget
from yt_dlp.utils import DateRange from yt_dlp.utils import DateRange
from .ItemDTO import ItemDTO
class Encoder(json.JSONEncoder): class Encoder(json.JSONEncoder):
""" """
@ -16,6 +14,8 @@ class Encoder(json.JSONEncoder):
""" """
def default(self, o): def default(self, o):
from .ItemDTO import ItemDTO
if isinstance(o, Path): if isinstance(o, Path):
return str(o) return str(o)

View file

@ -1,18 +1,19 @@
import asyncio import asyncio
import logging import logging
import re import re
from pathlib import Path from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING from xml.etree.ElementTree import Element
from app.library.config import Config from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item, ItemDTO from app.library.ItemDTO import Item, ItemDTO
from app.library.Tasks import Task from app.library.Tasks import Task
from app.library.Utils import is_downloaded from app.library.Utils import archive_read
from app.library.YTDLPOpts import YTDLPOpts
if TYPE_CHECKING: if TYPE_CHECKING:
from xml.etree.ElementTree import Element
from app.library.Download import Download from app.library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
@ -25,24 +26,22 @@ EventBus.get_instance().subscribe(
class YoutubeHandler: class YoutubeHandler:
queued_ids: set[str] = set() queued: set[str] = set()
failure_count: dict[str, int] = {} failure_count: dict[str, int] = {}
FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}" 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})|)/?$") 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_-]+)|).*$") PLAYLIST_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:playlist\?list=(?P<id>[A-Za-z0-9_-]+)|).*$")
@staticmethod @staticmethod
def can_handle(task: Task, config: Config) -> bool: def can_handle(task: Task) -> bool:
has, _ = YoutubeHandler.has_archive(task, config) if not task.get_ytdlp_opts().get_all().get("download_archive"):
if not has: LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
LOG.debug(f"Task '{task.id}: {task.name}' does not have an archive file configured.")
return False 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 return YoutubeHandler.parse(task.url) is not None
@staticmethod @staticmethod
@ -58,24 +57,24 @@ class YoutubeHandler:
queue (DownloadQueue): The download queue instance. queue (DownloadQueue): The download queue instance.
""" """
archive_file, params = YoutubeHandler.has_archive(task, config) params: dict = task.get_ytdlp_opts().get_all()
if not archive_file: if not (archive_file := params.get("download_archive")):
LOG.error(f"Task '{task.id}: {task.name}' does not have an archive file configured.") LOG.error(f"Task '{task.name}' does not have an archive file.")
return return
import httpx import httpx
from defusedxml.ElementTree import fromstring from defusedxml.ElementTree import fromstring
parsed = YoutubeHandler.parse(task.url) parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
if not parsed: 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 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.") LOG.debug(f"Fetching '{task.name}' feed.")
opts = { opts: dict[str, Any] = {
"proxy": params.get("proxy", None), "proxy": params.get("proxy"),
"headers": { "headers": {
"User-Agent": params.get( "User-Agent": params.get(
"user_agent", "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 = [] items: list = []
has_items = False
async with httpx.AsyncClient(**opts) as client: 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() response.raise_for_status()
root = fromstring(response.text) root: Element[str] = fromstring(response.text)
ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"} 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): for entry in root.findall("atom:entry", ns):
vid_elem = entry.find("yt:videoId", ns) vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
title_elem = entry.find("atom:title", ns) vid: str | None = vid_elem.text if vid_elem is not None else ""
pub_elem = entry.find("atom:published", ns) if not vid:
vid = vid_elem.text if vid_elem is not None else "" LOG.warning(f"Entry in '{task.name}' feed is missing a video ID. Skipping entry.")
title = title_elem.text if title_elem is not None else "" continue
published = pub_elem.text if pub_elem is not None else ""
items.append( archive_id: str = f"youtube {vid}"
{ url: str = f"https://www.youtube.com/watch?v={vid}"
"id": vid,
"url": f"https://www.youtube.com/watch?v={vid}", title_elem: Element[str] | None = entry.find("atom:title", ns)
"title": title, title: str | None = title_elem.text if title_elem is not None else ""
"published": published,
} 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: 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 return
filtered: list = [] filtered: list = []
downloaded: list[str] = archive_read(archive_file, [item["archive_id"] for item in items])
for item in items: for item in items:
status, _ = is_downloaded(archive_file, url=item["url"]) YoutubeHandler.queued.add(item["archive_id"])
if status is True or item["id"] in YoutubeHandler.queued_ids: if item["archive_id"] in downloaded:
continue continue
if queue.queue.exists(url=item["url"]): if queue.queue.exists(url=item["url"]):
LOG.debug(f"Item '{item['id']}' exists in the queue.")
YoutubeHandler.queued_ids.add(item["id"])
continue continue
try: try:
done: Download = queue.done.get(url=item["url"]) done: Download = queue.done.get(url=item["url"])
if "error" != done.info.status: if "error" != done.info.status:
LOG.debug(f"Item '{item['id']}' exists in the download history.")
YoutubeHandler.queued_ids.add(item["id"])
continue continue
except KeyError: except KeyError:
pass 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) filtered.append(item)
if len(filtered) < 1: 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 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) rItem: Item = Item.format(
folder: str = task.folder if task.folder else "" {
template: str = task.template if task.template else "" "url": feed_url,
cli: str = task.cli if task.cli else "" "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: try:
await asyncio.gather( await asyncio.gather(
*[ *[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered]
notify.emit(
Events.ADD_URL,
data=Item.format(
{"url": item["url"], "preset": preset, "folder": folder, "template": template, "cli": cli}
).serialize(),
)
for item in filtered
]
) )
except Exception as e: 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 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 @staticmethod
def parse(url: str) -> dict[str, str] | None: def parse(url: str) -> dict[str, str] | None:
""" """
@ -222,29 +223,33 @@ class YoutubeHandler:
if not item or not isinstance(item, ItemDTO): if not item or not isinstance(item, ItemDTO):
return return
cls.queued_ids.add(item.id) 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.")
if item.id not in cls.queued_ids:
return 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}'.") LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{failCount + 1}'.")
cls.queued_ids.remove(item.id) 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 @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 [ return [
"https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN", ("https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN", True),
"https://youtube.com/c/MyCustomName", ("https://youtube.com/c/MyCustomName", False),
"https://youtube.com/user/SomeUser123", ("https://youtube.com/user/SomeUser123", False),
"https://youtube.com/@SomeHandle", ("https://youtube.com/@SomeHandle", False),
"https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ", ("https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ", True),
"https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ", ("https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ", True),
"https://youtube.com/watch?v=foo", ("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 aiohttp.web import Request, Response
from app.library.config import Config from app.library.config import Config
from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events 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: if not item.info:
return web.json_response(data={"error": "item has no info."}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": "item has no info."}, status=web.HTTPNotFound.status_code)
info = { info: dict = {
**item.info.serialize(), **item.info.serialize(),
"ffprobe": {}, "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")}) 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) 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) return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
try: try:
ytdlp_args = config.get_ytdlp_args() ytdlp_args = config.get_ytdlp_args()
opts = { opts = {
"proxy": ytdlp_args.get("proxy", None), "proxy": ytdlp_args.get("proxy", None),
"headers": { "headers": {
"User-Agent": ytdlp_args.get( "User-Agent": ytdlp_args.get("user_agent", request.headers.get("User-Agent", random_user_agent())),
"user_agent", request.headers.get("User-Agent", random_user_agent())
),
}, },
} }
async with httpx.AsyncClient(**opts) as client: async with httpx.AsyncClient(**opts) as client:
LOG.debug(f"Fetching thumbnail from '{url}'.") 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( return web.Response(
body=response.content, body=response.content,
headers={ 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()), "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: async with httpx.AsyncClient(**opts) as client:
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"): if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):

View file

@ -1,11 +1,13 @@
import asyncio import asyncio
import logging import logging
import time
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from aiohttp.web_runner import GracefulExit from aiohttp.web_runner import GracefulExit
from app.library.config import Config from app.library.config import Config
from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.router import route from app.library.router import route
@ -13,10 +15,80 @@ from app.library.router import route
LOG: logging.Logger = logging.getLogger(__name__) 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") @route("POST", "api/system/shutdown", "system.shutdown")
async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response: async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response:
""" """
Get the presets. Initiate application shutdown.
Args: Args:
request (Request): The request object. 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") @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. Mark all items from task as downloaded.
@ -111,9 +111,9 @@ async def mark_task(request: Request, encoder: Encoder) -> Response:
if not task_id: if not task_id:
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
tasks = Tasks.get_instance() tasks: Tasks = Tasks.get_instance()
try: try:
task = tasks.get(task_id) task: Task | None = tasks.get(task_id)
if not task: if not task:
return web.json_response( return web.json_response(
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code 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) return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
except ValueError as e: except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code) 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 json
import logging import logging
import time import time
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path
from typing import Any from typing import Any
import anyio
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response 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.config import Config
from app.library.Presets import Presets from app.library.Presets import Presets
from app.library.router import route 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 from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__) 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, "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])))) data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))
cache.set(key=key, value=data, ttl=300) 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") @route("GET", "api/yt-dlp/options/", "get_options")
async def get_options() -> Response: async def get_options() -> Response:
""" """
@ -309,6 +240,37 @@ async def get_options() -> Response:
""" """
from app.library.ytdlp import ytdlp_options from app.library.ytdlp import ytdlp_options
return web.json_response( return web.json_response(body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code)
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 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.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
from app.library.router import RouteType, route 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__) 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") @route(RouteType.SOCKET, "add_url", "add_url")
async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict): async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
data = data if isinstance(data, dict) else {} 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))) 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") @route(RouteType.SOCKET, "item_start", "item_start")
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None: async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data: if not data:

View file

@ -39,6 +39,7 @@ dependencies = [
"apprise>=1.9.3", "apprise>=1.9.3",
"bgutil-ytdlp-pot-provider>=1.2.1", "bgutil-ytdlp-pot-provider>=1.2.1",
"pycryptodome>=3.23.0", "pycryptodome>=3.23.0",
"httpx-curl-cffi>=0.1.4",
] ]
[tool.ruff] [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<{ const props = defineProps<{
link?: string link?: string
preset?: string preset?: string
cli?: string
useUrl?: boolean useUrl?: boolean
externalModel?: boolean externalModel?: boolean
}>() }>()
@ -71,13 +72,16 @@ onMounted(async (): Promise<void> => {
if (props.preset) { if (props.preset) {
params.append('preset', props.preset) params.append('preset', props.preset)
} }
if (props.cli) {
params.append('args', props.cli)
}
params.append('url', props.link || '') params.append('url', props.link || '')
url += '?' + params.toString() url += '?' + params.toString()
} }
try { try {
isLoading.value = true isLoading.value = true
const response = await request(url, { credentials: 'include' }) const response = await request(url)
const body = await response.text() const body = await response.text()
try { try {

View file

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

View file

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

View file

@ -63,8 +63,9 @@
</label> </label>
</div> </div>
<div class="control is-expanded"> <div class="control is-expanded">
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder" placeholder="Default" <input type="text" class="input is-fullwidth" id="path" v-model="form.folder"
:disabled="!socket.isConnected || addInProgress" list="folders"> :placeholder="get_download_folder()" :disabled="!socket.isConnected || addInProgress"
list="folders">
</div> </div>
</div> </div>
</div> </div>
@ -166,16 +167,11 @@
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" /> <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 class="icon has-text-info"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
</NuxtLink> </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" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="resetConfig"> <NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span> <span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
@ -193,7 +189,8 @@
</button> </button>
</div> </div>
<div class="control"> <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 }" :class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url"> :disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -201,15 +198,6 @@
</button> </button>
</div> </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"> <div class="control">
<button type="button" class="button is-danger" @click="resetConfig" <button type="button" class="button is-danger" @click="resetConfig"
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'"> :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 props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{ 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: 'clear_form'): void
(e: 'remove_archive', url: string): void (e: 'remove_archive', url: string): void
}>() }>()
@ -363,7 +351,6 @@ const addDownload = async () => {
try { try {
addInProgress.value = true addInProgress.value = true
const response = await request('/api/history', { const response = await request('/api/history', {
credentials: 'include',
method: 'POST', method: 'POST',
body: JSON.stringify(request_data), 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 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 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 = () => { const get_output_template = () => {
if (form.value.preset && !hasFormatInConfig.value) { if (form.value.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset) 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' 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))) const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
</script> </script>

View file

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

View file

@ -510,7 +510,7 @@ const convert_url = async (url: string): Promise<string> => {
try { try {
convertInProgress.value = true 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 body = await resp.json()
const channel_id = ag(body, 'channel_id', null) const channel_id = ag(body, 'channel_id', null)
console.log('convert_url', { url, channel_id, body }) console.log('convert_url', { url, channel_id, body })

View file

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

View file

@ -29,8 +29,7 @@
<span class="icon"><i class="fas fa-pause" /></span> <span class="icon"><i class="fas fa-pause" /></span>
<span v-if="!isMobile">Pause</span> <span v-if="!isMobile">Pause</span>
</button> </button>
<button class="button is-danger" @click="socket.emit('resume', {})" v-else <button class="button is-danger" @click="resumeDownload" v-else v-tooltip.bottom="'Resume downloading.'">
v-tooltip.bottom="'Resume downloading.'">
<span class="icon"><i class="fas fa-play" /></span> <span class="icon"><i class="fas fa-play" /></span>
<span v-if="!isMobile">Resume</span> <span v-if="!isMobile">Resume</span>
</button> </button>
@ -63,16 +62,50 @@
</div> </div>
</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" <NewDownload v-if="config.showForm || config.app.basic_mode"
@getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :item="item_form" @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
@clear_form="item_form = {}" @remove_archive="" /> :item="item_form" @clear_form="item_form = {}" @remove_archive="" />
<Queue @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :thumbnails="show_thumbnail" <Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> :thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
<History @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" @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" @add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> @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" <GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli"
@closeModel="close_info()" /> :useUrl="info_view.useUrl" @closeModel="close_info()" />
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title" <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" :html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" /> @cancel="() => dialog_confirm.visible = false" />
@ -81,6 +114,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import DeprecatedNotice from '~/components/DeprecatedNotice.vue'
import type { item_request } from '~/types/item' import type { item_request } from '~/types/item'
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store'
@ -95,8 +129,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const info_view = ref({ const info_view = ref({
url: '', url: '',
preset: '', preset: '',
cli: '',
useUrl: false, 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 item_form = ref<item_request | object>({})
const query = ref() const query = ref()
const toggleFilter = ref(false) const toggleFilter = ref(false)
@ -139,6 +174,8 @@ watch(() => stateStore.queue, () => {
}, { deep: true }) }, { deep: true })
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' })
const pauseDownload = () => { const pauseDownload = () => {
dialog_confirm.value.visible = true dialog_confirm.value.visible = true
dialog_confirm.value.html_message = ` 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> <li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li>
</ul> </ul>
</span>` </span>`
dialog_confirm.value.confirm = () => { dialog_confirm.value.confirm = async () => {
socket.emit('pause', {}) await request('/api/system/pause', { method: 'POST' })
dialog_confirm.value.visible = false dialog_confirm.value.visible = false
} }
} }
@ -165,10 +202,11 @@ const close_info = () => {
info_view.value.useUrl = false 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.url = url
info_view.value.useUrl = useUrl info_view.value.useUrl = useUrl
info_view.value.preset = preset info_view.value.preset = preset
info_view.value.cli = cli
} }
watch(() => info_view.value.url, v => { watch(() => info_view.value.url, v => {

View file

@ -194,9 +194,14 @@
<hr class="dropdown-divider" /> <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 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> </NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
@ -313,9 +318,14 @@
<hr class="dropdown-divider" /> <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 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> </NuxtLink>
</Dropdown> </Dropdown>
</div> </div>
@ -370,6 +380,7 @@ const box = useConfirm()
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const { confirmDialog: cDialog } = useDialog()
const display_style = useStorage<string>("tasks_display_style", "cards") const display_style = useStorage<string>("tasks_display_style", "cards")
const tasks = ref<Array<task_item>>([]) const tasks = ref<Array<task_item>>([])
@ -428,7 +439,7 @@ watch(() => config.app.basic_mode, async v => {
return return
} }
await navigateTo('/') await navigateTo('/')
},{ immediate: true }) }, { immediate: true })
watch(() => socket.isConnected, async () => { watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) { 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 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) => { const archiveAll = async (item: task_item) => {
try { 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 item.in_progress = true
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'POST' }) const response = await request(`/api/tasks/${item.id}/mark`, { method: 'POST' })
const data = await response.json() const data = await response.json()
@ -791,4 +803,33 @@ const archiveAll = async (item: task_item) => {
item.in_progress = false 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> </script>

View file

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

View file

@ -82,6 +82,12 @@ type StoreItem = {
speed?: number | null speed?: number | null
/** Time remaining for the item download if available */ /** Time remaining for the item download if available */
eta?: number | null 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 } export type { ItemStatus, StoreItem }

View file

@ -12,20 +12,20 @@
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.2", "@pinia/nuxt": "^0.11.2",
"@sentry/nuxt": "^10.5.0", "@sentry/nuxt": "^10.8.0",
"@vueuse/core": "^13.7.0", "@vueuse/core": "^13.8.0",
"@vueuse/nuxt": "^13.7.0", "@vueuse/nuxt": "^13.8.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.0", "cron-parser": "^5.3.0",
"cronstrue": "^3.2.0", "cronstrue": "^3.2.0",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"hls.js": "^1.6.10", "hls.js": "^1.6.11",
"moment": "^2.30.1", "moment": "^2.30.1",
"nuxt": "^4.0.3", "nuxt": "^4.0.3",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vue": "^3.5.19", "vue": "^3.5.20",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-toastification": "2.0.0-rc.5" "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 }, { 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]] [[package]]
name = "humanfriendly" name = "humanfriendly"
version = "10.0" version = "10.0"
@ -744,11 +758,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.3.8" version = "4.4.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -1103,66 +1117,66 @@ wheels = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "2025.7.34" version = "2025.8.29"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/eb/f5/9b9384415fdc533551be2ba805dd8c4621873e5df69c958f403bfd3b2b6e/regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1", size = 264019 }, { 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/18/9d/e069ed94debcf4cc9626d652a48040b079ce34c7e4fb174f16874958d485/regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a", size = 276047 }, { 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/fd/cf/3bafbe9d1fd1db77355e7fbbbf0d0cfb34501a8b8e334deca14f94c7b315/regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0", size = 268362 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/3f/7d/aabb467d8f57d8149895d133c88eb809a1a6a0fe262c1d508eb9dfabb6f9/regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588", size = 264417 }, { 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/3b/39/bd922b55a4fc5ad5c13753274e5b536f5b06ec8eb9747675668491c7ab7a/regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62", size = 275387 }, { 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/f7/3c/c61d2fdcecb754a40475a3d1ef9a000911d3e3fc75c096acf44b0dfb786a/regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176", size = 268482 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401 }, { 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/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368 }, { 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/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482 }, { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730 }, { 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/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640 }, { 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/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757 }, { url = "https://files.pythonhosted.org/packages/0f/34/c723ebe214c33000b53e0eebdc63ad3697d5611c7fa9b388eef2113a5e82/regex-2025.8.29-cp314-cp314-win_arm64.whl", hash = "sha256:7b4a3dc155984f09a55c64b90923cb136cd0dad21ca0168aba2382d90ea4c546", size = 271832 },
] ]
[[package]] [[package]]
@ -1234,11 +1248,11 @@ wheels = [
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.1" version = "4.15.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -1367,11 +1381,11 @@ wheels = [
[[package]] [[package]]
name = "yt-dlp" name = "yt-dlp"
version = "2025.8.22" version = "2025.8.27"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@ -1394,6 +1408,7 @@ dependencies = [
{ name = "debugpy" }, { name = "debugpy" },
{ name = "defusedxml" }, { name = "defusedxml" },
{ name = "httpx" }, { name = "httpx" },
{ name = "httpx-curl-cffi" },
{ name = "multidict" }, { name = "multidict" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "platformdirs" }, { name = "platformdirs" },
@ -1432,6 +1447,7 @@ requires-dist = [
{ name = "debugpy", specifier = ">=1.8.1" }, { name = "debugpy", specifier = ">=1.8.1" },
{ name = "defusedxml", specifier = ">=0.7.1" }, { name = "defusedxml", specifier = ">=0.7.1" },
{ name = "httpx" }, { name = "httpx" },
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
{ name = "multidict", specifier = "==6.5.1" }, { name = "multidict", specifier = "==6.5.1" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "platformdirs" }, { name = "platformdirs" },
@ -1452,9 +1468,9 @@ provides-extras = ["installer"]
[[package]] [[package]]
name = "zipstream-ng" name = "zipstream-ng"
version = "1.8.0" version = "1.9.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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 },
] ]