commit
aaab5a72e8
40 changed files with 2029 additions and 1499 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -112,6 +112,7 @@
|
|||
"timespec",
|
||||
"tmpfilename",
|
||||
"ungroup",
|
||||
"unmark",
|
||||
"unnegated",
|
||||
"unpickleable",
|
||||
"Unraid",
|
||||
|
|
|
|||
286
API.md
286
API.md
|
|
@ -25,8 +25,11 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [POST /api/history/{id}](#post-apihistoryid)
|
||||
- [GET /api/history/{id}](#get-apihistoryid)
|
||||
- [GET /api/history](#get-apihistory)
|
||||
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
|
||||
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
|
||||
- [GET /api/tasks](#get-apitasks)
|
||||
- [PUT /api/tasks](#put-apitasks)
|
||||
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
||||
- [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8)
|
||||
- [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8)
|
||||
- [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets)
|
||||
|
|
@ -35,16 +38,26 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
|
||||
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
|
||||
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
|
||||
- [GET /api/yt-dlp/archive/recheck](#get-apiyt-dlparchiverecheck)
|
||||
- [POST /api/file/action/{path:.\*}](#post-apifileactionpath)
|
||||
- [POST /api/file/download](#post-apifiledownload)
|
||||
- [GET /api/file/download/{token}](#get-apifiledownloadtoken)
|
||||
- [GET /api/random/background](#get-apirandombackground)
|
||||
- [GET /api/presets](#get-apipresets)
|
||||
- [GET /api/dl\_fields](#get-apidl_fields)
|
||||
- [PUT /api/dl\_fields](#put-apidl_fields)
|
||||
- [PUT /api/presets](#put-apipresets)
|
||||
- [GET /api/conditions](#get-apiconditions)
|
||||
- [PUT /api/conditions](#put-apiconditions)
|
||||
- [POST /api/conditions/test](#post-apiconditionstest)
|
||||
- [GET /api/logs](#get-apilogs)
|
||||
- [GET /api/notifications](#get-apinotifications)
|
||||
- [PUT /api/notifications](#put-apinotifications)
|
||||
- [POST /api/yt-dlp/archive\_id/](#post-apiyt-dlparchive_id)
|
||||
- [POST /api/notifications/test](#post-apinotificationstest)
|
||||
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
||||
- [POST /api/system/shutdown](#post-apisystemshutdown)
|
||||
- [GET /api/dev/loop](#get-apidevloop)
|
||||
- [GET /api/dev/pip](#get-apidevpip)
|
||||
- [Error Responses](#error-responses)
|
||||
|
||||
---
|
||||
|
|
@ -348,6 +361,46 @@ or an error:
|
|||
|
||||
---
|
||||
|
||||
### DELETE /api/history/{id}/archive
|
||||
**Purpose**: Remove an item from archive file, allowing it to be re-downloaded.
|
||||
|
||||
**Path Parameter**:
|
||||
- `id`: Item ID from the history.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "message": "item '<title>' removed from '<archive_file>' archive." }
|
||||
```
|
||||
or an error:
|
||||
```json
|
||||
{ "error": "text" }
|
||||
```
|
||||
|
||||
- `400 Bad Request` if archive is not configured or parameters are invalid.
|
||||
- `404 Not Found` if the item or archive entry is not found.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/history/{id}/archive
|
||||
**Purpose**: Add item to the archive file preventing it from being downloaded.
|
||||
|
||||
**Path Parameter**:
|
||||
- `id`: Item ID from the history.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "message": "item '<archive_id>' archived in file '<archive_file>'." }
|
||||
```
|
||||
or an error:
|
||||
```json
|
||||
{ "error": "text" }
|
||||
```
|
||||
|
||||
- `404 Not Found` if the item or archive file does not exist.
|
||||
- `409 Conflict` if the item is already archived.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/tasks
|
||||
**Purpose**: Retrieves the scheduled tasks from the internal `Tasks` manager.
|
||||
|
||||
|
|
@ -420,6 +473,26 @@ or on error
|
|||
|
||||
---
|
||||
|
||||
### POST /api/tasks/{id}/mark
|
||||
**Purpose**: Mark all entries associated with a scheduled task as downloaded.
|
||||
|
||||
**Path Parameter**:
|
||||
- `id`: Task ID.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "message": "..." }
|
||||
```
|
||||
or
|
||||
```json
|
||||
{ "error": "..." }
|
||||
```
|
||||
|
||||
- `400 Bad Request` if id is missing or invalid.
|
||||
- `404 Not Found` if the task does not exist.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/player/playlist/{file:.*}.m3u8
|
||||
**Purpose**: Generate a playlist for a given local media file.
|
||||
|
||||
|
|
@ -577,22 +650,60 @@ Binary image data with the appropriate `Content-Type`.
|
|||
|
||||
---
|
||||
|
||||
### GET /api/yt-dlp/archive/recheck
|
||||
**Purpose**: Recheck manual archive entries to see if become available or not.
|
||||
### POST /api/file/action/{path:.*}
|
||||
**Purpose**: Perform a file browser action on a file or directory.
|
||||
|
||||
**Path Parameter**:
|
||||
- `path`: Base path (relative to `download_path`) to operate under. Use `/` for root.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{ "action": "rename|delete|move|directory", ... }
|
||||
```
|
||||
Actions and required fields:
|
||||
- `rename`: `{ "new_name": "<name>" }`
|
||||
- `delete`: no extra fields
|
||||
- `move`: `{ "new_path": "<dir-relative-to-download_path>" }`
|
||||
- `directory`: `{ "new_dir": "<subdir/to/create>" }`
|
||||
|
||||
**Response**: `200 OK` with empty body.
|
||||
|
||||
or an error:
|
||||
```json
|
||||
{ "error": "text" }
|
||||
```
|
||||
|
||||
- `403 Forbidden` if browser or actions are disabled.
|
||||
- `400/404` for invalid paths or parameters.
|
||||
|
||||
---
|
||||
|
||||
### POST /api/file/download
|
||||
**Purpose**: Prepare a ZIP download of selected files (and detected sidecars). Returns a short-lived token.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
[ "relative/path/file1.ext", "relative/path/file2.ext" ]
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "youtube_video_id",
|
||||
"url": "https://youtube.com/watch?v=...",
|
||||
"status": "available|unavailable|error",
|
||||
"info": { ... } // video info if available
|
||||
},
|
||||
...
|
||||
]
|
||||
{ "token": "<uuid>", "files": ["relative/path/file1.ext", "..."] }
|
||||
```
|
||||
- Returns `404 Not Found` if manual archive is not enabled or file doesn't exist.
|
||||
|
||||
- `400 Bad Request` if the body is not a JSON array or contains no valid files.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/file/download/{token}
|
||||
**Purpose**: Stream a ZIP file for the previously prepared download token.
|
||||
|
||||
**Path Parameter**:
|
||||
- `token`: Token returned by POST `/api/file/download`.
|
||||
|
||||
**Response**:
|
||||
- `200 OK` streaming response with `Content-Type: application/zip` and `Content-Disposition: attachment`.
|
||||
- JSON error with `400 Bad Request` if the token is invalid/expired or no files available.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -633,6 +744,47 @@ Binary image data with appropriate `Content-Type` header.
|
|||
|
||||
---
|
||||
|
||||
### GET /api/dl_fields
|
||||
**Purpose**: Retrieve the list of configured download fields.
|
||||
|
||||
**Query Parameters (optional)**:
|
||||
- `filter`: Comma-separated list of field names to include in each object.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{ "id": "<uuid>", "name": "...", ... },
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT /api/dl_fields
|
||||
**Purpose**: Save the list of download fields. Replaces existing entries.
|
||||
|
||||
**Body**: Array of objects. Required per-item fields: `name`. `id` is auto-generated if missing or invalid.
|
||||
```json
|
||||
[
|
||||
{ "name": "...", "id": "<uuid>", ... },
|
||||
{ "name": "..." }
|
||||
]
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{ "id": "<uuid>", "name": "...", ... },
|
||||
...
|
||||
]
|
||||
```
|
||||
or an error:
|
||||
```json
|
||||
{ "error": "text" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT /api/presets
|
||||
**Purpose**: Save/update download presets.
|
||||
|
||||
|
|
@ -716,6 +868,27 @@ Binary image data with appropriate `Content-Type` header.
|
|||
|
||||
---
|
||||
|
||||
### POST /api/conditions/test
|
||||
**Purpose**: Evaluate a condition expression against info extracted from a URL.
|
||||
|
||||
**Body**:
|
||||
```json
|
||||
{ "url": "https://...", "condition": "yt:duration > 600", "preset": "<optional-preset>" }
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": true,
|
||||
"condition": "...",
|
||||
"data": { ... } // sanitized, possibly large
|
||||
}
|
||||
```
|
||||
|
||||
- `400 Bad Request` for invalid body, missing fields, or extractor failures.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/logs
|
||||
**Purpose**: Retrieve recent application logs (if file logging is enabled).
|
||||
|
||||
|
|
@ -824,6 +997,41 @@ Binary image data with appropriate `Content-Type` header.
|
|||
"allowedTypes": ["added", "completed", "error", "cancelled", "cleared", "log_info", "log_success", ...]
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
### POST /api/yt-dlp/archive_id/
|
||||
**Purpose**: Get the archive ID for a given URLs.
|
||||
**Body**: Array of URLs.
|
||||
```json
|
||||
[
|
||||
"https://youtube.com/...",
|
||||
"https://..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"index": "index_of_the_url_in_request_array",
|
||||
"url": "the_url",
|
||||
"id": "the_video_id_or_null_if_not_found",
|
||||
"ie_key": "the_extractor_key_or_null_if_not_found",
|
||||
"archive_id": "the_archive_id_or_null_if_not_found",
|
||||
"error": "error_message_if_any_or_null"
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
or an error:
|
||||
```json
|
||||
{
|
||||
"error": "text"
|
||||
}
|
||||
```
|
||||
|
||||
- If the body is not a valid JSON array, returns `400 Bad Request`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -840,6 +1048,58 @@ Binary image data with appropriate `Content-Type` header.
|
|||
|
||||
---
|
||||
|
||||
### GET /api/yt-dlp/options
|
||||
**Purpose**: Get the current yt-dlp CLI options as a JSON object.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"description": "Description of the option",
|
||||
"flags":[ "--option", "-o" ],
|
||||
"group": "Option Group",
|
||||
"ignored": false, // true if this option is ignored by ytptube.
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/system/shutdown
|
||||
**Purpose**: Gracefully shut down the application (native mode only).
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "message": "The application shutting down." }
|
||||
```
|
||||
|
||||
- `400 Bad Request` if not running in native mode.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/dev/loop
|
||||
**Purpose**: Development-only. Show event loop details and running tasks.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "total_tasks": 1, "loop": "...", "tasks": [ { "task": "...", "stack": ["..."] } ] }
|
||||
```
|
||||
|
||||
- `403 Forbidden` if not in development mode.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/dev/pip
|
||||
**Purpose**: Development-only. Return installed versions for configured pip packages.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "package": "version-or-null", "...": null }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
Most endpoints return standard error codes (`400`, `403`, `404`, `500`, etc.) and a JSON body on failure. For example:
|
||||
|
|
|
|||
24
FAQ.md
24
FAQ.md
|
|
@ -18,7 +18,6 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_CONFIG_PATH | Path to where the config files will be stored. | `/config` |
|
||||
| YTP_TEMP_PATH | Path to where tmp files are stored. | `/tmp` |
|
||||
| YTP_TEMP_KEEP | Whether to keep the Individual video temp directory or remove it | `false` |
|
||||
| YTP_KEEP_ARCHIVE | Keep history of downloaded videos | `true` |
|
||||
| YTP_HOST | Which IP address to bind to | `0.0.0.0` |
|
||||
| YTP_PORT | Which port to bind to | `8081` |
|
||||
| YTP_LOG_LEVEL | Log level | `info` |
|
||||
|
|
@ -27,10 +26,8 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_ACCESS_LOG | Whether to log access to the web server | `true` |
|
||||
| YTP_DEBUG | Whether to turn on debug mode | `false` |
|
||||
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
|
||||
| YTP_SOCKET_TIMEOUT | The timeout for the yt-dlp socket connection variable | `30` |
|
||||
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
|
||||
| YTP_DB_FILE | The path to the SQLite database file | `{config_path}/ytptube.db` |
|
||||
| YTP_MANUAL_ARCHIVE | The path to the manual archive file | `{config_path}/manual_archive.log` |
|
||||
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
|
||||
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `empty string` |
|
||||
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
|
||||
|
|
@ -197,27 +194,6 @@ you and also enable the fallback by using the follow extractor args
|
|||
Use this alternative extractor args in case the extractor fails to get the pot tokens from the bgutil provider server.
|
||||
For more information please visit [bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider) project.
|
||||
|
||||
# How to set global settings for yt-dlp?
|
||||
|
||||
You can create a file named `ytdlp.cli` in the `/config` folder, and add your desired global settings there.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We strongly recommend to use presets instead of global settings, as presets are more convenient.
|
||||
|
||||
The `ytdlp.cli` file usage is deprecated and likely will be removed in `v1.0.0` release.
|
||||
|
||||
# Updating yt-dlp
|
||||
|
||||
The engine which powers the actual video downloads in YTPTube is [yt-dlp](https://github.com/yt-dlp/yt-dlp). Since video
|
||||
sites regularly updated, frequent updates of yt-dlp are required to keep up.
|
||||
|
||||
We have added the `YTP_YTDLP_AUTO_UPDATE` environment variable, which is enabled by default. This feature allows the
|
||||
container to automatically update `yt-dlp` to the latest version whenever the container starts. If a new version is
|
||||
available, it will be downloaded and applied automatically. To disable this automatic update, set the
|
||||
`YTP_YTDLP_AUTO_UPDATE` environment variable to `false`.
|
||||
|
||||
We will no longer release new versions of YTPTube for every new version of yt-dlp.
|
||||
|
||||
# Troubleshooting and submitting issues
|
||||
|
||||
Before asking a question or submitting an issue for YTPTube, please remember that YTPTube is only a thin wrapper for
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ class DataStore:
|
|||
|
||||
return items
|
||||
|
||||
def put(self, value: Download) -> Download:
|
||||
if "error" == value.info.status:
|
||||
def put(self, value: Download, no_notify: bool = False) -> Download:
|
||||
if "error" == value.info.status and not no_notify:
|
||||
from app.library.Events import EventBus, Events
|
||||
|
||||
asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error")
|
||||
|
|
@ -181,16 +181,18 @@ class DataStore:
|
|||
except AttributeError:
|
||||
pass
|
||||
|
||||
encoded: str = stored.json()
|
||||
|
||||
self._connection.execute(
|
||||
sqlStatement.strip(),
|
||||
(
|
||||
stored._id,
|
||||
str(type),
|
||||
stored.url,
|
||||
stored.json(),
|
||||
encoded,
|
||||
str(type),
|
||||
stored.url,
|
||||
stored.json(),
|
||||
encoded,
|
||||
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ from .ffprobe import ffprobe
|
|||
from .ItemDTO import ItemDTO
|
||||
from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies
|
||||
from .ytdlp import YTDLP
|
||||
from .YTDLPOpts import YTDLPOpts
|
||||
|
||||
|
||||
class Terminator:
|
||||
|
|
@ -27,19 +26,19 @@ class Terminator:
|
|||
|
||||
|
||||
class NestedLogger:
|
||||
debug_messages = ["[debug] ", "[download] "]
|
||||
debug_messages: list[str] = ["[debug] ", "[download] "]
|
||||
|
||||
def __init__(self, logger: logging.Logger):
|
||||
self.logger = logger
|
||||
def __init__(self, logger: logging.Logger) -> None:
|
||||
self.logger: logging.Logger = logger
|
||||
|
||||
def debug(self, msg: str):
|
||||
levelno = logging.DEBUG if any(msg.startswith(x) for x in self.debug_messages) else logging.INFO
|
||||
def debug(self, msg: str) -> None:
|
||||
levelno: int = logging.DEBUG if any(msg.startswith(x) for x in self.debug_messages) else logging.INFO
|
||||
self.logger.log(level=levelno, msg=re.sub(r"^\[(debug|info)\] ", "", msg, flags=re.IGNORECASE))
|
||||
|
||||
def error(self, msg):
|
||||
def error(self, msg) -> None:
|
||||
self.logger.error(msg)
|
||||
|
||||
def warning(self, msg):
|
||||
def warning(self, msg) -> None:
|
||||
self.logger.warning(msg)
|
||||
|
||||
|
||||
|
|
@ -110,7 +109,6 @@ class Download:
|
|||
self.template = info.template
|
||||
self.template_chapter = info.template_chapter
|
||||
self.download_info_expires = int(config.download_info_expires)
|
||||
self.preset = info.preset
|
||||
self.info = info
|
||||
self.id = info._id
|
||||
self.debug = bool(config.debug)
|
||||
|
|
@ -125,14 +123,14 @@ class Download:
|
|||
self.temp_disabled = bool(config.temp_disabled)
|
||||
self.is_live = bool(info.is_live) or info.live_in is not None
|
||||
self.info_dict = info_dict
|
||||
self.logger = logging.getLogger(f"Download.{info.id if info.id else info._id}")
|
||||
self.logger: logging.Logger = logging.getLogger(f"Download.{info.id if info.id else info._id}")
|
||||
self.started_time = 0
|
||||
self.queue_time = datetime.now(tz=UTC)
|
||||
self.queue_time: datetime = datetime.now(tz=UTC)
|
||||
self.logs = logs if logs else []
|
||||
|
||||
def _progress_hook(self, data: dict):
|
||||
if self.debug:
|
||||
d_copy = deepcopy(data)
|
||||
d_copy: dict = deepcopy(data)
|
||||
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
|
||||
d_copy["info_dict"].pop(k, None)
|
||||
|
||||
|
|
@ -148,7 +146,7 @@ class Download:
|
|||
|
||||
def _postprocessor_hook(self, data: dict):
|
||||
if self.debug:
|
||||
d_copy = deepcopy(data)
|
||||
d_copy: dict = deepcopy(data)
|
||||
for k in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"]:
|
||||
d_copy["info_dict"].pop(k, None)
|
||||
|
||||
|
|
@ -181,10 +179,7 @@ class Download:
|
|||
|
||||
try:
|
||||
params: dict = (
|
||||
YTDLPOpts.get_instance()
|
||||
.preset(self.preset)
|
||||
.add({"break_on_existing": True})
|
||||
.add_cli(args=self.info.cli, from_user=True)
|
||||
self.info.get_ytdlp_opts()
|
||||
.add(
|
||||
config={
|
||||
"color": "no_color",
|
||||
|
|
@ -263,31 +258,30 @@ class Download:
|
|||
self.info_dict = info
|
||||
|
||||
if self.is_live:
|
||||
hasDeletedOptions = False
|
||||
deletedOpts: list = []
|
||||
for opt in self.bad_live_options:
|
||||
if opt in params:
|
||||
params.pop(opt, None)
|
||||
hasDeletedOptions = True
|
||||
deletedOpts.append(opt)
|
||||
|
||||
if hasDeletedOptions:
|
||||
if len(deletedOpts) > 0:
|
||||
self.logger.warning(
|
||||
f"Live stream detected for '{self.info.title}', The following opts '{deletedOpts=}' have been deleted."
|
||||
)
|
||||
|
||||
if isinstance(self.info_dict, dict) and len(self.info_dict.get("formats", [])) < 1:
|
||||
msg = f"Failed to extract any formats for '{self.info.url}'."
|
||||
msg: str = f"Failed to extract any formats for '{self.info.url}'."
|
||||
if filtered_logs := extract_ytdlp_logs(self.logs):
|
||||
msg += " " + ", ".join(filtered_logs)
|
||||
|
||||
raise ValueError(msg) # noqa: TRY301
|
||||
|
||||
self.logger.info(
|
||||
f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" cookies="{bool(params.get("cookiefile"))}" started.'
|
||||
f'Task {self.info.name()}, preset="{self.info.preset}", cookies="{bool(params.get("cookiefile"))}" started.'
|
||||
)
|
||||
|
||||
self.logger.debug(f"Params before passing to yt-dlp. {params}")
|
||||
if self.debug:
|
||||
self.logger.debug(f"Params before passing to yt-dlp. {params}")
|
||||
|
||||
params["logger"] = NestedLogger(self.logger)
|
||||
|
||||
|
|
@ -337,7 +331,7 @@ class Download:
|
|||
self.logger.error(f"Failed to delete cookie file: {cookie_file}. {e}")
|
||||
|
||||
self.logger.info(
|
||||
f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
|
||||
f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
|
|
@ -361,7 +355,9 @@ class Download:
|
|||
|
||||
ret = await asyncio.get_running_loop().run_in_executor(None, self.proc.join)
|
||||
|
||||
if self.final_update:
|
||||
if self.final_update or self.cancelled:
|
||||
if self.cancelled:
|
||||
self.info.status = "cancelled"
|
||||
return ret
|
||||
|
||||
self.status_queue.put(Terminator())
|
||||
|
|
@ -373,9 +369,11 @@ class Download:
|
|||
|
||||
for i in range(drain_count):
|
||||
try:
|
||||
self.logger.debug(f"(50/{i}) Draining the status queue...")
|
||||
if self.debug:
|
||||
self.logger.debug(f"(50/{i}) Draining the status queue...")
|
||||
if self.final_update:
|
||||
self.logger.debug("(50/{i}) Draining stopped. Final update received.")
|
||||
if self.debug:
|
||||
self.logger.debug("(50/{i}) Draining stopped. Final update received.")
|
||||
break
|
||||
next_status = self.status_queue.get(timeout=0.1)
|
||||
if next_status is None or isinstance(next_status, Terminator):
|
||||
|
|
@ -499,12 +497,10 @@ class Download:
|
|||
return
|
||||
|
||||
if str(tmp_dir) == str(self.temp_dir):
|
||||
self.logger.warning(
|
||||
f"Attempted to delete video temp folder '{self.temp_path}', but it is the same as main temp folder."
|
||||
)
|
||||
self.logger.warning(f"Refusing to delete video temp folder '{self.temp_path}' as it's temp root.")
|
||||
return
|
||||
|
||||
status = delete_dir(tmp_dir)
|
||||
status: bool = delete_dir(tmp_dir)
|
||||
if by_pass:
|
||||
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.logger.info(f"Temp folder '{self.temp_path}' emptied.")
|
||||
|
|
@ -523,7 +519,7 @@ class Download:
|
|||
await self._notify.emit(Events.ITEM_UPDATED, data=self.info)
|
||||
return
|
||||
|
||||
self.tmpfilename = status.get("tmpfilename")
|
||||
self.tmpfilename: str | None = status.get("tmpfilename")
|
||||
|
||||
fl = None
|
||||
if "final_name" in status:
|
||||
|
|
@ -560,7 +556,7 @@ class Download:
|
|||
|
||||
if "downloaded_bytes" in status and status.get("downloaded_bytes", 0) > 0:
|
||||
self.info.downloaded_bytes = status.get("downloaded_bytes")
|
||||
total = status.get("total_bytes") or status.get("total_bytes_estimate")
|
||||
total: float | None = status.get("total_bytes") or status.get("total_bytes_estimate")
|
||||
if total:
|
||||
try:
|
||||
self.info.percent = status["downloaded_bytes"] / total * 100
|
||||
|
|
@ -617,7 +613,7 @@ class Download:
|
|||
return False
|
||||
|
||||
if self.started_time < 1:
|
||||
self.logger.debug(f"Download task '{self.info.title}: {self.info.id}' not started yet.")
|
||||
self.logger.debug(f"Download task '{self.info.name()}' not started yet.")
|
||||
return False
|
||||
|
||||
if int(time.time()) - self.started_time < 300:
|
||||
|
|
@ -633,7 +629,7 @@ class Download:
|
|||
return True
|
||||
|
||||
if self.info.status not in ["finished", "error", "cancelled", "downloading", "postprocessing"]:
|
||||
status = self.info.status if self.info.status else "unknown"
|
||||
status: str = self.info.status if self.info.status else "unknown"
|
||||
self.logger.warning(
|
||||
f"Download task '{self.info.title}: {self.info.id}' has been stuck in '{status}' state for '{int(time.time()) - self.started_time}' seconds."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,17 +29,15 @@ from .Utils import (
|
|||
dt_delta,
|
||||
extract_info,
|
||||
extract_ytdlp_logs,
|
||||
is_downloaded,
|
||||
load_cookies,
|
||||
str_to_dt,
|
||||
ytdlp_reject,
|
||||
)
|
||||
from .YTDLPOpts import YTDLPOpts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.library.Presets import Preset
|
||||
|
||||
LOG = logging.getLogger("DownloadQueue")
|
||||
LOG: logging.Logger = logging.getLogger("DownloadQueue")
|
||||
|
||||
|
||||
class DownloadQueue(metaclass=Singleton):
|
||||
|
|
@ -87,7 +85,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
|
||||
|
||||
@staticmethod
|
||||
def get_instance():
|
||||
def get_instance() -> "DownloadQueue":
|
||||
"""
|
||||
Get the instance of the DownloadQueue.
|
||||
|
||||
|
|
@ -100,7 +98,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
return DownloadQueue._instance
|
||||
|
||||
def attach(self, _: web.Application):
|
||||
def attach(self, _: web.Application) -> None:
|
||||
"""
|
||||
Attach the download queue to the application.
|
||||
|
||||
|
|
@ -136,13 +134,11 @@ class DownloadQueue(metaclass=Singleton):
|
|||
await self.done.test()
|
||||
return True
|
||||
|
||||
async def initialize(self):
|
||||
async def initialize(self) -> None:
|
||||
"""
|
||||
Initialize the download queue.
|
||||
"""
|
||||
LOG.info(
|
||||
f"Using '{self.config.max_workers}' worker/s for downloading. Can be configured via `YTP_MAX_WORKERS` environment variable."
|
||||
)
|
||||
LOG.info(f"Using '{self.config.max_workers}' workers for downloading.")
|
||||
asyncio.create_task(self._download_pool(), name="download_pool")
|
||||
|
||||
async def start_items(self, ids: list[str]) -> dict[str, str]:
|
||||
|
|
@ -158,11 +154,11 @@ class DownloadQueue(metaclass=Singleton):
|
|||
"""
|
||||
status: dict[str, str] = {"status": "ok"}
|
||||
started = False
|
||||
tasks = []
|
||||
tasks: list = []
|
||||
|
||||
for item_id in ids:
|
||||
try:
|
||||
item = self.queue.get(key=item_id)
|
||||
item: Download = self.queue.get(key=item_id)
|
||||
except KeyError as e:
|
||||
status[item_id] = f"not found: {e!s}"
|
||||
status["status"] = "error"
|
||||
|
|
@ -175,7 +171,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
item.info.auto_start = True
|
||||
updated = self.queue.put(item)
|
||||
updated: Download = self.queue.put(item)
|
||||
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
|
||||
tasks.append(
|
||||
self._notify.emit(
|
||||
|
|
@ -209,11 +205,11 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
"""
|
||||
status: dict[str, str] = {"status": "ok"}
|
||||
tasks = []
|
||||
tasks: list = []
|
||||
|
||||
for item_id in ids:
|
||||
try:
|
||||
item = self.queue.get(key=item_id)
|
||||
item: Download = self.queue.get(key=item_id)
|
||||
except KeyError as e:
|
||||
status[item_id] = f"not found: {e!s}"
|
||||
status["status"] = "error"
|
||||
|
|
@ -231,7 +227,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
item.info.auto_start = False
|
||||
updated = self.queue.put(item)
|
||||
updated: Download = self.queue.put(item)
|
||||
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
|
||||
tasks.append(
|
||||
self._notify.emit(
|
||||
|
|
@ -416,20 +412,20 @@ class DownloadQueue(metaclass=Singleton):
|
|||
LOG.debug(f"Entry id '{entry.get('id')}' url '{entry.get('webpage_url')} - {entry.get('url')}'.")
|
||||
|
||||
try:
|
||||
_item = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url"))
|
||||
if _item is not None:
|
||||
err_msg = f"Item '{_item.info.id}' - '{_item.info.title}' already exists. Removing from history."
|
||||
LOG.warning(err_msg)
|
||||
await self.clear([_item.info._id], remove_file=False)
|
||||
_item: Download = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url"))
|
||||
err_msg: str = f"Removing {_item.info.name()} from history list."
|
||||
LOG.warning(err_msg)
|
||||
await self.clear([_item.info._id], remove_file=False)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
_item = self.queue.get(key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url")))
|
||||
if _item is not None:
|
||||
err_msg = f"Item ID '{_item.info.id}' - '{_item.info.title}' already in download queue."
|
||||
LOG.info(err_msg)
|
||||
return {"status": "error", "msg": err_msg}
|
||||
_item: Download = self.queue.get(
|
||||
key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url"))
|
||||
)
|
||||
err_msg: str = f"Item {_item.info.name()} is already in download queue."
|
||||
LOG.info(err_msg)
|
||||
return {"status": "error", "msg": err_msg}
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
|
@ -494,7 +490,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
nEvent = Events.ITEM_MOVED
|
||||
nStore = "history"
|
||||
nTitle = "Upcoming Premiere" if is_premiere else "Upcoming Live Stream"
|
||||
nMessage = f"{'Premiere video' if is_premiere else 'Stream' } '{dlInfo.info.title}' is not available yet. {text_logs}"
|
||||
nMessage = f"{'Premiere video' if is_premiere else 'Stream'} '{dlInfo.info.title}' is not available yet. {text_logs}"
|
||||
|
||||
dlInfo.info.status = "not_live"
|
||||
dlInfo.info.msg = nMessage.replace(f" '{dlInfo.info.title}'", "")
|
||||
|
|
@ -535,7 +531,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
LOG.error(f"Failed to parse live_in date '{release_in}'. {e!s}")
|
||||
dlInfo.info.error += f" Failed to parse live_in date '{release_in}'."
|
||||
else:
|
||||
dlInfo.info.error += f" Delaying download by '{300+dl.extras.get('duration',0)}' seconds."
|
||||
dlInfo.info.error += f" Delaying download by '{300 + dl.extras.get('duration', 0)}' seconds."
|
||||
|
||||
nMessage = f"'{dlInfo.info.title}': '{dlInfo.info.error.strip()}'."
|
||||
|
||||
|
|
@ -602,10 +598,10 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if event_type.startswith("url"):
|
||||
return await self.add(item=item.new_with(url=entry.get("url")), already=already)
|
||||
|
||||
if not event_type.startswith("video"):
|
||||
return {"status": "error", "msg": f'Unsupported event type "{event_type}".'}
|
||||
if event_type.startswith("video"):
|
||||
return await self._add_video(entry=entry, item=item, logs=logs)
|
||||
|
||||
return await self._add_video(entry=entry, item=item, logs=logs)
|
||||
return {"status": "error", "msg": f'Unsupported event type "{event_type}".'}
|
||||
|
||||
async def add(self, item: Item, already: set | None = None):
|
||||
"""
|
||||
|
|
@ -658,16 +654,15 @@ class DownloadQueue(metaclass=Singleton):
|
|||
"level": logging.WARNING,
|
||||
"name": "callback-logger",
|
||||
},
|
||||
**YTDLPOpts.get_instance().preset(name=item.preset).add_cli(args=item.cli, from_user=True).get_all(),
|
||||
**item.get_ytdlp_opts().get_all(),
|
||||
}
|
||||
|
||||
if yt_conf.get("external_downloader"):
|
||||
LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.")
|
||||
item.extras.update({"external_downloader": True})
|
||||
|
||||
downloaded, id_dict = self._is_downloaded(file=yt_conf.get("download_archive", None), url=item.url)
|
||||
if downloaded is True and id_dict:
|
||||
message = f"'{id_dict.get('id')}': The URL '{item.url}' is already downloaded and recorded in archive."
|
||||
if item.is_archived():
|
||||
message: str = f"The URL '{item.url}' is already downloaded and recorded in archive."
|
||||
LOG.error(message)
|
||||
await self._notify.emit(Events.LOG_INFO, title="Already Downloaded", message=message)
|
||||
return {"status": "error", "msg": message}
|
||||
|
|
@ -686,7 +681,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
LOG.info(f"Checking '{item.url}' with {'cookies' if yt_conf.get('cookiefile') else 'no cookies'}.")
|
||||
|
||||
entry = await asyncio.wait_for(
|
||||
entry: dict | None = await asyncio.wait_for(
|
||||
fut=asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
|
|
@ -980,9 +975,9 @@ class DownloadQueue(metaclass=Singleton):
|
|||
self._active[entry.info._id] = entry
|
||||
await entry.start()
|
||||
|
||||
if entry.info.status not in ("finished", "skip"):
|
||||
if entry.info.status not in ("finished", "skip", "cancelled"):
|
||||
if not entry.info.error:
|
||||
entry.info.error = f"Download failed with status '{entry.info.status}'."
|
||||
entry.info.error = f"Download ended with unexpected status '{entry.info.status}'."
|
||||
entry.info.status = "error"
|
||||
except Exception as e:
|
||||
entry.info.status = "error"
|
||||
|
|
@ -1010,9 +1005,12 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if entry.info.status == "finished" and entry.info.filename:
|
||||
nTitle = "Download Completed"
|
||||
nMessage = f"Completed '{entry.info.title}' download."
|
||||
if entry.info.is_archivable and not entry.info.is_archived:
|
||||
entry.info.is_archived = True
|
||||
|
||||
_tasks.append(self._notify.emit(Events.ITEM_COMPLETED, data=entry.info, title=nTitle, message=nMessage))
|
||||
|
||||
self.done.put(value=entry)
|
||||
self.done.put(entry)
|
||||
_tasks.append(
|
||||
self._notify.emit(
|
||||
Events.ITEM_MOVED,
|
||||
|
|
@ -1029,23 +1027,6 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if self.event:
|
||||
self.event.set()
|
||||
|
||||
def _is_downloaded(self, url: str, file: str | None = None) -> tuple[bool, dict | None]:
|
||||
"""
|
||||
Check if the url has been downloaded already.
|
||||
|
||||
Args:
|
||||
url (str): The url to check.
|
||||
file (str | None): The archive file to check.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple with the status of the operation and the id of the downloaded item.
|
||||
|
||||
"""
|
||||
if not url or not file:
|
||||
return False, None
|
||||
|
||||
return is_downloaded(file, url)
|
||||
|
||||
async def _check_for_stale(self):
|
||||
"""
|
||||
Monitor pool for stale downloads and cancel them if needed.
|
||||
|
|
@ -1074,7 +1055,8 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if self.is_paused() or self.done.empty():
|
||||
return
|
||||
|
||||
LOG.debug("Checking history queue for queued live stream links.")
|
||||
if self.config.debug:
|
||||
LOG.debug("Checking history queue for queued live stream links.")
|
||||
|
||||
time_now = datetime.now(tz=UTC)
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ class HttpAPI:
|
|||
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
|
||||
def add_routes(self, app: web.Application):
|
||||
def add_routes(self, app: web.Application) -> None:
|
||||
"""
|
||||
Add the routes to the application.
|
||||
|
||||
|
|
@ -133,7 +133,8 @@ class HttpAPI:
|
|||
elif "" == base_path or not routePath.rstrip("/").startswith(base_path.rstrip("/")):
|
||||
route.path = f"{base_path}/{route.path.lstrip('/')}"
|
||||
|
||||
LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.")
|
||||
if self.config.debug:
|
||||
LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.")
|
||||
|
||||
app.router.add_route(route.method, route.path, handler=_handle(route.handler), name=route.name)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,9 +110,10 @@ class HttpSocket:
|
|||
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
|
||||
|
||||
for route in get_routes(RouteType.SOCKET).values():
|
||||
LOG.debug(
|
||||
f"Add ({route.name}) {route.method.value if isinstance(route.method,RouteType) else route.method}: {route.path}."
|
||||
)
|
||||
if self.config.debug:
|
||||
LOG.debug(
|
||||
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
|
||||
)
|
||||
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path))
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ from dataclasses import dataclass, field
|
|||
from email.utils import formatdate
|
||||
from typing import Any
|
||||
|
||||
from app.library.Utils import clean_item
|
||||
from app.library.Utils import archive_add, archive_delete, archive_read, clean_item, get_archive_id
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG = logging.getLogger("ItemDTO")
|
||||
LOG: logging.Logger = logging.getLogger("ItemDTO")
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
|
@ -92,29 +93,6 @@ class Item:
|
|||
"""
|
||||
return Item.format({**self.serialize(), **kwargs})
|
||||
|
||||
def __repr__(self):
|
||||
from .config import Config
|
||||
from .Utils import calc_download_path, strip_newline
|
||||
|
||||
data = {}
|
||||
for k, v in self.serialize().items():
|
||||
if not v and k not in ("auto_start"):
|
||||
continue
|
||||
|
||||
if k == "cli":
|
||||
data[k] = strip_newline(v)
|
||||
elif k == "extras":
|
||||
data[k] = f"{len(v)} items"
|
||||
elif k == "cookies":
|
||||
data[k] = f"{len(v)}/chars"
|
||||
elif k == "folder":
|
||||
data[k] = calc_download_path(base_path=Config.get_instance().download_path, folder=v)
|
||||
else:
|
||||
data[k] = v
|
||||
|
||||
items = "".join(f'{k}="{v}", ' for k, v in data.items() if v)
|
||||
return f"Item({items.strip(', ')})"
|
||||
|
||||
@staticmethod
|
||||
def format(item: dict) -> "Item":
|
||||
"""
|
||||
|
|
@ -137,16 +115,14 @@ class Item:
|
|||
msg = "url param is required."
|
||||
raise ValueError(msg)
|
||||
|
||||
data = {
|
||||
"url": url,
|
||||
}
|
||||
data: dict[str, str] = {"url": url}
|
||||
|
||||
preset = item.get("preset")
|
||||
preset: str | None = item.get("preset")
|
||||
if preset and isinstance(preset, str) and preset != Item._default_preset():
|
||||
from .Presets import Presets
|
||||
|
||||
if not Presets.get_instance().has(preset):
|
||||
msg = f"Preset '{preset}' does not exist."
|
||||
msg: str = f"Preset '{preset}' does not exist."
|
||||
raise ValueError(msg)
|
||||
|
||||
data["preset"] = preset
|
||||
|
|
@ -163,19 +139,19 @@ class Item:
|
|||
if "auto_start" in item and isinstance(item.get("auto_start"), bool):
|
||||
data["auto_start"] = bool(item.get("auto_start"))
|
||||
|
||||
extras = item.get("extras")
|
||||
extras: dict | None = item.get("extras")
|
||||
if extras and isinstance(extras, dict) and len(extras) > 0:
|
||||
data["extras"] = extras
|
||||
|
||||
if item.get("requeued") and isinstance(item.get("requeued"), bool):
|
||||
data["requeued"] = item.get("requeued")
|
||||
|
||||
cli = item.get("cli")
|
||||
cli: str | None = item.get("cli")
|
||||
if cli and len(cli) > 2:
|
||||
from .Utils import arg_converter
|
||||
|
||||
try:
|
||||
removed_options = []
|
||||
removed_options: list = []
|
||||
arg_converter(args=cli, level=True, removed_options=removed_options)
|
||||
if len(removed_options) > 0:
|
||||
LOG.warning("Removed the following options '%s' for '%s'.", ", ".join(removed_options), url)
|
||||
|
|
@ -187,6 +163,55 @@ class Item:
|
|||
|
||||
return Item(**data)
|
||||
|
||||
def get_archive_id(self) -> str | None:
|
||||
if not self.url:
|
||||
return None
|
||||
|
||||
idDict: dict = get_archive_id(self.url)
|
||||
return idDict.get("archive_id")
|
||||
|
||||
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if self.preset:
|
||||
params = params.preset(name=self.preset)
|
||||
|
||||
if self.cli:
|
||||
params = params.add_cli(self.cli, from_user=True)
|
||||
|
||||
return params
|
||||
|
||||
def get_archive_file(self) -> str | None:
|
||||
return self.get_ytdlp_opts().get_all().get("download_archive")
|
||||
|
||||
def is_archived(self) -> bool:
|
||||
archive_id: str | None = self.get_archive_id()
|
||||
archive_file: str | None = self.get_archive_file()
|
||||
return len(archive_read(archive_file, [archive_id])) > 0 if archive_file and archive_id else False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
from .config import Config
|
||||
from .Utils import calc_download_path, strip_newline
|
||||
|
||||
data: dict = {}
|
||||
for k, v in self.serialize().items():
|
||||
if not v and k not in ("auto_start"):
|
||||
continue
|
||||
|
||||
if k == "cli":
|
||||
data[k] = strip_newline(v)
|
||||
elif k == "extras":
|
||||
data[k] = f"{len(v)} items"
|
||||
elif k == "cookies":
|
||||
data[k] = f"{len(v)}/chars"
|
||||
elif k == "folder":
|
||||
data[k] = calc_download_path(base_path=Config.get_instance().download_path, folder=v)
|
||||
else:
|
||||
data[k] = v
|
||||
|
||||
items: str = "".join(f'{k}="{v}", ' for k, v in data.items() if v is not None)
|
||||
return f"Item({items.strip(', ')})"
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ItemDTO:
|
||||
|
|
@ -196,30 +221,55 @@ class ItemDTO:
|
|||
"""
|
||||
|
||||
_id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
|
||||
|
||||
""" Unique identifier for the item. """
|
||||
error: str | None = None
|
||||
""" Error message if the item failed. """
|
||||
id: str
|
||||
""" The ID of the item yt-dlp """
|
||||
title: str
|
||||
""" The title of the item. """
|
||||
url: str
|
||||
quality: str | None = None
|
||||
format: str | None = None
|
||||
""" The URL of the item. """
|
||||
preset: str = "default"
|
||||
""" The preset to be used for the item. """
|
||||
folder: str
|
||||
""" The folder to save the item to. """
|
||||
download_dir: str | None = None
|
||||
""" The full path to the download directory. """
|
||||
temp_dir: str | None = None
|
||||
""" The full path to the temporary directory. """
|
||||
status: str | None = None
|
||||
""" The status of the item. """
|
||||
cookies: str | None = None
|
||||
""" The cookies to be used for the item. """
|
||||
template: str | None = None
|
||||
""" The output template to be used for the item. """
|
||||
template_chapter: str | None = None
|
||||
""" The output template for chapters to be used for the item. """
|
||||
timestamp: float = field(default_factory=lambda: time.time_ns())
|
||||
""" The timestamp of the item. """
|
||||
is_live: bool | None = None
|
||||
""" If the item is a live stream. """
|
||||
datetime: str = field(default_factory=lambda: str(formatdate(time.time())))
|
||||
""" The datetime of the item. """
|
||||
live_in: str | None = None
|
||||
""" The time until the live stream starts. """
|
||||
file_size: int | None = None
|
||||
""" The file size of the item. """
|
||||
options: dict = field(default_factory=dict)
|
||||
""" The options used for the item. """
|
||||
extras: dict = field(default_factory=dict)
|
||||
""" Extra data associated with the item. """
|
||||
cli: str = ""
|
||||
""" The command options for yt-dlp to be used for this download. """
|
||||
auto_start: bool = True
|
||||
""" If the item should be started automatically. """
|
||||
is_archivable: bool | None = None
|
||||
""" If the item can be archived. """
|
||||
is_archived: bool | None = None
|
||||
""" If the item has been archived. """
|
||||
archive_id: str | None = None
|
||||
""" The archive ID of the item. """
|
||||
|
||||
# yt-dlp injected fields.
|
||||
tmpfilename: str | None = None
|
||||
|
|
@ -232,7 +282,13 @@ class ItemDTO:
|
|||
speed: str | None = None
|
||||
eta: str | None = None
|
||||
|
||||
_recomputed: bool = False
|
||||
_archive_file: str | None = None
|
||||
|
||||
def serialize(self) -> dict:
|
||||
if "finished" == self.status and not self._recomputed:
|
||||
self.archive_status()
|
||||
|
||||
item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields())
|
||||
return item
|
||||
|
||||
|
|
@ -248,6 +304,104 @@ class ItemDTO:
|
|||
def name(self) -> str:
|
||||
return f'id="{self.id}", title="{self.title}"'
|
||||
|
||||
def get_archive_id(self) -> str | None:
|
||||
"""
|
||||
Get the archive ID for the download URL.
|
||||
|
||||
Returns:
|
||||
str | None: The archive ID if available, None otherwise.
|
||||
|
||||
"""
|
||||
if self.archive_id:
|
||||
return self.archive_id
|
||||
|
||||
if not self.url:
|
||||
return None
|
||||
|
||||
idDict: dict = get_archive_id(self.url)
|
||||
self.archive_id = idDict.get("archive_id")
|
||||
|
||||
return self.archive_id
|
||||
|
||||
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||
"""
|
||||
Get the yt-dlp options for the item.
|
||||
|
||||
Returns:
|
||||
YTDLPOpts: The yt-dlp options for the item.
|
||||
|
||||
"""
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if self.preset:
|
||||
params = params.preset(name=self.preset)
|
||||
|
||||
if self.cli:
|
||||
params = params.add_cli(self.cli, from_user=True)
|
||||
|
||||
return params
|
||||
|
||||
def archive_status(self, force: bool = False) -> None:
|
||||
if not force and (self._recomputed or not self.archive_id):
|
||||
return
|
||||
|
||||
if "finished" == self.status:
|
||||
self._recomputed = True
|
||||
|
||||
self.is_archivable = bool(self._archive_file)
|
||||
if not self.is_archivable:
|
||||
self.is_archived = False
|
||||
else:
|
||||
self.is_archived = len(archive_read(self._archive_file, [self.archive_id])) > 0
|
||||
|
||||
def get_archive_file(self) -> str | None:
|
||||
"""
|
||||
Get the archive file path from the yt-dlp options.
|
||||
|
||||
Returns:
|
||||
str | None: The archive file path if available, None otherwise.
|
||||
|
||||
"""
|
||||
if self._archive_file or self._recomputed or not self.archive_id:
|
||||
return self._archive_file
|
||||
|
||||
self._archive_file = self.get_ytdlp_opts().get_all().get("download_archive")
|
||||
if self._archive_file:
|
||||
self._archive_file = self._archive_file.strip()
|
||||
|
||||
return self._archive_file
|
||||
|
||||
def archive_add(self) -> bool:
|
||||
"""
|
||||
Archive the item by adding its archive ID to the download archive file.
|
||||
|
||||
Returns:
|
||||
bool: True if the item was archived, False otherwise.
|
||||
|
||||
"""
|
||||
if self.is_archived or not self.is_archivable or not self.archive_id or not self._archive_file:
|
||||
return False
|
||||
|
||||
self.is_archived = archive_add(self._archive_file, [self.archive_id])
|
||||
|
||||
return self.is_archived
|
||||
|
||||
def archive_delete(self) -> bool:
|
||||
"""
|
||||
Remove the item's archive ID from the download archive file.
|
||||
|
||||
Returns:
|
||||
bool: True if the item was removed from the archive, False otherwise.
|
||||
|
||||
"""
|
||||
if not self.is_archivable or not self.is_archived or not self.archive_id or not self._archive_file:
|
||||
return False
|
||||
|
||||
archive_delete(self._archive_file, [self.archive_id])
|
||||
self.is_archived = False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def removed_fields() -> tuple:
|
||||
"""Fields that once existed but are no longer used."""
|
||||
|
|
@ -261,4 +415,11 @@ class ItemDTO:
|
|||
"output_template_chapter",
|
||||
"config",
|
||||
"temp_path",
|
||||
"_recomputed",
|
||||
"_archive_file",
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
self.get_archive_id()
|
||||
self.get_archive_file()
|
||||
self.archive_status()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from typing import Any
|
|||
from aiohttp import web
|
||||
|
||||
from .config import Config
|
||||
from .encoder import Encoder
|
||||
from .Events import EventBus, Events
|
||||
from .Singleton import Singleton
|
||||
from .Utils import arg_converter, init_class
|
||||
|
|
@ -92,6 +91,7 @@ class Preset:
|
|||
return self.__dict__
|
||||
|
||||
def json(self) -> str:
|
||||
from .encoder import Encoder
|
||||
return Encoder().encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ class Scheduler(metaclass=Singleton):
|
|||
|
||||
self._jobs[job_id] = job
|
||||
|
||||
LOG.debug(f"Added '{job_id}' to the scheduler.")
|
||||
LOG.debug(f"Added '{job_id}' to the scheduler to run on '{timer}'.")
|
||||
|
||||
return job_id
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from .ItemDTO import Item
|
|||
from .Scheduler import Scheduler
|
||||
from .Services import Services
|
||||
from .Singleton import Singleton
|
||||
from .Utils import extract_info, init_class, validate_url
|
||||
from .Utils import archive_add, archive_delete, extract_info, init_class, validate_url
|
||||
from .YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("tasks")
|
||||
|
|
@ -47,51 +47,69 @@ class Task:
|
|||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.serialize().get(key, default)
|
||||
|
||||
def mark(self) -> tuple[bool, str]:
|
||||
if not self.url:
|
||||
return False, "No URL found in task parameters."
|
||||
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if self.preset:
|
||||
params = params.preset(name=self.preset)
|
||||
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=self.preset)
|
||||
if self.cli:
|
||||
params.add_cli(self.cli, from_user=True)
|
||||
params = params.add_cli(self.cli, from_user=True)
|
||||
|
||||
params = params.get_all()
|
||||
if not (_archive := params.get("download_archive", None)):
|
||||
return False, "No archive file found in task parameters."
|
||||
return params
|
||||
|
||||
_archive: Path = Path(_archive)
|
||||
if not _archive.parent.exists():
|
||||
_archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
def mark(self) -> tuple[bool, str]:
|
||||
ret = self._mark_logic()
|
||||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
if not _archive.exists():
|
||||
_archive.touch()
|
||||
archive_file: Path = ret.get("file")
|
||||
items: set[str] = ret.get("items", set())
|
||||
|
||||
_info = extract_info(params, self.url, no_archive=True, follow_redirect=True)
|
||||
if not _info or not isinstance(_info, dict):
|
||||
return False, "Failed to extract information from URL."
|
||||
if len(items) < 1 or not archive_add(archive_file, list(items)):
|
||||
return (True, "No new items to mark as downloaded.")
|
||||
|
||||
if "playlist" != _info.get("_type"):
|
||||
return False, "Expected a playlist type from extract_info."
|
||||
return (True, f"Task '{self.name}' items marked as downloaded.")
|
||||
|
||||
archived_items: list[str] = []
|
||||
with _archive.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or not isinstance(line, str) or line.startswith("#"):
|
||||
continue
|
||||
def unmark(self) -> tuple[bool, str]:
|
||||
ret: tuple[bool, str] | set[tuple[Path, set[str]]] = self._mark_logic()
|
||||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
archived_items.append(line)
|
||||
archive_file: Path = ret.get("file")
|
||||
items: set[str] = ret.get("items", set())
|
||||
|
||||
def _process(item: dict) -> bool:
|
||||
status = False
|
||||
if len(items) < 1 or not archive_delete(archive_file, list(items)):
|
||||
return (True, "No items to remove from archive file.")
|
||||
|
||||
return (True, f"Removed '{self.name}' items from archive file.")
|
||||
|
||||
def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]:
|
||||
if not self.url:
|
||||
return (False, "No URL found in task parameters.")
|
||||
|
||||
params: dict = self.get_ytdlp_opts().get_all()
|
||||
if not (archive_file := params.get("download_archive")):
|
||||
return (False, "No archive file found.")
|
||||
|
||||
archive_file: Path = Path(archive_file)
|
||||
|
||||
ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=True)
|
||||
if not ie_info or not isinstance(ie_info, dict):
|
||||
return (False, "Failed to extract information from URL.")
|
||||
|
||||
if "playlist" != ie_info.get("_type"):
|
||||
return (False, "Expected a playlist type from extract_info.")
|
||||
|
||||
items: set[str] = set()
|
||||
|
||||
def _process(item: dict):
|
||||
for entry in item.get("entries", []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
if "playlist" == entry.get("_type"):
|
||||
_status = _process(entry)
|
||||
if status is False:
|
||||
status = _status
|
||||
_process(entry)
|
||||
continue
|
||||
|
||||
if entry.get("_type") not in ("video", "url"):
|
||||
|
|
@ -100,26 +118,13 @@ class Task:
|
|||
if not entry.get("id") or not entry.get("ie_key"):
|
||||
continue
|
||||
|
||||
archive_id = f'{entry.get("ie_key","").lower()} {entry.get("id")}'
|
||||
archive_id: str = f"{entry.get('ie_key', '').lower()} {entry.get('id')}"
|
||||
|
||||
if archive_id in archived_items:
|
||||
continue
|
||||
items.add(archive_id)
|
||||
|
||||
archived_items.append(archive_id)
|
||||
status = True
|
||||
_process(ie_info)
|
||||
|
||||
return status
|
||||
|
||||
updated = _process(_info)
|
||||
|
||||
if not updated:
|
||||
return True, "No new items to mark as downloaded."
|
||||
|
||||
with _archive.open("a") as f:
|
||||
for item in archived_items:
|
||||
f.write(f"{item}\n")
|
||||
|
||||
return True, f"Task '{self.name}' marked as downloaded. Updated archive file '{_archive}'."
|
||||
return {"file": archive_file, "items": items}
|
||||
|
||||
|
||||
class Tasks(metaclass=Singleton):
|
||||
|
|
@ -417,6 +422,7 @@ class Tasks(metaclass=Singleton):
|
|||
"folder": folder,
|
||||
"template": template,
|
||||
"cli": cli,
|
||||
"auto_start": task.auto_start,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -544,7 +550,11 @@ class HandleTask:
|
|||
if handler is None:
|
||||
return None
|
||||
|
||||
return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs)
|
||||
try:
|
||||
return await Services.get_instance().handle_async(handler=handler.handle, task=task, **kwargs)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
raise
|
||||
|
||||
def _discover(self) -> list[type]:
|
||||
import importlib
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from datetime import UTC, datetime, timedelta
|
|||
from functools import lru_cache
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from yt_dlp.utils import age_restricted, match_str
|
||||
|
|
@ -63,7 +63,7 @@ REMOVE_KEYS: list = [
|
|||
|
||||
YTDLP_INFO_CLS: YTDLP = None
|
||||
|
||||
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
|
||||
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
|
||||
|
||||
FILES_TYPE: list = [
|
||||
{"rx": re.compile(r"\.(avi|ts|mkv|mp4|mp3|mpv|ogm|m4v|webm|m4b)$", re.IGNORECASE), "type": "video"},
|
||||
|
|
@ -203,7 +203,7 @@ def extract_info(
|
|||
if no_archive and "download_archive" in params:
|
||||
del params["download_archive"]
|
||||
|
||||
data = YTDLP(params=params).extract_info(url, download=False)
|
||||
data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False)
|
||||
|
||||
if data and follow_redirect and "_type" in data and "url" == data["_type"]:
|
||||
return extract_info(
|
||||
|
|
@ -263,112 +263,6 @@ def merge_dict(source: dict, destination: dict) -> dict:
|
|||
return destination_copy
|
||||
|
||||
|
||||
def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
|
||||
"""
|
||||
Check if the video is already downloaded.
|
||||
|
||||
Args:
|
||||
archive_file (str): Archive file path.
|
||||
url (str): URL to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the video is already downloaded.
|
||||
dict: Video information.
|
||||
|
||||
"""
|
||||
idDict = {"id": None, "ie_key": None, "archive_id": None}
|
||||
|
||||
if not url or not archive_file:
|
||||
return (False, idDict)
|
||||
|
||||
if not Path(archive_file).exists():
|
||||
return (False, idDict)
|
||||
|
||||
idDict = get_archive_id(url=url)
|
||||
|
||||
if idDict.get("archive_id"):
|
||||
with open(archive_file) as f:
|
||||
for line in f:
|
||||
if idDict["archive_id"] in line:
|
||||
return (True, idDict)
|
||||
|
||||
return (False, idDict)
|
||||
|
||||
|
||||
def remove_from_archive(archive_file: str | Path, url: str) -> bool:
|
||||
"""
|
||||
Remove the downloaded video record from the archive file.
|
||||
|
||||
Args:
|
||||
archive_file (str): Archive file path.
|
||||
url (str): URL to check and remove.
|
||||
|
||||
Returns:
|
||||
bool: True if the record removed, False otherwise.
|
||||
|
||||
"""
|
||||
if not url or not archive_file:
|
||||
return False
|
||||
|
||||
archive_path: Path = Path(archive_file) if not isinstance(archive_file, Path) else archive_file
|
||||
if not archive_path.exists():
|
||||
return False
|
||||
|
||||
idDict = get_archive_id(url=url)
|
||||
archive_id: str | None = idDict.get("archive_id")
|
||||
|
||||
if not archive_id:
|
||||
return False
|
||||
|
||||
lines: list[str] = archive_path.read_text(encoding="utf-8").splitlines()
|
||||
new_lines: list[str] = [line for line in lines if archive_id not in line]
|
||||
|
||||
if len(lines) == len(new_lines):
|
||||
return False
|
||||
|
||||
archive_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
|
||||
"""
|
||||
Load a JSON or JSON5 file and return the contents as a dictionary
|
||||
|
||||
Args:
|
||||
file (str): File path
|
||||
check_type (type): Type to check the loaded file against.
|
||||
|
||||
Returns tuple:
|
||||
dict|list: Dictionary or list of the file contents. Empty dict if the file could not be loaded.
|
||||
bool: True if the file was loaded successfully.
|
||||
str: Error message if the file could not be loaded.
|
||||
|
||||
"""
|
||||
try:
|
||||
with open(file) as json_data:
|
||||
opts = json.load(json_data)
|
||||
|
||||
if check_type:
|
||||
assert isinstance(opts, check_type)
|
||||
|
||||
return (opts, True, "")
|
||||
except Exception:
|
||||
with open(file) as json_data:
|
||||
from pyjson5 import load as json5_load
|
||||
|
||||
try:
|
||||
opts = json5_load(json_data)
|
||||
|
||||
if check_type:
|
||||
assert isinstance(opts, check_type)
|
||||
|
||||
return (opts, True, "")
|
||||
except AssertionError:
|
||||
return ({}, False, f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.")
|
||||
except Exception as e:
|
||||
return ({}, False, f"{e}")
|
||||
|
||||
|
||||
def check_id(file: Path) -> bool | str:
|
||||
"""
|
||||
Check if we are able to get an id from the file name.
|
||||
|
|
@ -405,13 +299,9 @@ def check_id(file: Path) -> bool | str:
|
|||
|
||||
@lru_cache(maxsize=512)
|
||||
def is_private_address(hostname: str) -> bool:
|
||||
try:
|
||||
ip = socket.gethostbyname(hostname)
|
||||
ip_obj = ipaddress.ip_address(ip)
|
||||
return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local
|
||||
except socket.gaierror:
|
||||
# Could not resolve - treat as invalid or restricted
|
||||
return True
|
||||
ip = socket.gethostbyname(hostname)
|
||||
ip_obj = ipaddress.ip_address(ip)
|
||||
return ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_reserved or ip_obj.is_link_local
|
||||
|
||||
|
||||
def validate_url(url: str, allow_internal: bool = False) -> bool:
|
||||
|
|
@ -447,10 +337,20 @@ def validate_url(url: str, allow_internal: bool = False) -> bool:
|
|||
raise ValueError(msg)
|
||||
|
||||
hostname: str | None = parsed_url.host
|
||||
if allow_internal is False and (not hostname or is_private_address(hostname)):
|
||||
msg = "Access to internal urls or private networks is not allowed."
|
||||
if not hostname:
|
||||
msg = "Invalid hostname."
|
||||
raise ValueError(msg)
|
||||
|
||||
if allow_internal is False:
|
||||
try:
|
||||
if is_private_address(hostname):
|
||||
msg = "Access to internal urls or private networks is not allowed."
|
||||
raise ValueError(msg)
|
||||
except socket.gaierror as e:
|
||||
LOG.error(f"Error resolving hostname '{hostname}': {e!s}")
|
||||
msg = "Invalid hostname."
|
||||
raise ValueError(msg) from e
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -489,6 +389,10 @@ def arg_converter(
|
|||
|
||||
default_opts = _default_opts([]).ydl_opts
|
||||
|
||||
if args:
|
||||
# important to ignore external config files.
|
||||
args = "--ignore-config " + args
|
||||
|
||||
opts = yt_dlp.parse_options(shlex.split(args)).ydl_opts
|
||||
diff = {k: v for k, v in opts.items() if default_opts[k] != v} if not keep_defaults else opts.items()
|
||||
if "postprocessors" in diff:
|
||||
|
|
@ -1101,7 +1005,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
|
|||
raise ValueError(msg) from e
|
||||
|
||||
|
||||
def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
|
||||
def get_archive_id(url: str) -> dict[str, str | None]:
|
||||
"""
|
||||
Get the archive ID for a given URL.
|
||||
|
||||
|
|
@ -1109,8 +1013,11 @@ def get_archive_id(url: str) -> tuple[bool, dict[str | None, str | None, str | N
|
|||
url (str): URL to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the video is already downloaded.
|
||||
dict: Video information.
|
||||
dict: {
|
||||
"id": str | None,
|
||||
"ie_key": str | None,
|
||||
"archive_id": str | None
|
||||
}
|
||||
|
||||
"""
|
||||
global YTDLP_INFO_CLS # noqa: PLW0603
|
||||
|
|
@ -1279,14 +1186,11 @@ def load_modules(root_path: Path, directory: Path):
|
|||
|
||||
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
|
||||
|
||||
LOG.debug(f"Loading routes from '{directory}' with package name '{package_name}'.")
|
||||
|
||||
for _, name, _ in pkgutil.iter_modules([directory]):
|
||||
full_name: str = f"{package_name}.{name}"
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
LOG.debug(f"Loading module '{full_name}'.")
|
||||
importlib.import_module(full_name)
|
||||
except ImportError as e:
|
||||
LOG.error(f"Failed to import module '{full_name}': {e}")
|
||||
|
|
@ -1449,3 +1353,145 @@ def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]:
|
|||
folders.extend(list_folders(entry, base, depth_limit))
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
def archive_add(file: str | Path, ids: list[str], skip_check: bool = False) -> bool:
|
||||
"""
|
||||
Add IDs to an archive file.
|
||||
|
||||
Args:
|
||||
file (str|Path): The archive file path.
|
||||
ids (list[str]): List of IDs to add.
|
||||
skip_check (bool): If True, skip checking for existing IDs.
|
||||
|
||||
"""
|
||||
if not ids or not file:
|
||||
return False
|
||||
|
||||
path: Path = Path(file) if not isinstance(file, Path) else file
|
||||
existing_ids: set[str] = set()
|
||||
|
||||
if not skip_check and path.exists():
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
s = line.strip()
|
||||
|
||||
if not s or len(s.split()) < 2:
|
||||
continue
|
||||
|
||||
existing_ids.add(s)
|
||||
|
||||
new_ids: list[str] = []
|
||||
for raw in ids:
|
||||
s: str = str(raw).strip()
|
||||
|
||||
if not s or len(s.split()) < 2 or s in existing_ids or s in new_ids:
|
||||
continue
|
||||
|
||||
new_ids.append(s)
|
||||
|
||||
if not new_ids:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not path.parent.exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write("".join(f"{x}\n" for x in new_ids))
|
||||
|
||||
return True
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed to write to archive file '{path!s}'. {e!s}")
|
||||
return False
|
||||
|
||||
|
||||
def archive_read(file: str | Path, ids: list[str] | None = None) -> list[str]:
|
||||
"""
|
||||
Read IDs from an archive file with optional filtering.
|
||||
|
||||
- If `ids` is empty, return all IDs present in the archive file.
|
||||
- If `ids` is provided, return only the ids that exist in the archive file,
|
||||
|
||||
Args:
|
||||
file (str|Path): The archive file path.
|
||||
ids (list[str]): Optional list of IDs to query; empty list returns all.
|
||||
|
||||
Returns:
|
||||
list[str]: List of ids found in the archive file filtered by `ids` if provided.
|
||||
|
||||
"""
|
||||
if not file:
|
||||
return []
|
||||
|
||||
path: Path = Path(file) if not isinstance(file, Path) else file
|
||||
if not file or not path.exists():
|
||||
return []
|
||||
|
||||
ids_set: set[str] | None = (
|
||||
{s.strip() for s in ids if str(s).strip() and len(str(s).strip().split()) >= 2} if ids else None
|
||||
)
|
||||
|
||||
found: list[str] = []
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
s: str = line.strip()
|
||||
|
||||
if not s or len(s.split()) < 2:
|
||||
continue
|
||||
|
||||
if ids_set is None or s in ids_set:
|
||||
found.append(s)
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def archive_delete(file: str | Path, ids: list[str]) -> bool:
|
||||
"""
|
||||
Delete the given IDs from an archive file.
|
||||
|
||||
Args:
|
||||
file (str|Path): The archive file path.
|
||||
ids (list[str]): List of IDs to remove.
|
||||
|
||||
Returns:
|
||||
bool: True if deletion succeeded (or nothing to do), False on error.
|
||||
|
||||
"""
|
||||
if not file or not ids:
|
||||
return False
|
||||
|
||||
path: Path = Path(file) if not isinstance(file, Path) else file
|
||||
|
||||
if not path.exists():
|
||||
return False
|
||||
|
||||
remove_ids: set[str] = {x.strip() for x in ids if str(x).strip() and len(str(x).strip().split()) >= 2}
|
||||
if not remove_ids:
|
||||
return True
|
||||
|
||||
changed = False
|
||||
kept_lines: list[str] = []
|
||||
removed_ids: list[str] = []
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
s: str = line.strip()
|
||||
|
||||
if not s or len(s.split()) < 2:
|
||||
changed = True
|
||||
continue
|
||||
|
||||
if s in remove_ids:
|
||||
changed = True
|
||||
removed_ids.append(s)
|
||||
continue
|
||||
|
||||
kept_lines.append(line)
|
||||
|
||||
if not changed:
|
||||
return True
|
||||
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
f.writelines(kept_lines)
|
||||
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -200,7 +200,8 @@ class YTDLPOpts:
|
|||
if data["format"] == "-best":
|
||||
data["format"] = data["format"][1:]
|
||||
|
||||
LOG.debug(f"Final yt-dlp options: '{data!s}'.")
|
||||
if self._config.debug:
|
||||
LOG.debug(f"Final yt-dlp options: '{data!s}'.")
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -103,9 +103,6 @@ class Config:
|
|||
archive_file: str = "{config_path}/archive.log"
|
||||
"""The path to the download archive file."""
|
||||
|
||||
manual_archive: str = "{config_path}/archive.manual.log"
|
||||
"""The path to the manual archive file."""
|
||||
|
||||
apprise_config: str = "{config_path}/apprise.yml"
|
||||
"""The path to the Apprise configuration file."""
|
||||
|
||||
|
|
@ -387,34 +384,20 @@ class Config:
|
|||
except Exception as e:
|
||||
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
|
||||
|
||||
ytdl_options = {}
|
||||
opts_file: Path = Path(self.config_path) / "ytdlp.cli"
|
||||
if opts_file.exists() and opts_file.stat().st_size > 2:
|
||||
LOG.info(f"Loading yt-dlp custom options from '{opts_file}'.")
|
||||
LOG.warning(
|
||||
"Usage of 'ytdlp.cli' global config file is deprecated and will be removed in future releases. please migrate to presets."
|
||||
)
|
||||
with open(opts_file) as f:
|
||||
self.ytdlp_cli = f.read().strip()
|
||||
if self.ytdlp_cli:
|
||||
self._ytdlp_cli_mutable = self.ytdlp_cli
|
||||
try:
|
||||
removed_options = []
|
||||
ytdl_options = arg_converter(args=self.ytdlp_cli, level=True, removed_options=removed_options)
|
||||
|
||||
try:
|
||||
LOG.debug("Parsed yt-dlp cli options '%s'.", ytdl_options)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if len(removed_options) > 0:
|
||||
LOG.warning(
|
||||
"Removed the following options: '%s' from '%s'", ", ".join(removed_options), opts_file
|
||||
)
|
||||
arg_converter(args=self.ytdlp_cli, level=True)
|
||||
except Exception as e:
|
||||
msg = f"Failed to parse yt-dlp cli options from '{opts_file}'. '{e!s}'."
|
||||
raise ValueError(msg) from e
|
||||
else:
|
||||
LOG.warning(f"Empty yt-dlp custom options file '{opts_file}'.")
|
||||
else:
|
||||
LOG.info(f"No yt-dlp custom options found at '{opts_file}'.")
|
||||
|
||||
self._ytdlp_cli_mutable += f"\n--socket-timeout {self.socket_timeout}"
|
||||
|
||||
|
|
@ -424,7 +407,7 @@ class Config:
|
|||
LOG.info(f"Creating archive file '{archive_file}'.")
|
||||
archive_file.touch(exist_ok=True)
|
||||
|
||||
LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}'.")
|
||||
LOG.info(f"keep archive option is enabled. Using archive file '{archive_file}' by default.")
|
||||
self._ytdlp_cli_mutable += f"\n--download-archive {archive_file.as_posix()!s}"
|
||||
|
||||
if self.temp_keep:
|
||||
|
|
@ -519,6 +502,16 @@ class Config:
|
|||
return "production" == self.app_env
|
||||
|
||||
def get_ytdlp_args(self) -> dict:
|
||||
"""
|
||||
Get the yt-dlp command line options as a dictionary.
|
||||
|
||||
Returns:
|
||||
dict: The yt-dlp command line options.
|
||||
|
||||
Deprecated:
|
||||
Usage of global ytdlp.cli file is deprecated, please use presets instead.
|
||||
|
||||
"""
|
||||
try:
|
||||
return arg_converter(args=self._ytdlp_cli_mutable, level=True)
|
||||
except Exception as e:
|
||||
|
|
@ -537,6 +530,7 @@ class Config:
|
|||
|
||||
ytdlp_args = self.get_ytdlp_args()
|
||||
|
||||
# TODO: this doesn't make sense, as each item might have it's own archive file or none at all.
|
||||
if not data.get("keep_archive", False) and ytdlp_args.get("download_archive", None):
|
||||
data["keep_archive"] = True
|
||||
|
||||
|
|
@ -544,7 +538,7 @@ class Config:
|
|||
return data
|
||||
|
||||
@staticmethod
|
||||
def _ytdlp_version():
|
||||
def _ytdlp_version() -> str:
|
||||
try:
|
||||
from yt_dlp.version import __version__ as YTDLP_VERSION
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ from pathlib import Path
|
|||
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||||
from yt_dlp.utils import DateRange
|
||||
|
||||
from .ItemDTO import ItemDTO
|
||||
|
||||
|
||||
class Encoder(json.JSONEncoder):
|
||||
"""
|
||||
|
|
@ -16,6 +14,8 @@ class Encoder(json.JSONEncoder):
|
|||
"""
|
||||
|
||||
def default(self, o):
|
||||
from .ItemDTO import ItemDTO
|
||||
|
||||
if isinstance(o, Path):
|
||||
return str(o)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.Tasks import Task
|
||||
from app.library.Utils import is_downloaded
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
from app.library.Utils import archive_read
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.Download import Download
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
|
@ -25,24 +26,22 @@ EventBus.get_instance().subscribe(
|
|||
|
||||
|
||||
class YoutubeHandler:
|
||||
queued_ids: set[str] = set()
|
||||
queued: set[str] = set()
|
||||
failure_count: dict[str, int] = {}
|
||||
|
||||
FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
|
||||
FEED_PLAYLIST = "https://www.youtube.com/feeds/videos.xml?playlist_id={id}"
|
||||
|
||||
CHANNEL_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$")
|
||||
|
||||
PLAYLIST_REGEX = re.compile(r"^https?://(?:www\.)?youtube\.com/(?:playlist\?list=(?P<id>[A-Za-z0-9_-]+)|).*$")
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task, config: Config) -> bool:
|
||||
has, _ = YoutubeHandler.has_archive(task, config)
|
||||
if not has:
|
||||
LOG.debug(f"Task '{task.id}: {task.name}' does not have an archive file configured.")
|
||||
def can_handle(task: Task) -> bool:
|
||||
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
||||
LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
|
||||
return False
|
||||
|
||||
LOG.debug(f"Checking if task '{task.id}: {task.name}' can handle YouTube URL: {task.url}")
|
||||
LOG.debug(f"Checking if task '{task.name}' is using parsable YouTube URL: {task.url}")
|
||||
return YoutubeHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -58,24 +57,24 @@ class YoutubeHandler:
|
|||
queue (DownloadQueue): The download queue instance.
|
||||
|
||||
"""
|
||||
archive_file, params = YoutubeHandler.has_archive(task, config)
|
||||
if not archive_file:
|
||||
LOG.error(f"Task '{task.id}: {task.name}' does not have an archive file configured.")
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
if not (archive_file := params.get("download_archive")):
|
||||
LOG.error(f"Task '{task.name}' does not have an archive file.")
|
||||
return
|
||||
|
||||
import httpx
|
||||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
parsed = YoutubeHandler.parse(task.url)
|
||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||
if not parsed:
|
||||
LOG.error(f"Cannot parse '{task.id}: {task.name}' URL: {task.url}")
|
||||
LOG.error(f"Cannot parse '{task.name}' URL: {task.url}")
|
||||
return
|
||||
|
||||
feed_url = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
|
||||
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
|
||||
|
||||
LOG.debug(f"Fetching '{task.id}: {task.name}' feed.")
|
||||
opts = {
|
||||
"proxy": params.get("proxy", None),
|
||||
LOG.debug(f"Fetching '{task.name}' feed.")
|
||||
opts: dict[str, Any] = {
|
||||
"proxy": params.get("proxy"),
|
||||
"headers": {
|
||||
"User-Agent": params.get(
|
||||
"user_agent",
|
||||
|
|
@ -84,109 +83,111 @@ class YoutubeHandler:
|
|||
},
|
||||
}
|
||||
|
||||
try:
|
||||
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
|
||||
|
||||
opts["transport"] = AsyncCurlTransport(
|
||||
impersonate="chrome",
|
||||
default_headers=True,
|
||||
curl_options={CurlOpt.FRESH_CONNECT: True},
|
||||
)
|
||||
opts.pop("headers", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
items: list = []
|
||||
has_items = False
|
||||
|
||||
async with httpx.AsyncClient(**opts) as client:
|
||||
response = await client.request(method="GET", url=feed_url, timeout=120)
|
||||
response: httpx.Response = await client.request(method="GET", url=feed_url, timeout=120)
|
||||
response.raise_for_status()
|
||||
|
||||
root = fromstring(response.text)
|
||||
ns = {"atom": "http://www.w3.org/2005/Atom", "yt": "http://www.youtube.com/xml/schemas/2015"}
|
||||
root: Element[str] = fromstring(response.text)
|
||||
ns: dict[str, str] = {
|
||||
"atom": "http://www.w3.org/2005/Atom",
|
||||
"yt": "http://www.youtube.com/xml/schemas/2015",
|
||||
}
|
||||
|
||||
for entry in root.findall("atom:entry", ns):
|
||||
vid_elem = entry.find("yt:videoId", ns)
|
||||
title_elem = entry.find("atom:title", ns)
|
||||
pub_elem = entry.find("atom:published", ns)
|
||||
vid = vid_elem.text if vid_elem is not None else ""
|
||||
title = title_elem.text if title_elem is not None else ""
|
||||
published = pub_elem.text if pub_elem is not None else ""
|
||||
items.append(
|
||||
{
|
||||
"id": vid,
|
||||
"url": f"https://www.youtube.com/watch?v={vid}",
|
||||
"title": title,
|
||||
"published": published,
|
||||
}
|
||||
)
|
||||
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
|
||||
vid: str | None = vid_elem.text if vid_elem is not None else ""
|
||||
if not vid:
|
||||
LOG.warning(f"Entry in '{task.name}' feed is missing a video ID. Skipping entry.")
|
||||
continue
|
||||
|
||||
archive_id: str = f"youtube {vid}"
|
||||
url: str = f"https://www.youtube.com/watch?v={vid}"
|
||||
|
||||
title_elem: Element[str] | None = entry.find("atom:title", ns)
|
||||
title: str | None = title_elem.text if title_elem is not None else ""
|
||||
|
||||
pub_elem: Element[str] | None = entry.find("atom:published", ns)
|
||||
published: str | None = pub_elem.text if pub_elem is not None else ""
|
||||
has_items = True
|
||||
|
||||
if archive_id in YoutubeHandler.queued:
|
||||
continue
|
||||
|
||||
items.append({"id": vid, "url": url, "title": title, "published": published, "archive_id": archive_id})
|
||||
|
||||
if len(items) < 1:
|
||||
LOG.warning(f"No entries found in '{task.id}: {task.name}' feed. URL: {feed_url}")
|
||||
if not has_items:
|
||||
LOG.warning(f"No entries found in '{task.name}' feed. URL: {feed_url}")
|
||||
else:
|
||||
LOG.debug(f"No new items found in '{task.name}' feed.")
|
||||
return
|
||||
|
||||
filtered: list = []
|
||||
|
||||
downloaded: list[str] = archive_read(archive_file, [item["archive_id"] for item in items])
|
||||
|
||||
for item in items:
|
||||
status, _ = is_downloaded(archive_file, url=item["url"])
|
||||
if status is True or item["id"] in YoutubeHandler.queued_ids:
|
||||
YoutubeHandler.queued.add(item["archive_id"])
|
||||
if item["archive_id"] in downloaded:
|
||||
continue
|
||||
|
||||
if queue.queue.exists(url=item["url"]):
|
||||
LOG.debug(f"Item '{item['id']}' exists in the queue.")
|
||||
YoutubeHandler.queued_ids.add(item["id"])
|
||||
continue
|
||||
|
||||
try:
|
||||
done: Download = queue.done.get(url=item["url"])
|
||||
if "error" != done.info.status:
|
||||
LOG.debug(f"Item '{item['id']}' exists in the download history.")
|
||||
YoutubeHandler.queued_ids.add(item["id"])
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
YoutubeHandler.queued_ids.add(item["id"])
|
||||
if item["archive_id"] not in YoutubeHandler.failure_count:
|
||||
YoutubeHandler.failure_count[item["archive_id"]] = 0
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
if len(filtered) < 1:
|
||||
LOG.debug(f"No new items found in '{task.id}: {task.name}' feed.")
|
||||
LOG.debug(f"No new items found in '{task.name}' feed.")
|
||||
return
|
||||
|
||||
LOG.info(f"Found '{len(filtered)}' new items from '{task.id}: {task.name}' feed.")
|
||||
LOG.info(f"Found '{len(filtered)}' new items from '{task.name}' feed.")
|
||||
|
||||
preset: str = str(task.preset or config.default_preset)
|
||||
folder: str = task.folder if task.folder else ""
|
||||
template: str = task.template if task.template else ""
|
||||
cli: str = task.cli if task.cli else ""
|
||||
rItem: Item = Item.format(
|
||||
{
|
||||
"url": feed_url,
|
||||
"preset": str(task.preset or config.default_preset),
|
||||
"folder": task.folder if task.folder else "",
|
||||
"template": task.template if task.template else "",
|
||||
"cli": task.cli if task.cli else "",
|
||||
"auto_start": task.auto_start,
|
||||
"extras": {"source_task": task.id},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
notify.emit(
|
||||
Events.ADD_URL,
|
||||
data=Item.format(
|
||||
{"url": item["url"], "preset": preset, "folder": folder, "template": template, "cli": cli}
|
||||
).serialize(),
|
||||
)
|
||||
for item in filtered
|
||||
]
|
||||
*[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered]
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error while adding items from '{task.id}: {task.name}'. {e!s}")
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def has_archive(task: Task, config: Config) -> tuple[Path | None, dict]:
|
||||
archive_file: Path | None = Path(config.archive_file) if config.keep_archive else None
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if task.preset:
|
||||
params.preset(name=task.preset)
|
||||
|
||||
if task.cli:
|
||||
params.add_cli(task.cli, from_user=True)
|
||||
|
||||
params = params.get_all()
|
||||
if user_archive_file := params.get("download_archive", None):
|
||||
archive_file = Path(user_archive_file)
|
||||
|
||||
if not archive_file:
|
||||
return (None, params)
|
||||
|
||||
if not archive_file.exists():
|
||||
archive_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
archive_file.touch(exist_ok=True)
|
||||
|
||||
return (archive_file, params)
|
||||
|
||||
@staticmethod
|
||||
def parse(url: str) -> dict[str, str] | None:
|
||||
"""
|
||||
|
|
@ -222,29 +223,33 @@ class YoutubeHandler:
|
|||
if not item or not isinstance(item, ItemDTO):
|
||||
return
|
||||
|
||||
cls.queued_ids.add(item.id)
|
||||
|
||||
if item.id not in cls.queued_ids:
|
||||
if not item.archive_id or not cls.failure_count.get(item.archive_id, None):
|
||||
LOG.debug(f"Item '{item.name()}' not queued by the handler.")
|
||||
return
|
||||
|
||||
currentFailureCount: int = cls.failure_count.get(item.id, 0)
|
||||
failCount: int = int(cls.failure_count.get(item.archive_id, 0))
|
||||
|
||||
LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{currentFailureCount + 1}'.")
|
||||
cls.queued_ids.remove(item.id)
|
||||
LOG.info(f"Removing '{item.name()}' from queued IDs due to error. Failure count: '{failCount + 1}'.")
|
||||
if item.archive_id in cls.queued:
|
||||
cls.queued.remove(item.archive_id)
|
||||
|
||||
cls.failure_count[item.id] = cls.failure_count.get(item.id, 0) + 1
|
||||
cls.failure_count[item.archive_id] = 1 + failCount
|
||||
|
||||
@staticmethod
|
||||
def tests() -> list[str]:
|
||||
def tests() -> list[tuple[str, bool]]:
|
||||
"""
|
||||
Return a list of test URLs to validate the parsing logic.
|
||||
Test cases for the URL parser.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, bool]]: A list of tuples containing the URL and expected result.
|
||||
|
||||
"""
|
||||
return [
|
||||
"https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN",
|
||||
"https://youtube.com/c/MyCustomName",
|
||||
"https://youtube.com/user/SomeUser123",
|
||||
"https://youtube.com/@SomeHandle",
|
||||
"https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ",
|
||||
"https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ",
|
||||
"https://youtube.com/watch?v=foo",
|
||||
("https://www.youtube.com/channel/UCabc123ABCDEFGHIJKLMN", True),
|
||||
("https://youtube.com/c/MyCustomName", False),
|
||||
("https://youtube.com/user/SomeUser123", False),
|
||||
("https://youtube.com/@SomeHandle", False),
|
||||
("https://youtube.com/playlist?list=PLxyz789ABCDEFGHIJ", True),
|
||||
("https://youtube.com/watch?v=foo&list=PLxyz789ABCDEFGHIJ", True),
|
||||
("https://youtube.com/watch?v=foo", False),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -7,6 +7,7 @@ from aiohttp import web
|
|||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.Download import Download
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
|
|
@ -98,7 +99,7 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder, co
|
|||
if not item.info:
|
||||
return web.json_response(data={"error": "item has no info."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
info = {
|
||||
info: dict = {
|
||||
**item.info.serialize(),
|
||||
"ffprobe": {},
|
||||
}
|
||||
|
|
@ -256,3 +257,119 @@ async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) ->
|
|||
response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")})
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", r"api/history/{id}/archive", "history.item.archive.add")
|
||||
async def item_archive_add(request: Request, queue: DownloadQueue, notify: EventBus) -> Response:
|
||||
"""
|
||||
Manually mark an item as archived.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not (id := request.match_info.get("id")):
|
||||
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
item: Download | None = queue.done.get_by_id(id)
|
||||
if not item:
|
||||
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
|
||||
except KeyError:
|
||||
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if not item.info.is_archivable:
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' does not have an archive file."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.info.archive_id:
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' does not have an archive ID."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if item.info.is_archived:
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' already archived."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
if not item.info.archive_add():
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' could not be added to archive."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
item.info.archive_status(force=True)
|
||||
queue.done.put(item, no_notify=True)
|
||||
await notify.emit(Events.ITEM_UPDATED, data=item.info)
|
||||
|
||||
return web.json_response(
|
||||
data={"message": f"item '{item.info.title}' archived."},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("DELETE", r"api/history/{id}/archive", "history.item.archive.delete")
|
||||
async def item_archive_delete(request: Request, queue: DownloadQueue, notify: EventBus) -> Response:
|
||||
"""
|
||||
Remove an item from the archive.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not (id := request.match_info.get("id")):
|
||||
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
item: Download | None = queue.done.get_by_id(id)
|
||||
if not item:
|
||||
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
|
||||
except KeyError:
|
||||
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if not item.info.is_archivable:
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' does not have an archive file."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.info.archive_id:
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' does not have an archive ID."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.info.is_archived:
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' not archived."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
if not item.info.archive_delete():
|
||||
return web.json_response(
|
||||
data={"error": f"item '{item.info.title}' not found in archive file."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
item.info.archive_status(force=True)
|
||||
queue.done.put(item, no_notify=True)
|
||||
await notify.emit(Events.ITEM_UPDATED, data=item.info)
|
||||
|
||||
return web.json_response(
|
||||
data={"message": f"item '{item.info.title}' removed from archive."},
|
||||
status=web.HTTPOk.status_code,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,19 +43,19 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
|
|||
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
|
||||
|
||||
try:
|
||||
|
||||
ytdlp_args = config.get_ytdlp_args()
|
||||
opts = {
|
||||
"proxy": ytdlp_args.get("proxy", None),
|
||||
"headers": {
|
||||
"User-Agent": ytdlp_args.get(
|
||||
"user_agent", request.headers.get("User-Agent", random_user_agent())
|
||||
),
|
||||
"User-Agent": ytdlp_args.get("user_agent", request.headers.get("User-Agent", random_user_agent())),
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(**opts) as client:
|
||||
LOG.debug(f"Fetching thumbnail from '{url}'.")
|
||||
response = await client.request(method="GET", url=url)
|
||||
response = await client.request(method="GET", url=url, follow_redirects=True)
|
||||
|
||||
return web.Response(
|
||||
body=response.content,
|
||||
headers={
|
||||
|
|
@ -122,6 +122,17 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
|
|||
"User-Agent": ytdlp_args.get("user_agent", random_user_agent()),
|
||||
},
|
||||
}
|
||||
try:
|
||||
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
|
||||
|
||||
opts["transport"] = AsyncCurlTransport(
|
||||
impersonate="chrome",
|
||||
default_headers=True,
|
||||
curl_options={CurlOpt.FRESH_CONNECT: True},
|
||||
)
|
||||
opts.pop("headers", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient(**opts) as client:
|
||||
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from aiohttp.web_runner import GracefulExit
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
|
|
@ -13,10 +15,80 @@ from app.library.router import route
|
|||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("POST", "api/system/pause", "system.pause")
|
||||
async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Pause non-active downloads.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if queue.is_paused():
|
||||
return web.json_response(
|
||||
{"message": "Non-active downloads are already paused."},
|
||||
status=web.HTTPNotAcceptable.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
queue.pause()
|
||||
|
||||
msg = "Non-active downloads have been paused."
|
||||
await notify.emit(
|
||||
Events.PAUSED,
|
||||
data={"paused": True, "at": time.time()},
|
||||
title="Downloads Paused",
|
||||
message=msg,
|
||||
)
|
||||
|
||||
return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/system/resume", "system.resume")
|
||||
async def downloads_resume(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Resume non-active downloads.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
queue (DownloadQueue): The download queue instance.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
"""
|
||||
if not queue.is_paused():
|
||||
return web.json_response(
|
||||
{"message": "Non-active downloads are not paused."},
|
||||
status=web.HTTPNotAcceptable.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
queue.resume()
|
||||
|
||||
msg = "Resumed all downloads."
|
||||
await notify.emit(
|
||||
Events.RESUMED,
|
||||
data={"paused": False, "at": time.time()},
|
||||
title="Downloads Resumed",
|
||||
message=msg,
|
||||
)
|
||||
|
||||
return web.json_response(data={"message": msg}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/system/shutdown", "system.shutdown")
|
||||
async def shutdown_system(request: Request, config: Config, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Get the presets.
|
||||
Initiate application shutdown.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ async def tasks_add(request: Request, encoder: Encoder) -> Response:
|
|||
|
||||
|
||||
@route("POST", "api/tasks/{id}/mark", "tasks_mark")
|
||||
async def mark_task(request: Request, encoder: Encoder) -> Response:
|
||||
async def task_mark(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Mark all items from task as downloaded.
|
||||
|
||||
|
|
@ -111,9 +111,9 @@ async def mark_task(request: Request, encoder: Encoder) -> Response:
|
|||
if not task_id:
|
||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
tasks = Tasks.get_instance()
|
||||
tasks: Tasks = Tasks.get_instance()
|
||||
try:
|
||||
task = tasks.get(task_id)
|
||||
task: Task | None = tasks.get(task_id)
|
||||
if not task:
|
||||
return web.json_response(
|
||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
|
|
@ -126,3 +126,38 @@ async def mark_task(request: Request, encoder: Encoder) -> Response:
|
|||
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
|
||||
@route("DELETE", "api/tasks/{id}/mark", "tasks_unmark")
|
||||
async def task_unmark(request: Request, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Remove All tasks items from download archive.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
task_id: str = request.match_info.get("id", None)
|
||||
|
||||
if not task_id:
|
||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
tasks: Tasks = Tasks.get_instance()
|
||||
try:
|
||||
task: Task | None = tasks.get(task_id)
|
||||
if not task:
|
||||
return web.json_response(
|
||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
_status, _message = task.unmark()
|
||||
if not _status:
|
||||
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
|
||||
|
|
@ -14,7 +11,14 @@ from app.library.cache import Cache
|
|||
from app.library.config import Config
|
||||
from app.library.Presets import Presets
|
||||
from app.library.router import route
|
||||
from app.library.Utils import REMOVE_KEYS, arg_converter, extract_info, validate_url
|
||||
from app.library.Utils import (
|
||||
REMOVE_KEYS,
|
||||
archive_read,
|
||||
arg_converter,
|
||||
extract_info,
|
||||
get_archive_id,
|
||||
validate_url,
|
||||
)
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
|
@ -199,6 +203,14 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
|||
"expires": time.time() + 300,
|
||||
}
|
||||
|
||||
is_archived = False
|
||||
if (archive_file := ytdlp_opts.get("download_archive")) and (
|
||||
archive_id := get_archive_id(url=url).get("archive_id")
|
||||
):
|
||||
is_archived: bool = len(archive_read(archive_file, [archive_id])) > 0
|
||||
|
||||
data["is_archived"] = is_archived
|
||||
|
||||
data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))
|
||||
|
||||
cache.set(key=key, value=data, ttl=300)
|
||||
|
|
@ -217,87 +229,6 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
|||
)
|
||||
|
||||
|
||||
@route("GET", "api/yt-dlp/archive/recheck/", "archive_recheck")
|
||||
async def archive_recheck(cache: Cache) -> Response:
|
||||
"""
|
||||
Recheck the manual archive entries.
|
||||
|
||||
Args:
|
||||
cache (Cache): The cache instance.
|
||||
|
||||
Returns:
|
||||
Response: The response object
|
||||
|
||||
"""
|
||||
config: Config = Config.get_instance()
|
||||
if not config.manual_archive:
|
||||
return web.json_response(data={"error": "Manual archive is not enabled."}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
manual_archive = Path(config.manual_archive)
|
||||
if not manual_archive.exists():
|
||||
return web.json_response(
|
||||
data={"error": "Manual archive file not found.", "file": manual_archive},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
tasks: list = []
|
||||
response: list = []
|
||||
|
||||
def info_wrapper(id: str, url: str) -> tuple[str, dict]:
|
||||
try:
|
||||
return (
|
||||
id,
|
||||
extract_info(
|
||||
config={
|
||||
"proxy": config.get_ytdlp_args().get("proxy", None),
|
||||
"simulate": True,
|
||||
"dump_single_json": True,
|
||||
},
|
||||
url=url,
|
||||
no_archive=True,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
return (id, {"error": str(e)})
|
||||
|
||||
async with await anyio.open_file(manual_archive) as f:
|
||||
# line format is "youtube ID - at: ISO8601"
|
||||
async for line in f:
|
||||
line = line.strip()
|
||||
|
||||
if not line or not line.startswith("youtube"):
|
||||
continue
|
||||
|
||||
id = line.split(" ")[1].strip()
|
||||
|
||||
if not id:
|
||||
continue
|
||||
|
||||
url = f"https://www.youtube.com/watch?v={id}"
|
||||
key = cache.hash(id)
|
||||
|
||||
if cache.has(key):
|
||||
data = cache.get(key)
|
||||
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
|
||||
continue
|
||||
|
||||
tasks.append(
|
||||
asyncio.get_event_loop().run_in_executor(None, lambda i=id, url=url: info_wrapper(id=i, url=url))
|
||||
)
|
||||
|
||||
if len(tasks) > 0:
|
||||
results = await asyncio.gather(*tasks)
|
||||
for data in results:
|
||||
if not data:
|
||||
continue
|
||||
|
||||
id, info = data
|
||||
cache.set(key=cache.hash(id), value=info, ttl=3600 * 6)
|
||||
response.append({id: bool(data.get("id", None)) if isinstance(data, dict) else False})
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("GET", "api/yt-dlp/options/", "get_options")
|
||||
async def get_options() -> Response:
|
||||
"""
|
||||
|
|
@ -309,6 +240,37 @@ async def get_options() -> Response:
|
|||
"""
|
||||
from app.library.ytdlp import ytdlp_options
|
||||
|
||||
return web.json_response(
|
||||
body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code
|
||||
)
|
||||
return web.json_response(body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids")
|
||||
async def get_archive_ids(request: Request) -> Response:
|
||||
"""
|
||||
Get the yt-dlp CLI options.
|
||||
|
||||
Returns:
|
||||
Response: The response object with the yt-dlp CLI options.
|
||||
|
||||
"""
|
||||
from app.library.Utils import get_archive_id
|
||||
|
||||
data = (await request.json()) if request.body_exists else None
|
||||
if not data or not isinstance(data, list):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request. expecting list with URLs."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
response = []
|
||||
|
||||
for i, url in enumerate(data):
|
||||
dct = {"index": i, "url": url}
|
||||
try:
|
||||
validate_url(url)
|
||||
dct.update(get_archive_id(url))
|
||||
except ValueError as e:
|
||||
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)})
|
||||
|
||||
response.append(dct)
|
||||
|
||||
return web.json_response(data=response, status=web.HTTPOk.status_code)
|
||||
|
|
|
|||
|
|
@ -1,43 +1,13 @@
|
|||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.router import RouteType, route
|
||||
from app.library.Utils import is_downloaded
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "pause", "pause_downloads")
|
||||
async def pause(notify: EventBus, queue: DownloadQueue):
|
||||
queue.pause()
|
||||
await notify.emit(
|
||||
Events.PAUSED,
|
||||
data={"paused": True, "at": time.time()},
|
||||
title="Downloads Paused",
|
||||
message="Non-active downloads have been paused.",
|
||||
)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "resume", "resume_downloads")
|
||||
async def resume(notify: EventBus, queue: DownloadQueue):
|
||||
queue.resume()
|
||||
await notify.emit(
|
||||
Events.RESUMED,
|
||||
data={"paused": False, "at": time.time()},
|
||||
title="Downloads Resumed",
|
||||
message="Resumed all downloads.",
|
||||
)
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "add_url", "add_url")
|
||||
async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
|
||||
data = data if isinstance(data, dict) else {}
|
||||
|
|
@ -79,57 +49,6 @@ async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: di
|
|||
await queue.clear([id], remove_file=bool(data.get("remove_file", False)))
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "archive_item", "archive_item")
|
||||
async def archive_item(config: Config, data: dict):
|
||||
if not isinstance(data, dict) or "url" not in data:
|
||||
return
|
||||
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if "preset" in data and isinstance(data["preset"], str):
|
||||
params.preset(name=data["preset"])
|
||||
|
||||
if "cli" in data and isinstance(data["cli"], str) and len(data["cli"]) > 1:
|
||||
params.add_cli(data["cli"], from_user=True)
|
||||
|
||||
params = params.get_all()
|
||||
|
||||
file: str = params.get("download_archive", None)
|
||||
|
||||
if not file:
|
||||
return
|
||||
|
||||
exists, idDict = is_downloaded(file, data["url"])
|
||||
if exists or "archive_id" not in idDict or idDict["archive_id"] is None:
|
||||
return
|
||||
|
||||
async with await anyio.open_file(file, "a") as f:
|
||||
await f.write(f"{idDict['archive_id']}\n")
|
||||
|
||||
manual_archive: str = config.manual_archive
|
||||
if not manual_archive:
|
||||
return
|
||||
|
||||
manual_archive = Path(manual_archive)
|
||||
|
||||
if not manual_archive.exists():
|
||||
manual_archive.touch(exist_ok=True)
|
||||
|
||||
previouslyArchived = False
|
||||
async with await anyio.open_file(manual_archive) as f:
|
||||
async for line in f:
|
||||
if idDict["archive_id"] in line:
|
||||
previouslyArchived = True
|
||||
break
|
||||
|
||||
if not previouslyArchived:
|
||||
async with await anyio.open_file(manual_archive, "a") as f:
|
||||
await f.write(f"{idDict['archive_id']} - at: {datetime.now(UTC).isoformat()}\n")
|
||||
LOG.info(f"Archiving url '{data['url']}' with id '{idDict['archive_id']}'.")
|
||||
else:
|
||||
LOG.info(f"URL '{data['url']}' with id '{idDict['archive_id']}' already archived.")
|
||||
|
||||
|
||||
@route(RouteType.SOCKET, "item_start", "item_start")
|
||||
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
|
||||
if not data:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ dependencies = [
|
|||
"apprise>=1.9.3",
|
||||
"bgutil-ytdlp-pot-provider>=1.2.1",
|
||||
"pycryptodome>=3.23.0",
|
||||
"httpx-curl-cffi>=0.1.4",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
|
|
|||
67
ui/app/components/DeprecatedNotice.vue
Normal file
67
ui/app/components/DeprecatedNotice.vue
Normal 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>
|
||||
|
|
@ -47,6 +47,7 @@ const emitter = defineEmits<{ (e: 'closeModel'): void }>()
|
|||
const props = defineProps<{
|
||||
link?: string
|
||||
preset?: string
|
||||
cli?: string
|
||||
useUrl?: boolean
|
||||
externalModel?: boolean
|
||||
}>()
|
||||
|
|
@ -71,13 +72,16 @@ onMounted(async (): Promise<void> => {
|
|||
if (props.preset) {
|
||||
params.append('preset', props.preset)
|
||||
}
|
||||
if (props.cli) {
|
||||
params.append('args', props.cli)
|
||||
}
|
||||
params.append('url', props.link || '')
|
||||
url += '?' + params.toString()
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request(url, { credentials: 'include' })
|
||||
const response = await request(url)
|
||||
const body = await response.text()
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@
|
|||
</span>
|
||||
</div>
|
||||
<div v-if="showThumbnails && item.extras.thumbnail">
|
||||
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
<FloatingImage
|
||||
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
:title="`[${item.preset}] - ${item.title}`">
|
||||
<div class="is-text-overflow">
|
||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||
|
|
@ -138,11 +139,8 @@
|
|||
</div>
|
||||
</td>
|
||||
<td class="is-vcentered has-text-centered is-unselectable">
|
||||
<span class="icon-text">
|
||||
<span class="icon" :class="setIconColor(item)"><i
|
||||
:class="[setIcon(item), is_queued(item)]" /></span>
|
||||
<span>{{ setStatus(item) }}</span>
|
||||
</span>
|
||||
<span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span>
|
||||
<span>{{ setStatus(item) }}</span>
|
||||
</td>
|
||||
<td class="is-vcentered has-text-centered is-unselectable">
|
||||
<span class="user-hint" :date-datetime="item.datetime"
|
||||
|
|
@ -195,7 +193,7 @@
|
|||
</NuxtLink>
|
||||
<hr class="dropdown-divider" />
|
||||
</template>
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)">
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp Information</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -205,25 +203,23 @@
|
|||
<span>Local Information</span>
|
||||
</NuxtLink>
|
||||
|
||||
<template v-if="item.status != 'finished' || !item.filename">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
|
||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
<span>Add to download form</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
|
||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
<span>Add to download form</span>
|
||||
</NuxtLink>
|
||||
|
||||
<template v-if="'finished' !== item.status && config.app?.keep_archive">
|
||||
<template v-if="item.is_archivable && !item.is_archived">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Archive Item</span>
|
||||
<span>Add to archive</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-if="'finished' === item.status && item.filename && config.app?.keep_archive">
|
||||
<template v-if="item.is_archivable && item.is_archived">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="removeFromArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Remove from archive</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -276,20 +272,22 @@
|
|||
<figure class="image is-3by1">
|
||||
<span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
|
||||
<div class="play-icon"></div>
|
||||
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
<img @load="e => pImg(e)"
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<span v-else-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||
class="play-overlay">
|
||||
<div class="play-icon embed-icon"></div>
|
||||
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
<img @load="e => pImg(e)"
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<template v-else>
|
||||
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail"
|
||||
:src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" />
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</template>
|
||||
</figure>
|
||||
|
|
@ -297,10 +295,8 @@
|
|||
<div class="card-content">
|
||||
<div class="columns is-mobile is-multiline">
|
||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||
<span class="icon-text">
|
||||
<span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span>
|
||||
<span>{{ setStatus(item) }}</span>
|
||||
</span>
|
||||
<span class="icon" :class="setIconColor(item)"><i :class="[setIcon(item), is_queued(item)]" /></span>
|
||||
<span>{{ setStatus(item) }}</span>
|
||||
</div>
|
||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
|
|
@ -370,7 +366,7 @@
|
|||
<hr class="dropdown-divider" />
|
||||
</template>
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
|
||||
v-if="!config.app.basic_mode">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp Information</span>
|
||||
|
|
@ -382,15 +378,13 @@
|
|||
<span>Local Information</span>
|
||||
</NuxtLink>
|
||||
|
||||
<template v-if="item.status != 'finished' || !item.filename">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
|
||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
<span>Add to download form</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="retryItem(item, true)">
|
||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||
<span>Add to download form</span>
|
||||
</NuxtLink>
|
||||
|
||||
<template v-if="'finished' !== item.status && config.app?.keep_archive && !config.app.basic_mode">
|
||||
<template v-if="item.is_archivable && !item.is_archived">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="addArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
|
|
@ -398,10 +392,9 @@
|
|||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="'finished' === item.status && item.filename && config.app?.keep_archive && !config.app.basic_mode">
|
||||
<template v-if="item.is_archivable && item.is_archived">
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="removeFromArchiveDialog(item)">
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="removeFromArchiveDialog(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Remove from archive</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -468,7 +461,7 @@ import { useStorage } from '@vueuse/core'
|
|||
import type { StoreItem } from '~/types/store'
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'getInfo', url: string, preset: string): void
|
||||
(e: 'getInfo', url: string, preset: string, cli: string): void
|
||||
(e: 'add_new', item: Partial<StoreItem>): void
|
||||
(e: 'getItemInfo', id: string): void
|
||||
(e: 'clear_search'): void
|
||||
|
|
@ -613,9 +606,6 @@ const deleteSelectedItems = () => {
|
|||
continue
|
||||
}
|
||||
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
|
||||
if ('finished' === item.status) {
|
||||
socket.emit('archive_item', item)
|
||||
}
|
||||
socket.emit('item_delete', {
|
||||
id: item._id,
|
||||
remove_file: config.app.remove_files,
|
||||
|
|
@ -739,10 +729,7 @@ const addArchiveDialog = (item: StoreItem) => {
|
|||
|
||||
const archiveItem = async (item: StoreItem, opts = {}) => {
|
||||
try {
|
||||
const req = await request(`/api/archive/${item._id}`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
})
|
||||
const req = await request(`/api/history/${item._id}/archive`, { method: 'POST' })
|
||||
const data = await req.json()
|
||||
dialog_confirm.value.visible = false
|
||||
if (!req.ok) {
|
||||
|
|
@ -834,8 +821,7 @@ const downloadSelected = async () => {
|
|||
try {
|
||||
const response = await request('/api/file/download', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(files_list),
|
||||
body: JSON.stringify(files_list)
|
||||
})
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
|
|
@ -871,12 +857,8 @@ const removeFromArchiveDialog = (item: StoreItem) => {
|
|||
}
|
||||
|
||||
const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean }) => {
|
||||
console.log('Removing from archive:', item, opts)
|
||||
try {
|
||||
const req = await request(`/api/archive/${item._id}`, {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
})
|
||||
const req = await request(`/api/history/${item._id}/archive`, { method: 'DELETE' })
|
||||
const data = await req.json()
|
||||
if (!req.ok) {
|
||||
toast.error(data.error)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ onMounted(async () => {
|
|||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const imgRequest = await request(url, { credentials: 'include' })
|
||||
const imgRequest = await request(url)
|
||||
if (200 !== imgRequest.status) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,9 @@
|
|||
</label>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder" placeholder="Default"
|
||||
:disabled="!socket.isConnected || addInProgress" list="folders">
|
||||
<input type="text" class="input is-fullwidth" id="path" v-model="form.folder"
|
||||
:placeholder="get_download_folder()" :disabled="!socket.isConnected || addInProgress"
|
||||
list="folders">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -166,16 +167,11 @@
|
|||
</NuxtLink>
|
||||
<hr class="dropdown-divider" />
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset)">
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset, form.cli)">
|
||||
<span class="icon has-text-info"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp Information</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="removeFromArchive(form.url)">
|
||||
<span class="icon has-text-warning"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Remove from archive</span>
|
||||
</NuxtLink>
|
||||
|
||||
<hr class="dropdown-divider" />
|
||||
<NuxtLink class="dropdown-item" @click="resetConfig">
|
||||
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
|
||||
|
|
@ -193,7 +189,8 @@
|
|||
</button>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)"
|
||||
<button type="button" class="button is-info"
|
||||
@click="emitter('getInfo', form.url, form.preset, form.cli)"
|
||||
:class="{ 'is-loading': !socket.isConnected }"
|
||||
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
|
|
@ -201,15 +198,6 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button type="button" class="button is-warning" @click="removeFromArchive(form.url)"
|
||||
:class="{ 'is-loading': !socket.isConnected }"
|
||||
:disabled="!socket.isConnected || addInProgress || !form?.url">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Remove from archive</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button type="button" class="button is-danger" @click="resetConfig"
|
||||
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
|
||||
|
|
@ -247,7 +235,7 @@ import type { AutoCompleteOptions } from '~/types/autocomplete';
|
|||
|
||||
const props = defineProps<{ item?: Partial<item_request> }>()
|
||||
const emitter = defineEmits<{
|
||||
(e: 'getInfo', url: string, preset: string | undefined): void
|
||||
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
|
||||
(e: 'clear_form'): void
|
||||
(e: 'remove_archive', url: string): void
|
||||
}>()
|
||||
|
|
@ -363,7 +351,6 @@ const addDownload = async () => {
|
|||
try {
|
||||
addInProgress.value = true
|
||||
const response = await request('/api/history', {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request_data),
|
||||
})
|
||||
|
|
@ -496,27 +483,6 @@ const filter_presets = (flag: boolean = true) => config.presets.filter(item => i
|
|||
const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name)
|
||||
const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap'])
|
||||
|
||||
const removeFromArchive = async (url: string) => {
|
||||
try {
|
||||
const req = await request(`/api/archive/0`, {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
|
||||
const data = await req.json()
|
||||
|
||||
if (!req.ok) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(data.message ?? `Removed item from archive.`)
|
||||
} catch (e: any) {
|
||||
toast.error(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const get_output_template = () => {
|
||||
if (form.value.preset && !hasFormatInConfig.value) {
|
||||
const preset = config.presets.find(p => p.name === form.value.preset)
|
||||
|
|
@ -527,5 +493,15 @@ const get_output_template = () => {
|
|||
return config.app.output_template || '%(title)s.%(ext)s'
|
||||
}
|
||||
|
||||
const get_download_folder = (): string => {
|
||||
if (form.value.preset && false === hasFormatInConfig.value) {
|
||||
const preset = config.presets.find(p => p.name === form.value.preset)
|
||||
if (preset?.folder) {
|
||||
return preset.folder.replace(config.app.download_path, '')
|
||||
}
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@
|
|||
</span>
|
||||
</div>
|
||||
<div v-if="showThumbnails && item.extras?.thumbnail">
|
||||
<FloatingImage :image="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
<FloatingImage
|
||||
:image="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
:title="item.title">
|
||||
<div class="is-text-overflow">
|
||||
<NuxtLink target="_blank" :href="item.url">{{ item.title }}</NuxtLink>
|
||||
|
|
@ -98,12 +99,10 @@
|
|||
</template>
|
||||
</td>
|
||||
<td class="has-text-centered is-text-overflow is-unselectable">
|
||||
<span class="icon-text">
|
||||
<span class="icon" :class="setIconColor(item)">
|
||||
<i class="fas fa-solid" :class="setIcon(item)" />
|
||||
</span>
|
||||
<span v-text="setStatus(item)" />
|
||||
<span class="icon" :class="setIconColor(item)">
|
||||
<i class="fas fa-solid" :class="setIcon(item)" />
|
||||
</span>
|
||||
<span v-text="setStatus(item)" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress-bar is-unselectable">
|
||||
|
|
@ -151,7 +150,7 @@
|
|||
<template v-if="!config.app.basic_mode">
|
||||
<hr class="dropdown-divider" />
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)">
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp Information</span>
|
||||
</NuxtLink>
|
||||
|
|
@ -206,13 +205,14 @@
|
|||
<span v-if="isEmbedable(item.url)" @click="embed_url = getEmbedable(item.url) as string"
|
||||
class="play-overlay">
|
||||
<div class="play-icon embed-icon"></div>
|
||||
<img @load="e => pImg(e)" :src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))"
|
||||
<img @load="e => pImg(e)"
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))"
|
||||
v-if="item.extras?.thumbnail" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</span>
|
||||
<template v-else>
|
||||
<img @load="e => pImg(e)" v-if="item.extras?.thumbnail"
|
||||
:src="uri('/api/thumbnail?url=' + encodePath(item.extras.thumbnail))" />
|
||||
:src="uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail))" />
|
||||
<img v-else src="/images/placeholder.png" />
|
||||
</template>
|
||||
</figure>
|
||||
|
|
@ -226,12 +226,10 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||
<span class="icon-text">
|
||||
<span class="icon" :class="setIconColor(item)">
|
||||
<i class="fas fa-solid" :class="setIcon(item)" />
|
||||
</span>
|
||||
<span v-text="setStatus(item)" />
|
||||
<span class="icon" :class="setIconColor(item)">
|
||||
<i class="fas fa-solid" :class="setIcon(item)" />
|
||||
</span>
|
||||
<span v-text="setStatus(item)" />
|
||||
</div>
|
||||
<div class="column is-half-mobile has-text-centered is-text-overflow is-unselectable">
|
||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||
|
|
@ -277,7 +275,7 @@
|
|||
<hr class="dropdown-divider" v-if="!config.app.basic_mode" />
|
||||
</template>
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"
|
||||
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
|
||||
v-if="!config.app.basic_mode">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>yt-dlp Information</span>
|
||||
|
|
@ -321,7 +319,7 @@ import { useStorage } from '@vueuse/core'
|
|||
import type { StoreItem } from '~/types/store'
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(e: 'getInfo', url: string, preset: string): void
|
||||
(e: 'getInfo', url: string, preset: string, cli: string): void
|
||||
(e: 'getItemInfo', id: string): void
|
||||
(e: 'clear_search'): void
|
||||
}>()
|
||||
|
|
|
|||
|
|
@ -510,7 +510,7 @@ const convert_url = async (url: string): Promise<string> => {
|
|||
|
||||
try {
|
||||
convertInProgress.value = true
|
||||
const resp = await request('/api/yt-dlp/url/info?' + params.toString(), { credentials: 'include' })
|
||||
const resp = await request('/api/yt-dlp/url/info?' + params.toString())
|
||||
const body = await resp.json()
|
||||
const channel_id = ag(body, 'channel_id', null)
|
||||
console.log('convert_url', { url, channel_id, body })
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ const filters = reactive({
|
|||
const reload = async (): Promise<void> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const resp = await request('/api/yt-dlp/options', { credentials: 'include' })
|
||||
const resp = await request('/api/yt-dlp/options')
|
||||
if (!resp.ok) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,7 @@
|
|||
<span class="icon"><i class="fas fa-pause" /></span>
|
||||
<span v-if="!isMobile">Pause</span>
|
||||
</button>
|
||||
<button class="button is-danger" @click="socket.emit('resume', {})" v-else
|
||||
v-tooltip.bottom="'Resume downloading.'">
|
||||
<button class="button is-danger" @click="resumeDownload" v-else v-tooltip.bottom="'Resume downloading.'">
|
||||
<span class="icon"><i class="fas fa-play" /></span>
|
||||
<span v-if="!isMobile">Resume</span>
|
||||
</button>
|
||||
|
|
@ -63,16 +62,50 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="config.is_loaded" class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<DeprecatedNotice :version="config.app.app_version" title="Deprecation Notice" tone="warning"
|
||||
icon="fas fa-exclamation-triangle fa-fade fa-spin-10">
|
||||
<p>
|
||||
The following environment variables and features are deprecated and will be removed in future releases:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
The environment variables <code>YTP_KEEP_ARCHIVE</code> and <code>YTP_SOCKET_TIMEOUT</code> will no
|
||||
longer be user-configurable. Their behavior will be part of the <strong>default presets</strong>. To keep
|
||||
your current behavior <strong>and avoid re-downloading</strong>, please add the following <strong>Command
|
||||
options for yt-dlp</strong> to your presets:
|
||||
<code>--socket-timeout 30 --download-archive /config/archive.log</code>
|
||||
</li>
|
||||
<li>
|
||||
The global yt-dlp config file <code>/config/ytdlp.cli</code> is deprecated and will be removed. Please
|
||||
migrate any global options into your presets.
|
||||
</li>
|
||||
<li>
|
||||
The <strong>Basic mode</strong> (which limited the interface to the new download form) is being removed.
|
||||
Everything except what is available behind configurable flag will become part of the standard interface.
|
||||
</li>
|
||||
<li>The <strong>archive.manual.log</strong> feature has been removed.</li>
|
||||
</ul>
|
||||
<p>
|
||||
These changes help reduce confusion from multiple sources of truth. Going forward, <strong>presets</strong>
|
||||
and the <strong>Command options for yt-dlp</strong> will be the single source of truth.
|
||||
</p>
|
||||
</DeprecatedNotice>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewDownload v-if="config.showForm || config.app.basic_mode"
|
||||
@getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :item="item_form"
|
||||
@clear_form="item_form = {}" @remove_archive="" />
|
||||
<Queue @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :thumbnails="show_thumbnail"
|
||||
:query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
|
||||
<History @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)"
|
||||
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
||||
:item="item_form" @clear_form="item_form = {}" @remove_archive="" />
|
||||
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
||||
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
||||
@clear_search="query = ''" />
|
||||
<History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
||||
@add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
|
||||
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
|
||||
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :useUrl="info_view.useUrl"
|
||||
@closeModel="close_info()" />
|
||||
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli"
|
||||
:useUrl="info_view.useUrl" @closeModel="close_info()" />
|
||||
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
|
||||
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
|
||||
@cancel="() => dialog_confirm.visible = false" />
|
||||
|
|
@ -81,6 +114,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import DeprecatedNotice from '~/components/DeprecatedNotice.vue'
|
||||
import type { item_request } from '~/types/item'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
|
||||
|
|
@ -95,8 +129,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
|||
const info_view = ref({
|
||||
url: '',
|
||||
preset: '',
|
||||
cli: '',
|
||||
useUrl: false,
|
||||
}) as Ref<{ url: string, preset: string, useUrl: boolean }>
|
||||
}) as Ref<{ url: string, preset: string, cli: string, useUrl: boolean }>
|
||||
const item_form = ref<item_request | object>({})
|
||||
const query = ref()
|
||||
const toggleFilter = ref(false)
|
||||
|
|
@ -139,6 +174,8 @@ watch(() => stateStore.queue, () => {
|
|||
}, { deep: true })
|
||||
|
||||
|
||||
const resumeDownload = async () => await request('/api/system/resume', { method: 'POST' })
|
||||
|
||||
const pauseDownload = () => {
|
||||
dialog_confirm.value.visible = true
|
||||
dialog_confirm.value.html_message = `
|
||||
|
|
@ -153,8 +190,8 @@ const pauseDownload = () => {
|
|||
<li>If you are in middle of adding a playlist/channel, it will break and stop adding more items.</li>
|
||||
</ul>
|
||||
</span>`
|
||||
dialog_confirm.value.confirm = () => {
|
||||
socket.emit('pause', {})
|
||||
dialog_confirm.value.confirm = async () => {
|
||||
await request('/api/system/pause', { method: 'POST' })
|
||||
dialog_confirm.value.visible = false
|
||||
}
|
||||
}
|
||||
|
|
@ -165,10 +202,11 @@ const close_info = () => {
|
|||
info_view.value.useUrl = false
|
||||
}
|
||||
|
||||
const view_info = (url: string, useUrl: boolean = false, preset: string = '') => {
|
||||
const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => {
|
||||
info_view.value.url = url
|
||||
info_view.value.useUrl = useUrl
|
||||
info_view.value.preset = preset
|
||||
info_view.value.cli = cli
|
||||
}
|
||||
|
||||
watch(() => info_view.value.url, v => {
|
||||
|
|
|
|||
|
|
@ -194,9 +194,14 @@
|
|||
|
||||
<hr class="dropdown-divider" />
|
||||
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)">
|
||||
<NuxtLink class="dropdown-item" @click="archiveAll(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Archive all</span>
|
||||
<span>Archive All</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="unarchiveAll(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Unarchive All</span>
|
||||
</NuxtLink>
|
||||
|
||||
<hr class="dropdown-divider" />
|
||||
|
|
@ -313,9 +318,14 @@
|
|||
|
||||
<hr class="dropdown-divider" />
|
||||
|
||||
<NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)">
|
||||
<NuxtLink class="dropdown-item" @click="archiveAll(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Archive all</span>
|
||||
<span>Archive All</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="dropdown-item" @click="unarchiveAll(item)">
|
||||
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
|
||||
<span>Unarchive All</span>
|
||||
</NuxtLink>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
|
@ -370,6 +380,7 @@ const box = useConfirm()
|
|||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
const { confirmDialog: cDialog } = useDialog()
|
||||
const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||
|
||||
const tasks = ref<Array<task_item>>([])
|
||||
|
|
@ -428,7 +439,7 @@ watch(() => config.app.basic_mode, async v => {
|
|||
return
|
||||
}
|
||||
await navigateTo('/')
|
||||
},{ immediate: true })
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => socket.isConnected, async () => {
|
||||
if (socket.isConnected && initialLoad.value) {
|
||||
|
|
@ -764,17 +775,18 @@ const get_tags = (name: string): Array<string> => {
|
|||
|
||||
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
|
||||
|
||||
const archiveItems = async (item: task_item) => {
|
||||
dialog_confirm.value.visible = true
|
||||
dialog_confirm.value.title = 'Archive All videos'
|
||||
dialog_confirm.value.message = `Archive all items for '${item.name}' task? This will mark all items as downloaded and update the archive file.`
|
||||
dialog_confirm.value.confirm = async () => await archiveAll(item)
|
||||
}
|
||||
|
||||
const archiveAll = async (item: task_item) => {
|
||||
try {
|
||||
dialog_confirm.value.visible = false
|
||||
const { status } = await cDialog({
|
||||
message: `Mark all '${item.name}' items as downloaded in download archive?`
|
||||
})
|
||||
|
||||
if (true !== status) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.in_progress = true
|
||||
|
||||
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
|
|
@ -791,4 +803,33 @@ const archiveAll = async (item: task_item) => {
|
|||
item.in_progress = false
|
||||
}
|
||||
}
|
||||
|
||||
const unarchiveAll = async (item: task_item) => {
|
||||
try {
|
||||
const { status } = await cDialog({
|
||||
message: `Remove all '${item.name}' items from download archive?`
|
||||
})
|
||||
|
||||
if (true !== status) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.in_progress = true
|
||||
|
||||
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'DELETE' })
|
||||
const data = await response.json()
|
||||
|
||||
if (data?.error) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(data.message)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
|
||||
return
|
||||
} finally {
|
||||
item.in_progress = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
4
ui/app/types/config.d.ts
vendored
4
ui/app/types/config.d.ts
vendored
|
|
@ -45,7 +45,9 @@ type AppConfig = {
|
|||
/** App branch name, e.g. "main" or "develop" */
|
||||
app_branch: string
|
||||
/** When the app started */
|
||||
started: number
|
||||
started: number,
|
||||
/** Application environment, e.g. "production", "development" */
|
||||
app_env: string
|
||||
}
|
||||
|
||||
type Preset = {
|
||||
|
|
|
|||
6
ui/app/types/store.d.ts
vendored
6
ui/app/types/store.d.ts
vendored
|
|
@ -82,6 +82,12 @@ type StoreItem = {
|
|||
speed?: number | null
|
||||
/** Time remaining for the item download if available */
|
||||
eta?: number | null
|
||||
/** If the item can be archived */
|
||||
is_archivable?: boolean
|
||||
/** If the item is archived */
|
||||
is_archived?: boolean
|
||||
/** Item archive ID */
|
||||
archive_id?: string | null
|
||||
}
|
||||
|
||||
export type { ItemStatus, StoreItem }
|
||||
|
|
|
|||
|
|
@ -12,20 +12,20 @@
|
|||
"web-types": "./web-types.json",
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@sentry/nuxt": "^10.5.0",
|
||||
"@vueuse/core": "^13.7.0",
|
||||
"@vueuse/nuxt": "^13.7.0",
|
||||
"@sentry/nuxt": "^10.8.0",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/nuxt": "^13.8.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cron-parser": "^5.3.0",
|
||||
"cronstrue": "^3.2.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
"hls.js": "^1.6.10",
|
||||
"hls.js": "^1.6.11",
|
||||
"moment": "^2.30.1",
|
||||
"nuxt": "^4.0.3",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.19",
|
||||
"vue": "^3.5.20",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-toastification": "2.0.0-rc.5"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
156
uv.lock
156
uv.lock
|
|
@ -591,6 +591,20 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-curl-cffi"
|
||||
version = "0.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "curl-cffi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/e9/42542f3f4bf9ba457f10dda6620dc3f56e77480722c67579a35956c5d2e2/httpx_curl_cffi-0.1.4.tar.gz", hash = "sha256:1e00b741b121b3781942d45781288f89842f41fe32a743e27672d7fc542c1cd1", size = 7907 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/25/7a093edd86d0ea2cc0ff5570f74b00c603ce100303031a77f7d9db9aa80a/httpx_curl_cffi-0.1.4-py3-none-any.whl", hash = "sha256:bbd6e89abbf26a37c436d9a3d2c55ae646b78b0dcf4bc1be2f2451912e39d7b6", size = 8913 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanfriendly"
|
||||
version = "10.0"
|
||||
|
|
@ -744,11 +758,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.3.8"
|
||||
version = "4.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1103,66 +1117,66 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2025.7.34"
|
||||
version = "2025.8.29"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/de/e13fa6dc61d78b30ba47481f99933a3b49a57779d625c392d8036770a60d/regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a", size = 400714 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/10/2d333227cf5198eb3252f2d50c8ade5cd2015f11c22403f0c9e3d529e81a/regex-2025.8.29.tar.gz", hash = "sha256:731ddb27a0900fa227dfba976b4efccec8c1c6fba147829bb52e71d49e91a5d7", size = 400817 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/85/f497b91577169472f7c1dc262a5ecc65e39e146fc3a52c571e5daaae4b7d/regex-2025.7.34-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da304313761b8500b8e175eb2040c4394a875837d5635f6256d6fa0377ad32c8", size = 484594 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/c5/ad2a5c11ce9e6257fcbfd6cd965d07502f6054aaa19d50a3d7fd991ec5d1/regex-2025.7.34-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:35e43ebf5b18cd751ea81455b19acfdec402e82fe0dc6143edfae4c5c4b3909a", size = 289294 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/01/83ffd9641fcf5e018f9b51aa922c3e538ac9439424fda3df540b643ecf4f/regex-2025.7.34-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96bbae4c616726f4661fe7bcad5952e10d25d3c51ddc388189d8864fbc1b3c68", size = 285933 },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/20/5edab2e5766f0259bc1da7381b07ce6eb4401b17b2254d02f492cd8a81a8/regex-2025.7.34-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9feab78a1ffa4f2b1e27b1bcdaad36f48c2fed4870264ce32f52a393db093c78", size = 792335 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/bd/744d3ed8777dce8487b2606b94925e207e7c5931d5870f47f5b643a4580a/regex-2025.7.34-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f14b36e6d4d07f1a5060f28ef3b3561c5d95eb0651741474ce4c0a4c56ba8719", size = 858605 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/3d/93754176289718d7578c31d151047e7b8acc7a8c20e7706716f23c49e45e/regex-2025.7.34-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85c3a958ef8b3d5079c763477e1f09e89d13ad22198a37e9d7b26b4b17438b33", size = 905780 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/2e/c689f274a92deffa03999a430505ff2aeace408fd681a90eafa92fdd6930/regex-2025.7.34-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37555e4ae0b93358fa7c2d240a4291d4a4227cc7c607d8f85596cdb08ec0a083", size = 798868 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9e/39673688805d139b33b4a24851a71b9978d61915c4d72b5ffda324d0668a/regex-2025.7.34-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee38926f31f1aa61b0232a3a11b83461f7807661c062df9eb88769d86e6195c3", size = 781784 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/bd/4c1cab12cfabe14beaa076523056b8ab0c882a8feaf0a6f48b0a75dab9ed/regex-2025.7.34-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a664291c31cae9c4a30589bd8bc2ebb56ef880c9c6264cb7643633831e606a4d", size = 852837 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/21/663d983cbb3bba537fc213a579abbd0f263fb28271c514123f3c547ab917/regex-2025.7.34-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f3e5c1e0925e77ec46ddc736b756a6da50d4df4ee3f69536ffb2373460e2dafd", size = 844240 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/2d/9beeeb913bc5d32faa913cf8c47e968da936af61ec20af5d269d0f84a100/regex-2025.7.34-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d428fc7731dcbb4e2ffe43aeb8f90775ad155e7db4347a639768bc6cd2df881a", size = 787139 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f5/9b9384415fdc533551be2ba805dd8c4621873e5df69c958f403bfd3b2b6e/regex-2025.7.34-cp311-cp311-win32.whl", hash = "sha256:e154a7ee7fa18333ad90b20e16ef84daaeac61877c8ef942ec8dfa50dc38b7a1", size = 264019 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/9d/e069ed94debcf4cc9626d652a48040b079ce34c7e4fb174f16874958d485/regex-2025.7.34-cp311-cp311-win_amd64.whl", hash = "sha256:24257953d5c1d6d3c129ab03414c07fc1a47833c9165d49b954190b2b7f21a1a", size = 276047 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/3bafbe9d1fd1db77355e7fbbbf0d0cfb34501a8b8e334deca14f94c7b315/regex-2025.7.34-cp311-cp311-win_arm64.whl", hash = "sha256:3157aa512b9e606586900888cd469a444f9b898ecb7f8931996cb715f77477f0", size = 268362 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/f0/31d62596c75a33f979317658e8d261574785c6cd8672c06741ce2e2e2070/regex-2025.7.34-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7f7211a746aced993bef487de69307a38c5ddd79257d7be83f7b202cb59ddb50", size = 485492 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/16/b818d223f1c9758c3434be89aa1a01aae798e0e0df36c1f143d1963dd1ee/regex-2025.7.34-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fb31080f2bd0681484b275461b202b5ad182f52c9ec606052020fe13eb13a72f", size = 290000 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/70/69506d53397b4bd6954061bae75677ad34deb7f6ca3ba199660d6f728ff5/regex-2025.7.34-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0200a5150c4cf61e407038f4b4d5cdad13e86345dac29ff9dab3d75d905cf130", size = 286072 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/73/536a216d5f66084fb577bb0543b5cb7de3272eb70a157f0c3a542f1c2551/regex-2025.7.34-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:739a74970e736df0773788377969c9fea3876c2fc13d0563f98e5503e5185f46", size = 797341 },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/af/733f8168449e56e8f404bb807ea7189f59507cbea1b67a7bbcd92f8bf844/regex-2025.7.34-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4fef81b2f7ea6a2029161ed6dea9ae13834c28eb5a95b8771828194a026621e4", size = 862556 },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/dd/59c464d58c06c4f7d87de4ab1f590e430821345a40c5d345d449a636d15f/regex-2025.7.34-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea74cf81fe61a7e9d77989050d0089a927ab758c29dac4e8e1b6c06fccf3ebf0", size = 910762 },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a8/b05ccf33ceca0815a1e253693b2c86544932ebcc0049c16b0fbdf18b688b/regex-2025.7.34-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4636a7f3b65a5f340ed9ddf53585c42e3ff37101d383ed321bfe5660481744b", size = 801892 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9a/b993cb2e634cc22810afd1652dba0cae156c40d4864285ff486c73cd1996/regex-2025.7.34-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cef962d7834437fe8d3da6f9bfc6f93f20f218266dcefec0560ed7765f5fe01", size = 786551 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/79/7849d67910a0de4e26834b5bb816e028e35473f3d7ae563552ea04f58ca2/regex-2025.7.34-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:cbe1698e5b80298dbce8df4d8d1182279fbdaf1044e864cbc9d53c20e4a2be77", size = 856457 },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c6/de516bc082524b27e45cb4f54e28bd800c01efb26d15646a65b87b13a91e/regex-2025.7.34-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:32b9f9bcf0f605eb094b08e8da72e44badabb63dde6b83bd530580b488d1c6da", size = 848902 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/22/519ff8ba15f732db099b126f039586bd372da6cd4efb810d5d66a5daeda1/regex-2025.7.34-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:524c868ba527eab4e8744a9287809579f54ae8c62fbf07d62aacd89f6026b282", size = 788038 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/7d/aabb467d8f57d8149895d133c88eb809a1a6a0fe262c1d508eb9dfabb6f9/regex-2025.7.34-cp312-cp312-win32.whl", hash = "sha256:d600e58ee6d036081c89696d2bdd55d507498a7180df2e19945c6642fac59588", size = 264417 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/39/bd922b55a4fc5ad5c13753274e5b536f5b06ec8eb9747675668491c7ab7a/regex-2025.7.34-cp312-cp312-win_amd64.whl", hash = "sha256:9a9ab52a466a9b4b91564437b36417b76033e8778e5af8f36be835d8cb370d62", size = 275387 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/c61d2fdcecb754a40475a3d1ef9a000911d3e3fc75c096acf44b0dfb786a/regex-2025.7.34-cp312-cp312-win_arm64.whl", hash = "sha256:c83aec91af9c6fbf7c743274fd952272403ad9a9db05fe9bfc9df8d12b45f176", size = 268482 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/16/b709b2119975035169a25aa8e4940ca177b1a2e25e14f8d996d09130368e/regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5", size = 485334 },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a6/c09136046be0595f0331bc58a0e5f89c2d324cf734e0b0ec53cf4b12a636/regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd", size = 289942 },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/91/08fc0fd0f40bdfb0e0df4134ee37cfb16e66a1044ac56d36911fd01c69d2/regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b", size = 285991 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2f/99dc8f6f756606f0c214d14c7b6c17270b6bbe26d5c1f05cde9dbb1c551f/regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad", size = 797415 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/cf/2fcdca1110495458ba4e95c52ce73b361cf1cafd8a53b5c31542cde9a15b/regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59", size = 862487 },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/38/899105dd27fed394e3fae45607c1983e138273ec167e47882fc401f112b9/regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415", size = 910717 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/f6/4716198dbd0bcc9c45625ac4c81a435d1c4d8ad662e8576dac06bab35b17/regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f", size = 801943 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/5d/cff8896d27e4e3dd11dd72ac78797c7987eb50fe4debc2c0f2f1682eb06d/regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1", size = 786664 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/29/758bf83cf7b4c34f07ac3423ea03cee3eb3176941641e4ccc05620f6c0b8/regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c", size = 856457 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/30/c19d212b619963c5b460bfed0ea69a092c6a43cba52a973d46c27b3e2975/regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a", size = 849008 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/b8/3c35da3b12c87e3cc00010ef6c3a4ae787cff0bc381aa3d251def219969a/regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0", size = 788101 },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/23/6376f3a23cf2f3c00514b1cdd8c990afb4dfbac3cb4a68b633c6b7e2e307/regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a", size = 485385 },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/5b/6d4d3a0b4d312adbfd6d5694c8dddcf1396708976dd87e4d00af439d962b/regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435", size = 289788 },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/71/5862ac9913746e5054d01cb9fb8125b3d0802c0706ef547cae1e7f4428fa/regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac", size = 286136 },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/df/5b505dc447eb71278eba10d5ec940769ca89c1af70f0468bfbcb98035dc2/regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72", size = 797753 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/38/3e3dc953d13998fa047e9a2414b556201dbd7147034fbac129392363253b/regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e", size = 863263 },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e5/3ff66b29dde12f5b874dda2d9dec7245c2051f2528d8c2a797901497f140/regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751", size = 910103 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/fe/14176f2182125977fba3711adea73f472a11f3f9288c1317c59cd16ad5e6/regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4", size = 801709 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/0d/80d4e66ed24f1ba876a9e8e31b709f9fd22d5c266bf5f3ab3c1afe683d7d/regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98", size = 786726 },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/75/c3ebb30e04a56c046f5c85179dc173818551037daae2c0c940c7b19152cb/regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7", size = 857306 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b2/a4dc5d8b14f90924f27f0ac4c4c4f5e195b723be98adecc884f6716614b6/regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47", size = 848494 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/21/9ac6e07a4c5e8646a90b56b61f7e9dac11ae0747c857f91d3d2bc7c241d9/regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e", size = 787850 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730 },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a2/e9b9ce5407af9147dc39a7de4f161fd72804c095ea398ab472e8dbc65533/regex-2025.8.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:156f711019968ffb3512723a38b06d94d379675c296bdb6104d1abb6e57374c6", size = 484663 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/7c/5b2cf5f1350c1c218542fb0be89cf28d8375ebe240cb5769f108325eb285/regex-2025.8.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9082c0db8d43c696fac70b5b0592934f21533940f0118239b5c32fa23e51ed1a", size = 289365 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/27/44733d2aa3b0c9532580872e9ed2df6a86fe7b975b75dc1f1733f6751e55/regex-2025.8.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3535b9a69a818735ebac392876dae4b215fe28c13b145353a2dac468ebae16", size = 286007 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ac/2d4f6904422b95f22d1548d8655b288837f3218b54853c6050de61a87b7e/regex-2025.8.29-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c460628f6098cf8916b2d62fb39a37a39e49cca0279ac301ff9d94f7e75033e", size = 792412 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/61/8f67415c0ad59abf8f4dd24ad9de504eb37c363318f757be35c42b537d66/regex-2025.8.29-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dad3ce46390fe3d81ae1c131e29179f010925fa164e15b918fb037effdb7ad9", size = 858682 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/31/c3552278e507ab255c51dce4dda0072252e78c801a16697085e71595b1c7/regex-2025.8.29-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f89e5beb3012d3c36c526fd4af163ada24011a0b417378f726b17c2fb382a35d", size = 905855 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/84/5150fdffe83df17a7b869930c06d8007b890be3fdf6eb509b849431cabeb/regex-2025.8.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40eeff06bbcfa69201b60488f3f3aa38ad3c92c7c0ab2cfc7c9599abfdf24262", size = 798943 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/bc/695f94a6fada1838adc75312512843f8d9d94eda71c253958fb40bba5083/regex-2025.8.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d7a9bc68610d22735b6ac01a3c3ef5b03d9303a18bd3e2249340213389f273dc", size = 781859 },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8e/641b228837f551c129bc03005a158c48aebb353a1f6a34dfcea025b5e4bc/regex-2025.8.29-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e785e40f7edfc19ff0b81b27f25eefdb0251cfd2ac4a9fa1eea03f5129e93758", size = 852914 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/49/b8d55dffd138369ee8378830b3bad4f7b815517df5ad16212031521f966f/regex-2025.8.29-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba1deae2ceaa0b181ac9fd4cb8f04d6ba1494f3c8d053c8999f7c0dadb93497b", size = 844314 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/73/48b6b616fdc1b6dc75a00c2670da7038400796c855b7bd0fbd4dad18c26c/regex-2025.8.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15869e4f36de7091342e1dae90216aafa3746e3a069f30b34503a36931036f95", size = 787215 },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/af/38af20de8ea862c5275da67d5a0e63023a92cc5df344ad9a80fc1fcd448e/regex-2025.8.29-cp311-cp311-win32.whl", hash = "sha256:aef62e0b08b0e3c2616783a9f75a02f001254695a0a1d28b829dc9fb6a3603e4", size = 264088 },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d9/f765e5d9eaaa67e10267662002aea786334176c2b22066437df6d73a6424/regex-2025.8.29-cp311-cp311-win_amd64.whl", hash = "sha256:fd347592a4811ba1d246f99fb53db82a1898a5aebb511281ac0c2d81632e1789", size = 276119 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/cd/44da9fae9a0c1af09f7171facc8d6313b1cbdfeea9f3526607495a28bdd7/regex-2025.8.29-cp311-cp311-win_arm64.whl", hash = "sha256:d93801012bb23901df403ae0adf528abfd50041c9e1136a303937d45c14466e0", size = 268429 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a0/8c37d276a80ffda94f7e019e50cc88f898015512c7f104e49f1a0a6d3c59/regex-2025.8.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd61f18dc4446bc3a2904559a61f32e98091cef7fb796e06fa35b9bfefe4c0c5", size = 485565 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/34/baf5963bec36ac250fa242f0f0e7670f013de5004db6caa31c872981df42/regex-2025.8.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f21b416be10a8348a7313ba8c610569a1ab4bf8ec70731750540842a4551cd3d", size = 290073 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/29/c5c18143cd60b736d7ff8acece126118fe5649f45a7a8db18e308f5f813d/regex-2025.8.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:008947a7fa92f4cb3b28201c9aa7becc0a44c31a7c2fcb934356e1877baccc09", size = 286144 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7c/0d90b687d2a33fe28b201f85ddfde6b378bf41677aedbe23eb7dc79385aa/regex-2025.8.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78ab1b3e68b890d7ebd69218cfbfe4a09dc00b8a47be8648510b81b932d55ff", size = 797417 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/67/c391c899e5ef274c4dd4ede029ffb853ddf5ba77aa251be02cfe3810574c/regex-2025.8.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a848368797515bc141d3fad5fd2d81bf9e8a6a22d9ac1a4be4690dd22e997854", size = 862630 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/20/ae749a68da3496a133836c8724649bd2e004fc176c7c6647d9cb269cc975/regex-2025.8.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8eaf3ea6631f804efcf0f5bd0e4ab62ba984fd9b70e3aef44b05cc6b951cc728", size = 910837 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/80/bc4244ec79fba4185fd3a29d79f77f79b3b0dc12ee426687501b0b077e2a/regex-2025.8.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4561aeb36b0bf3bb44826e4b61a80c6ace0d8839bf4914d78f061f9ba61444b4", size = 801968 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/bd/a2d75042bb1d3c9997e22bc0051cb9791a405589d6293c874f7c2ba487e7/regex-2025.8.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:93e077d1fbd24033fa427eab43d80ad47e449d25700cda78e8cac821a30090bf", size = 786626 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ab/19cec75bf7d335cc7595d4857591455de118f6bfb563e6731c31f4fe33c3/regex-2025.8.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d92379e53d782bdb773988687300e3bccb91ad38157b754b04b1857aaeea16a3", size = 856532 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/3d/517cd0b0f4b8330164d03ef0eafdd61ee839f82b891fcd8c571d5c727117/regex-2025.8.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d41726de2040c2a487bbac70fdd6e3ff2f1aa47dc91f0a29f6955a6dfa0f06b6", size = 848977 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/fc/b57e2644d87d038d7302f359f4042bf7092bd8259a3ae999adf236e6fbc0/regex-2025.8.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1915dfda52bd4d466f3a66b66988db1f647ee1d9c605858640ceeb779cffd908", size = 788112 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/2f/70737feddbd33ec9f3f0cb8b38e7fc89304eccc80fd693d79a6f336e2282/regex-2025.8.29-cp312-cp312-win32.whl", hash = "sha256:e2ef0087ad6949918836f215480a9331f6c59ad54912a9a412f08ab1c9ccbc98", size = 264487 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/f5/8832d05ecc5a7f80043e7521ea55adfa2d9b9ac0e646474153e7e13722c2/regex-2025.8.29-cp312-cp312-win_amd64.whl", hash = "sha256:c15d361fe9800bf38ef69c2e0c4b8b961ae4ce2f076fcf4f28e1fc9ea127f55a", size = 275455 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/f9/f10ae0c4e5e22db75dda155d83056e2b70c4e87b04ad9838723ff5057e90/regex-2025.8.29-cp312-cp312-win_arm64.whl", hash = "sha256:305577fab545e64fb84d9a24269aa3132dbe05e1d7fa74b3614e93ec598fe6e6", size = 268558 },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/2f0e1fbca855f3c519f3f8198817d14a9569ca939bc0cc86efd4da196d3e/regex-2025.8.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:eed02e5c39f91268ea4ddf68ee19eed189d57c605530b7d32960f54325c52e7a", size = 485405 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ed/52afe839607719750acc87d144ec3db699adb9c1f40ecb6fa9f3700437b6/regex-2025.8.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:630d5c7e0a490db2fee3c7b282c8db973abcbb036a6e4e6dc06c4270965852be", size = 290014 },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/84/beb3becb129e41ae3e6bacd737aa751228ec0c17c707b9999648f050968c/regex-2025.8.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2206d3a30469e8fc8848139884168127f456efbaca8ae14809c26b98d2be15c6", size = 286059 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/31/74476ac68cd5ed46634683cba634ab0885e917624d620c5959f67835554b/regex-2025.8.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394c492c398a9f9e17545e19f770c58b97e65963eedaa25bb879e80a03e2b327", size = 797490 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/97/1a8d109f891c4af31f43295304a51b76bc7aef4ce6d7953e4832f86c85f0/regex-2025.8.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db8b0e05af08ff38d78544950e844b5f159032b66dedda19b3f9b17297248be7", size = 862562 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/a8/13d6ea4b8a0c7eed0e528dcb25cbdc3bc53e26b0928dc48d6c0381516c4a/regex-2025.8.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd7c1821eff911917c476d41030b422791ce282c23ee9e1b8f7681fd0993f1e4", size = 910790 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/b3/1c7320c1fdc6569a086949d2c5b7b742696098c28a6c83ca909b8d36d17b/regex-2025.8.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0b4d8a7f75da748a2d0c045600259f1899c9dd8dd9d3da1daa50bf534c3fa5ba", size = 802016 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b5/f3613b70a569b6309cd2a61ae869407b45cff25c9734f5ff179b416e9615/regex-2025.8.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5cd74545c32e0da0d489c2293101a82f4a1b88050c235e45509e4123017673b2", size = 786740 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/8a/9f16babae23011acbd27f886c4817159508f4f3209bcfce4bc2b8f12f2ba/regex-2025.8.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:97b98ea38fc3c1034f3d7bd30288d2c5b3be8cdcd69e2061d1c86cb14644a27b", size = 856533 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/d0/adca6eec8ed79541edadecf8b512d7a3960c2ba983d2e5baf68dbddd7a90/regex-2025.8.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8decb26f271b989d612c5d99db5f8f741dcd63ece51c59029840070f5f9778bf", size = 849083 },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/cc/37fddb2a17cefffb43b9dfd5f585a6cd6f90ee5b32c821886d0c0c3bc243/regex-2025.8.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62141843d1ec079cd66604424af566e542e7e072b2d9e37165d414d2e6e271dd", size = 788177 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ea/413fe88ce5ac2418223434aa1603d92134b74deed6007dc6e4c37d83bbcd/regex-2025.8.29-cp313-cp313-win32.whl", hash = "sha256:dd23006c90d9ff0c2e4e5f3eaf8233dcefe45684f2acb330869ec5c2aa02b1fb", size = 264473 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/73/d07bc1d1969e41bf1637a8aad4228da506747f4c94415ef03c534c7d68d6/regex-2025.8.29-cp313-cp313-win_amd64.whl", hash = "sha256:d41a71342819bdfe87c701f073a14ea4bd3f847333d696c7344e9ff3412b7f70", size = 275438 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/cd/2e05fc85ebee6fe6c5073c9b0c737a473c226422d75e93903810b247a9fe/regex-2025.8.29-cp313-cp313-win_arm64.whl", hash = "sha256:54018e66344d60b214f4aa151c046e0fa528221656f4f7eba5a787ccc7057312", size = 268553 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/2d/2aa4b98231017994ea52d05c13997778af415f5d7faa7f90988a640dac44/regex-2025.8.29-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c03308757831a8d89e7c007abb75d1d4c9fbca003b5fb32755d4475914535f08", size = 485447 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b4/ed3241bb99a0783fe650d8511924c7c43f704b720fab3e353393bea8c96a/regex-2025.8.29-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0d4b71791975fc203e0e6c50db974abb23a8df30729c1ac4fd68c9f2bb8c9358", size = 289862 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f6/5237a7d0b2bd64bb216d06470549bc4cc33de57033772e3018708636a027/regex-2025.8.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:284fcd2dcb613e8b89b22a30cf42998c9a73ee360b8a24db8457d24f5c42282e", size = 286211 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/eb/05568fdc4028d1b339fb950fe6b92ade2613edd6423291939c8e29b21e8a/regex-2025.8.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b394b5157701b22cf63699c792bfeed65fbfeacbd94fea717a9e2036a51148ab", size = 797826 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/2a/a3c1c209faa1f6a218e64c5a235e06f6f36c45b5aa924c6bf75241a996f7/regex-2025.8.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea197ac22396faf5e70c87836bb89f94ed5b500e1b407646a4e5f393239611f1", size = 863338 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/66/5e96f217662387742c0d9732e97129850bd3243e019309c1fbdcd62b5421/regex-2025.8.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:decd84f195c08b3d9d0297a7e310379aae13ca7e166473534508c81b95c74bba", size = 910176 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f2/975e77333267f9652bc2cc926382d8c9d86683eb84d1989459e644ac818b/regex-2025.8.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebaf81f7344dbf1a2b383e35923648de8f78fb262cf04154c82853887ac3e684", size = 801784 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/d9/b25dbf9729b5a5958a804e91b376fe8e829ec10c0d7edb4b1ad91070132b/regex-2025.8.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d82fb8a97e5ed8f1d3ed7f8e0e7fe1760faa95846c0d38b314284dfdbe86b229", size = 786799 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/0a/7f8de7ea41d7a3a21dfcb9dcea7b727fdde9e35d74a23e16ef5edcd68005/regex-2025.8.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1dcec2448ed0062f63e82ca02d1d05f74d4127cb6a9d76a73df60e81298d380b", size = 857380 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/40/494600424c394a507070b41fc0666ceaa7dccf62c3220a76833eb11de647/regex-2025.8.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d0ffe4a3257a235f9d39b99c6f1bc53c7a4b11f28565726b1aa00a5787950d60", size = 848570 },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d0/6988feb7c15bb3df7b944a10b3b58fb238c94987c70a991ba87e3685e1cd/regex-2025.8.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5421a2d2026e8189500f12375cfd80a9a1914466d446edd28b37eb33c1953b39", size = 787926 },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/16/d719b131b0577a2a975376b3e673fc7f89b9998d54753f0419d59d33b3a1/regex-2025.8.29-cp314-cp314-win32.whl", hash = "sha256:ceeeaab602978c8eac3b25b8707f21a69c0bcd179d9af72519da93ef3966158f", size = 269805 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b7/50d3bb5df25ae73e7aee186a2f1e4f1ed5e4d54006bdf5abd558c1ce9e7a/regex-2025.8.29-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f8b0d5b88c33fe4060e6def58001fd8334b03c7ce2126964fa8851ab5d1b", size = 278710 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/34/c723ebe214c33000b53e0eebdc63ad3697d5611c7fa9b388eef2113a5e82/regex-2025.8.29-cp314-cp314-win_arm64.whl", hash = "sha256:7b4a3dc155984f09a55c64b90923cb136cd0dad21ca0168aba2382d90ea4c546", size = 271832 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1234,11 +1248,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.1"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1367,11 +1381,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "yt-dlp"
|
||||
version = "2025.8.22"
|
||||
version = "2025.8.27"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/98/b077bebdc5c759a3f7af3ed3a2a5345ad1145c61963b476469b840ac84ce/yt_dlp-2025.8.22.tar.gz", hash = "sha256:d1846bbb7edbcd2a0d4a2d76c7a2124868de9ea3b3959a8cb8219e3f7cb5c335", size = 3037631 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/d4/d9dd231b03f09fdfb5f0fe70f30de0b5f59454aa54fa6b2b2aea49404988/yt_dlp-2025.8.27.tar.gz", hash = "sha256:ed74768d2a93b29933ab14099da19497ef571637f7aa375140dd3d882b9c1854", size = 3038374 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/06/e9e5e85969bd85142b004577915f33356ee0d5e20b79af8dd40b8bbfc96f/yt_dlp-2025.8.22-py3-none-any.whl", hash = "sha256:b8c71fe4516170dea60c6e5c54e2d45654693b8dc273cad060f22199476ec979", size = 3266783 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9c/b69fc0c800f80b94ea2f8eff1d1f473fecee6aa337681d297ba7c7c5d3fd/yt_dlp-2025.8.27-py3-none-any.whl", hash = "sha256:0b8fd3bb7c54bc2e7ecb5cdac7d64c30e2503ea4d3dd9ae24d4f09e22aaa95f4", size = 3267059 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1394,6 +1408,7 @@ dependencies = [
|
|||
{ name = "debugpy" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-curl-cffi" },
|
||||
{ name = "multidict" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "platformdirs" },
|
||||
|
|
@ -1432,6 +1447,7 @@ requires-dist = [
|
|||
{ name = "debugpy", specifier = ">=1.8.1" },
|
||||
{ name = "defusedxml", specifier = ">=0.7.1" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-curl-cffi", specifier = ">=0.1.4" },
|
||||
{ name = "multidict", specifier = "==6.5.1" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "platformdirs" },
|
||||
|
|
@ -1452,9 +1468,9 @@ provides-extras = ["installer"]
|
|||
|
||||
[[package]]
|
||||
name = "zipstream-ng"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/16/5d9224baf640214255c34a0a0e9528c8403d2b89e2ba7df9d7cada58beb1/zipstream_ng-1.8.0.tar.gz", hash = "sha256:b7129d2c15d26934b3e1cb22256593b6bdbd03c553c26f4199a5bf05110642bc", size = 35887 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/f2/690a35762cf8366ce6f3b644805de970bd6a897ca44ce74184c7b2bc94e7/zipstream_ng-1.9.0.tar.gz", hash = "sha256:a0d94030822d137efbf80dfdc680603c42f804696f41147bb3db895df667daea", size = 37963 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/81/11ecdfd5370d6c383f0188a6f2fa2842499e1be617e678d1845f972c6821/zipstream_ng-1.8.0-py3-none-any.whl", hash = "sha256:e7196cb845cf924ed12e7a3b38404ef9e82a5a699801295f5f4cf601449e2bf6", size = 23082 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/62/c2da1c495291a52e561257d017585e08906d288035d025ccf636f6b9a266/zipstream_ng-1.9.0-py3-none-any.whl", hash = "sha256:31dc2cf617abdbf28d44f2e08c0d14c8eee2ea0ec26507a7e4d5d5f97c564b7a", size = 24852 },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue