Compare commits
53 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68646e266 | ||
|
|
3c571429e7 | ||
|
|
0ba52fa38e | ||
|
|
19f0b7819e | ||
|
|
3381ca3805 | ||
|
|
72f2373c42 | ||
|
|
44f5f2eee0 | ||
|
|
13424d1bcf | ||
|
|
3631947234 | ||
|
|
aedeaf9a0a | ||
|
|
18f54a28c6 | ||
|
|
db8d8663eb | ||
|
|
a614a48108 | ||
|
|
4f5ac8d8a0 | ||
|
|
c7fbe6b484 | ||
|
|
8c54ed6c0a | ||
|
|
9ada8b5a91 | ||
|
|
a346ed69d9 | ||
|
|
3e663bd483 | ||
|
|
4b7230843e | ||
|
|
196d2d4e32 | ||
|
|
a194b9bf28 | ||
|
|
d6318fca0f | ||
|
|
4881fabb15 | ||
|
|
7a3c41b6bc | ||
|
|
473e45c55b | ||
|
|
4319be36f6 | ||
|
|
2efc09d587 | ||
|
|
acca33a7dc | ||
|
|
e61df14ec0 | ||
|
|
0482df2fec | ||
|
|
50dd2d336c | ||
|
|
6df1250a43 | ||
|
|
c70b3a46bd | ||
|
|
10c18ff5b9 | ||
|
|
9259119227 | ||
|
|
ab56a7d8c9 | ||
|
|
fa8ba1af34 | ||
|
|
1267dbaf6b | ||
|
|
39ed3d0361 | ||
|
|
cf90db3b4c | ||
|
|
ef8642ace7 | ||
|
|
434d3e981d | ||
|
|
376e439115 | ||
|
|
64fdcac0f7 | ||
|
|
8e14258c7a | ||
|
|
2e9684de5d | ||
|
|
ca7227ffc5 | ||
|
|
3b3c8120b8 | ||
|
|
88db357b7f | ||
|
|
854f5f4b42 | ||
|
|
d18145f1c5 | ||
|
|
3bd2a7bee7 |
207 changed files with 14311 additions and 6085 deletions
2
.github/workflows/build-pr.yml
vendored
2
.github/workflows/build-pr.yml
vendored
|
|
@ -15,7 +15,7 @@ on:
|
|||
|
||||
env:
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -29,7 +29,7 @@ env:
|
|||
DOCKERHUB_SLUG: arabcoders/ytptube
|
||||
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
PYTHON_VERSION: "3.13"
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
2
.github/workflows/native-build.yml
vendored
2
.github/workflows/native-build.yml
vendored
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
env:
|
||||
PYTHON_VERSION: "3.13"
|
||||
BUN_VERSION: latest
|
||||
NODE_VERSION: 20
|
||||
NODE_VERSION: 22
|
||||
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
steps:
|
||||
|
|
|
|||
249
API.md
249
API.md
|
|
@ -59,7 +59,6 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
|
||||
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
|
||||
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile)
|
||||
- [GET /api/thumbnail](#get-apithumbnail)
|
||||
- [GET /api/file/ffprobe/{file:.\*}](#get-apifileffprobefile)
|
||||
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
|
||||
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
|
||||
|
|
@ -89,6 +88,8 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [DELETE /api/conditions/{id}](#delete-apiconditionsid)
|
||||
- [GET /api/logs](#get-apilogs)
|
||||
- [GET /api/logs/stream](#get-apilogsstream)
|
||||
- [GET /api/logs/level](#get-apilogslevel)
|
||||
- [POST /api/logs/level/{level}](#post-apilogslevellevel)
|
||||
- [GET /api/notifications/](#get-apinotifications)
|
||||
- [GET /api/notifications/events/](#get-apinotificationsevents)
|
||||
- [POST /api/notifications/](#post-apinotifications)
|
||||
|
|
@ -100,6 +101,8 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [POST /api/notifications/test](#post-apinotificationstest)
|
||||
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
|
||||
- [GET /api/system/configuration](#get-apisystemconfiguration)
|
||||
- [GET /api/system/folders](#get-apisystemfolders)
|
||||
- [GET /api/system/diagnostics](#get-apisystemdiagnostics)
|
||||
- [GET /api/system/limits](#get-apisystemlimits)
|
||||
- [POST /api/system/terminal](#post-apisystemterminal)
|
||||
- [GET /api/system/terminal](#get-apisystemterminal)
|
||||
|
|
@ -697,10 +700,16 @@ GET /api/history?type=queue&status=pending&order=ASC
|
|||
|
||||
This endpoint returns the current state of active downloads from memory.
|
||||
|
||||
**Query Parameters**:
|
||||
- `limit` (optional): Override the configured queue display limit for this request. `0` means unlimited.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"history_count": 0, // total number of completed items in history
|
||||
"queue_count": 250, // total number of queued items
|
||||
"queue_loaded": 100, // number of queued items included in this response
|
||||
"queue_limit": 100, // effective display limit, 0 means unlimited
|
||||
"queue":{
|
||||
"id": "abc123",
|
||||
"url": "https://example.com/video",
|
||||
|
|
@ -1655,17 +1664,6 @@ Binary TS data (`Content-Type: video/mpegts`).
|
|||
|
||||
---
|
||||
|
||||
### GET /api/thumbnail
|
||||
**Purpose**: Proxy/fetch a remote thumbnail image.
|
||||
|
||||
**Query Parameter**:
|
||||
- `?url=<remote-thumbnail-url>`
|
||||
|
||||
**Response**:
|
||||
Binary image data with the appropriate `Content-Type`.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/file/ffprobe/{file:.*}
|
||||
**Purpose**: Return the `ffprobe` data for a local file.
|
||||
|
||||
|
|
@ -2325,12 +2323,46 @@ Binary image data with appropriate headers
|
|||
{
|
||||
"logs": [
|
||||
{
|
||||
"timestamp": "2023-01-01T12:00:00Z",
|
||||
"level": "INFO",
|
||||
"message": "...",
|
||||
...
|
||||
},
|
||||
...
|
||||
"id": "<uuid>",
|
||||
"datetime": "2026-05-18T12:00:00.000+00:00",
|
||||
"level": "error",
|
||||
"levelno": 40,
|
||||
"logger": "ytptube",
|
||||
"message": "Failed to download 'Example Video'.",
|
||||
"exception": {
|
||||
"type": "ValueError",
|
||||
"message": "bad",
|
||||
"file": "/app/library/downloads/queue_manager.py",
|
||||
"line": 123,
|
||||
"stack": [
|
||||
{
|
||||
"path": "/app/library/downloads/queue_manager.py",
|
||||
"file": "queue_manager.py",
|
||||
"module": "queue_manager",
|
||||
"function": "start",
|
||||
"line": 123
|
||||
}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"path": "/app/library/downloads/queue_manager.py",
|
||||
"file": "queue_manager.py",
|
||||
"module": "queue_manager",
|
||||
"function": "start",
|
||||
"line": 123
|
||||
},
|
||||
"fields": {
|
||||
"download": {
|
||||
"download_id": "abc123",
|
||||
"media_id": "video-id",
|
||||
"title": "Example Video",
|
||||
"url": "https://example.test/video",
|
||||
"preset": "default",
|
||||
"status": "error",
|
||||
"has_cookies": false
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"offset": 0,
|
||||
"limit": 100,
|
||||
|
|
@ -2352,9 +2384,45 @@ Binary image data with appropriate headers
|
|||
**Event Payload**:
|
||||
```json
|
||||
{
|
||||
"id": "<sha256>",
|
||||
"line": "<log line>",
|
||||
"datetime": "2024-01-01T12:00:00.000000+00:00"
|
||||
"id": "<uuid>",
|
||||
"datetime": "2026-05-18T12:00:00.000+00:00",
|
||||
"level": "error",
|
||||
"levelno": 40,
|
||||
"logger": "ytptube",
|
||||
"message": "Failed to download 'Example Video'.",
|
||||
"exception": {
|
||||
"type": "ValueError",
|
||||
"message": "bad",
|
||||
"file": "/app/library/downloads/queue_manager.py",
|
||||
"line": 123,
|
||||
"stack": [
|
||||
{
|
||||
"path": "/app/library/downloads/queue_manager.py",
|
||||
"file": "queue_manager.py",
|
||||
"module": "queue_manager",
|
||||
"function": "start",
|
||||
"line": 123
|
||||
}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"path": "/app/library/downloads/queue_manager.py",
|
||||
"file": "queue_manager.py",
|
||||
"module": "queue_manager",
|
||||
"function": "start",
|
||||
"line": 123
|
||||
},
|
||||
"fields": {
|
||||
"download": {
|
||||
"download_id": "abc123",
|
||||
"media_id": "video-id",
|
||||
"title": "Example Video",
|
||||
"url": "https://example.test/video",
|
||||
"preset": "default",
|
||||
"status": "error",
|
||||
"has_cookies": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -2362,6 +2430,31 @@ Binary image data with appropriate headers
|
|||
|
||||
---
|
||||
|
||||
### GET /api/logs/level
|
||||
**Purpose**: Read the active runtime log level.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"conf": "info",
|
||||
"active": "info",
|
||||
"levels": ["debug", "info", "warning", "error"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /api/logs/level/{level}
|
||||
**Purpose**: Change the active runtime log level.
|
||||
|
||||
**Path Parameter**:
|
||||
- `level`: One of `debug`, `info`, `warning`, `error`.
|
||||
|
||||
**Response**:
|
||||
- `204 No Content` on success.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/notifications/
|
||||
**Purpose**: Retrieve notification targets with pagination.
|
||||
|
||||
|
|
@ -2542,55 +2635,86 @@ or an error:
|
|||
---
|
||||
|
||||
### GET /api/system/configuration
|
||||
**Purpose**: Retrieve comprehensive system configuration including app settings, presets, download fields, queue status, and folder structure.
|
||||
**Purpose**: Retrieve system configuration.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"app": {
|
||||
"version": "...",
|
||||
"download_path": "/path/to/downloads",
|
||||
"base_path": "/",
|
||||
...
|
||||
},
|
||||
"presets": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "default",
|
||||
"description": "...",
|
||||
...
|
||||
}
|
||||
],
|
||||
"dl_fields": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Title",
|
||||
"field": "title",
|
||||
"kind": "text",
|
||||
...
|
||||
}
|
||||
],
|
||||
"app": {...},
|
||||
"presets": [...],
|
||||
"dl_fields": [...],
|
||||
"paused": false,
|
||||
"folders": [
|
||||
{"name": "folder1", "path": "folder1"},
|
||||
{"name": "folder2", "path": "folder2"}
|
||||
],
|
||||
"history_count": 150,
|
||||
"queue": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"url": "https://example.com/video",
|
||||
"status": "pending",
|
||||
...
|
||||
}
|
||||
]
|
||||
"history_count": 150
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /api/system/folders
|
||||
**Purpose**: List child directories for a given relative path within the download directory.
|
||||
|
||||
**Query Parameters**:
|
||||
- `path=<relative-path>` (optional, default: root) - Relative path within the download directory.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"path": "videos",
|
||||
"folders": ["archive", "shorts", "2024"]
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- This endpoint combines multiple data sources into a single response for efficient initialization
|
||||
- The `folders` array includes available download folders up to the configured depth limit
|
||||
- The `queue` array contains active download items
|
||||
- Results are cached server-side for a short time.
|
||||
- Non-existent paths return an empty folder list.
|
||||
|
||||
---
|
||||
|
||||
### GET /api/system/diagnostics
|
||||
**Purpose**: View system information.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"generated_at": 1713000000,
|
||||
"summary": {
|
||||
"total": 10,
|
||||
"pass": 5,
|
||||
"fail": 2,
|
||||
"warn": 1,
|
||||
"skip": 2,
|
||||
"required_failed": 2
|
||||
},
|
||||
"runtime": {
|
||||
"app_version": "1.0.0",
|
||||
"app_branch": "main",
|
||||
"app_commit_sha": "abcdef12",
|
||||
"app_build_date": "20260526",
|
||||
"started": 1712999900,
|
||||
"uptime_seconds": 100,
|
||||
"platform": "linux",
|
||||
"platform_release": "6.8.0",
|
||||
"platform_machine": "x86_64",
|
||||
"python_version": "3.13.1",
|
||||
"python_minimum": "3.13",
|
||||
"is_native": false,
|
||||
"console_enabled": false
|
||||
},
|
||||
"requirements": {
|
||||
"python": {
|
||||
"current": "3.13.1",
|
||||
"required": "3.13",
|
||||
"supported": true,
|
||||
"note": ""
|
||||
}
|
||||
},
|
||||
"checks": []
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- Unexpected collection errors are returned as an `error`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -2602,7 +2726,6 @@ or an error:
|
|||
{
|
||||
"downloads": {
|
||||
"paused": false,
|
||||
"live_bypasses_limits": true,
|
||||
"global": {
|
||||
"limit": 20,
|
||||
"active": 3,
|
||||
|
|
@ -3345,7 +3468,7 @@ Emitted when a download item is moved between queue and history.
|
|||
"data": {
|
||||
"id": "abc123",
|
||||
"from": "queue",
|
||||
"to": "done"
|
||||
"to": "history"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
57
FAQ.md
57
FAQ.md
|
|
@ -3,6 +3,9 @@
|
|||
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line,
|
||||
or the `environment:` section in `compose.yaml` file.
|
||||
|
||||
<details>
|
||||
<summary>Click to expand</summary>
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------- | ------------------------------------------------------------------- | --------------------- |
|
||||
| TZ | The timezone to use for the application | `(not_set)` |
|
||||
|
|
@ -44,10 +47,10 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_FLARESOLVERR_CACHE_TTL | The cache TTL (in seconds) for FlareSolverr solutions | `600` |
|
||||
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
|
||||
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
|
||||
| YTP_QUEUE_DISPLAY_LIMIT | Max queued downloads returned to the UI. `0` = unlimited | `100` |
|
||||
| YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` |
|
||||
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
|
||||
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
|
||||
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
|
||||
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
|
||||
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
||||
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
||||
|
|
@ -60,19 +63,20 @@ or the `environment:` section in `compose.yaml` file.
|
|||
| YTP_THUMB_CONCURRENCY | The number of concurrent ffmpeg thumbnail generations allowed. | `2` |
|
||||
| YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` |
|
||||
| YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` |
|
||||
| YTP_DISABLE_EXEC | Strip some dangerous yt-dlp options. | `false` |
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
||||
> The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download.
|
||||
> The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The env variable `YTP_SIMPLE_MODE` only control what being displayed for first time visitor, the users can still switch between the two modes via the WebUI settings page.
|
||||
|
||||
## Notes about YTP_AUTO_CLEAR_HISTORY_DAYS
|
||||
|
||||
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day.
|
||||
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
|
||||
> To raise the worker limit for a specific extractor, set an env variable using this format: `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`
|
||||
> The extractor name must be uppercase. You can find the extractor name in the download logs. This value cannot be
|
||||
> higher than `YTP_MAX_WORKERS`; higher values are ignored.
|
||||
>
|
||||
> `YTP_SIMPLE_MODE=true` only applies when the browser has no saved layout choice yet. Users can still choose a layout in
|
||||
> WebUI Settings. `/?simple=1` forces and saves Simple for that browser.
|
||||
>
|
||||
> `YTP_AUTO_CLEAR_HISTORY_DAYS` `0` days means no automatic clearing of the download history. lowest value that will
|
||||
> trigger the clearing is `1` day. This setting will **NOT** delete the downloaded files, it will only clear the
|
||||
> history from the database.
|
||||
|
||||
# Browser extensions & bookmarklets
|
||||
|
||||
|
|
@ -119,6 +123,35 @@ As this is a simple basic authentication, if your browser doesn't show the promp
|
|||
|
||||
`http://username:password@your_ytptube_url:port`
|
||||
|
||||
# Security recommendations
|
||||
|
||||
YTPTube is designed for LAN and home-lab use behind a firewall or reverse proxy. The web interface and API are
|
||||
unauthenticated by default because in a trusted network, auth adds friction without meaningful benefit. However,
|
||||
if you expose YTPTube to the internet directly or via port forwarding **YOU MUST enable authentication**.
|
||||
|
||||
### Without auth, anyone who can reach the API can:
|
||||
|
||||
- Download arbitrary content through your IP and server.
|
||||
- Delete or modify your downloaded files and database.
|
||||
- Run arbitrary `yt-dlp` options, including `--exec`, which executes shell commands inside the container.
|
||||
|
||||
This is not a vulnerability, it's the intended design. The `cli` field passes options directly to `yt-dlp`,
|
||||
a tool that by design can execute commands. Auth is the mechanism that controls who gets to use that power.
|
||||
|
||||
**If you expose YTPTube to untrusted networks**, do one of the following:
|
||||
|
||||
1. **Enable authentication** — set both `YTP_AUTH_USERNAME` and `YTP_AUTH_PASSWORD`.
|
||||
2. **Put it behind a reverse proxy** with its own authentication layer (see [Run behind reverse proxy](#run-behind-reverse-proxy)).
|
||||
3. **Keep it on a private network** with no public exposure.
|
||||
|
||||
YTPTube already gates other powerful features behind explicit opt-in: the built-in terminal, file browser actions and internal
|
||||
URL requests for example. The `cli` field is no different, its power is by design, and access control is your responsibility.
|
||||
|
||||
> [!NOTE]
|
||||
> If you choose to run without authentication but still want to reduce at least some impact, you can set
|
||||
> `YTP_DISABLE_EXEC=true`. This strips some dangerous options at run time. However, understand that this is not a
|
||||
> substitute for auth an unauthenticated API is still fully open for all other operations.
|
||||
|
||||
# I cant download anything
|
||||
|
||||
If you are receiving errors like:
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -44,20 +44,28 @@ Please read the [FAQ](FAQ.md) for more information.
|
|||
|
||||
# Installation
|
||||
|
||||
> [!IMPORTANT]
|
||||
> By default YTPTube runs without authentication. If you expose it to the internet, **enable auth**. See [security recommendations](FAQ.md#security-recommendations).
|
||||
|
||||
## Run using docker command
|
||||
|
||||
```bash
|
||||
mkdir -p ./{config,downloads/files,downloads/tmp} && docker run -itd --rm --user "${UID}:${UID}" --name ytptube \
|
||||
mkdir -p ./{config,downloads/{files,tmp}} && docker run -itd --rm --user "${UID}:${UID}" --name ytptube \
|
||||
-e YTP_TEMP_PATH=/downloads/tmp -e YTP_DOWNLOAD_PATH=/downloads/files \
|
||||
-p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
|
||||
ghcr.io/arabcoders/ytptube:latest
|
||||
```
|
||||
|
||||
Then you can access the WebUI at `http://localhost:8081`.
|
||||
## Run using podman
|
||||
|
||||
> [!NOTE]
|
||||
> If you are using `podman` instead of `docker`, you can use the same command, but you need to change the user to `0:0`
|
||||
> it will appears to be running as root, but it will run as the user who started the container.
|
||||
```bash
|
||||
mkdir -p ./{config,downloads/{files,tmp}} && podman run -itd --rm --userns=keep-id --name ytptube \
|
||||
-e YTP_TEMP_PATH=/downloads/tmp -e YTP_DOWNLOAD_PATH=/downloads/files \
|
||||
-p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
|
||||
arabcoders/ytptube:latest
|
||||
```
|
||||
|
||||
Then you can access the WebUI at `http://localhost:8081`.
|
||||
|
||||
## Using compose file
|
||||
|
||||
|
|
@ -67,6 +75,8 @@ The following is an example of a `compose.yaml` file that can be used to run YTP
|
|||
services:
|
||||
ytptube:
|
||||
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id.
|
||||
# comment out the above line and uncomment the below line if you are using podman-compose.
|
||||
#userns_mode: keep-id
|
||||
image: ghcr.io/arabcoders/ytptube:latest
|
||||
container_name: ytptube
|
||||
restart: unless-stopped
|
||||
|
|
@ -81,18 +91,14 @@ services:
|
|||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Make sure to change the `user` line to match your user id and group id.
|
||||
> Make sure to change the `user` line to match your user id and group id in docker setups, or use `userns_mode: keep-id` in podman setups.
|
||||
|
||||
```bash
|
||||
mkdir -p ./{config,downloads/files,downloads/tmp} && docker compose -f compose.yaml up -d
|
||||
mkdir -p ./{config,downloads/{files,tmp}} && docker compose -f compose.yaml up -d
|
||||
```
|
||||
|
||||
Then you can access the WebUI at `http://localhost:8081`.
|
||||
|
||||
> [!NOTE]
|
||||
> you can use podman-compose instead of docker-compose, as it supports the same syntax. However, you should change the
|
||||
> user to `0:0` it will appears to be running as root, but it will run as the user who started the container.
|
||||
|
||||
## Unraid
|
||||
|
||||
For `Unraid` users You can install the `Community Applications` plugin, and search for **ytptube** it comes
|
||||
|
|
@ -106,17 +112,14 @@ For simple API documentation, you can refer to the [API documentation](API.md).
|
|||
|
||||
This project is not affiliated with yt-dlp or any other service.
|
||||
|
||||
It’s a personal project designed to make downloading videos from the internet more convenient. It’s not intended for
|
||||
piracy or any unlawful use.
|
||||
This is a personal project designed to make downloading videos from the internet more convenient for me. It is not
|
||||
intended for piracy or any unlawful use. This project was built primarily for my own use and preferences.
|
||||
|
||||
AI-based tools may have been used to assist with parts of this project. Regardless of how a change is produced, every
|
||||
change is reviewed and approved by the human maintainer before it is included.
|
||||
AI-assisted tools are used in this project. If you are uncomfortable with this, you should not use this project.
|
||||
|
||||
This project was built primarily for my own needs and preferences. The UI might not be the most polished or visually
|
||||
refined, but I’m happy with it as it is. You can, however, create and load your own UI for complete customization.
|
||||
|
||||
Contributions are welcome, but I may decline changes that don’t align with my vision for the project. Unsolicited pull
|
||||
requests may be ignored. For suggestions or feature requests, please open a discussion or join the Discord server.
|
||||
Contributions are welcome, but I may decline changes that do not interest me or do not align with my vision for this
|
||||
project. Unsolicited pull requests will be closed. For suggestions or feature requests, please open a discussion or
|
||||
join the Discord server.
|
||||
|
||||
# Social contact
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ def pytest_configure(config) -> None:
|
|||
if getattr(config.option, "basetemp", None) is None:
|
||||
config.option.basetemp = str(get_test_run_root() / "pytest")
|
||||
|
||||
os.environ["YTP_FILE_LOGGING"] = "false"
|
||||
|
||||
|
||||
def pytest_unconfigure(config) -> None:
|
||||
del config
|
||||
os.environ.pop("YTP_FILE_LOGGING", None)
|
||||
cleanup_test_run_root()
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.conditions.models import ConditionModel
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.conditions.repository import ConditionsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
|
|
@ -36,7 +36,11 @@ class Migration(FeatureMigration):
|
|||
try:
|
||||
items: list[dict] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
LOG.exception(
|
||||
"Failed to read conditions migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
|
|
@ -54,7 +58,11 @@ class Migration(FeatureMigration):
|
|||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert condition '%s': %s", normalized.name, exc)
|
||||
LOG.exception(
|
||||
"Failed to insert condition '%s'.",
|
||||
normalized.name,
|
||||
extra={"condition_name": normalized.name, "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s condition(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.conditions.migration import Migration
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
|
||||
from app.features.conditions.models import ConditionModel
|
||||
from app.features.core.deps import get_session
|
||||
from app.library.log import get_logger
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model_from_payload(payload: dict[str, Any]) -> ConditionModel:
|
||||
model = ConditionModel()
|
||||
for key, value in payload.items():
|
||||
if not hasattr(model, key):
|
||||
msg = f"'{key}' is an invalid keyword argument for ConditionModel"
|
||||
raise TypeError(msg)
|
||||
setattr(model, key, value)
|
||||
return model
|
||||
|
||||
|
||||
def _coerce_model(payload: ConditionModel | dict[str, Any]) -> ConditionModel:
|
||||
if isinstance(payload, ConditionModel):
|
||||
return payload
|
||||
return _model_from_payload(payload)
|
||||
|
||||
|
||||
class ConditionsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
|
|
@ -38,7 +57,7 @@ class ConditionsRepository(metaclass=Singleton):
|
|||
def get_instance() -> ConditionsRepository:
|
||||
return ConditionsRepository()
|
||||
|
||||
async def list(self) -> list[ConditionModel]:
|
||||
async def all(self) -> list[ConditionModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[ConditionModel]] = await session.execute(
|
||||
select(ConditionModel).order_by(ConditionModel.priority.desc(), ConditionModel.name.asc())
|
||||
|
|
@ -93,11 +112,11 @@ class ConditionsRepository(metaclass=Singleton):
|
|||
result: Result[tuple[ConditionModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: ConditionModel | dict) -> ConditionModel:
|
||||
async def create(self, payload: ConditionModel | dict[str, Any]) -> ConditionModel:
|
||||
async with self.session() as session:
|
||||
model: ConditionModel = ConditionModel(**payload) if isinstance(payload, dict) else payload
|
||||
model = _coerce_model(payload)
|
||||
if model.id is not None:
|
||||
model.id = None
|
||||
model.id = None # ty: ignore
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Condition with name '{model.name}' already exists."
|
||||
|
|
@ -160,13 +179,11 @@ class ConditionsRepository(metaclass=Singleton):
|
|||
await session.commit()
|
||||
return model
|
||||
|
||||
async def replace_all(self, items: Iterable[dict | ConditionModel]) -> list[ConditionModel]:
|
||||
async def replace_all(self, items: Iterable[dict[str, Any] | ConditionModel]) -> list[ConditionModel]:
|
||||
async with self.session() as session:
|
||||
try:
|
||||
await session.execute(delete(ConditionModel))
|
||||
models: list[ConditionModel] = [
|
||||
ConditionModel(**item) if isinstance(item, dict) else item for item in items
|
||||
]
|
||||
models: list[ConditionModel] = [_coerce_model(item) for item in items]
|
||||
session.add_all(models)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -15,10 +14,11 @@ from app.library.cache import Cache
|
|||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_url
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model(model: Any) -> Condition:
|
||||
|
|
@ -115,18 +115,44 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
|
|||
else:
|
||||
data = cache.get(key)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to extract video info for condition check '%s'.",
|
||||
cond,
|
||||
extra={
|
||||
"route": "conditions.match",
|
||||
"condition": cond,
|
||||
"url": url,
|
||||
"preset": preset,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to extract video info. '{e!s}'"},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Failed to extract video info."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
from app.features.ytdlp.mini_filter import match_str
|
||||
|
||||
status: bool = match_str(cond, data)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to evaluate condition '%s'.",
|
||||
cond,
|
||||
extra={
|
||||
"route": "conditions.match",
|
||||
"condition": cond,
|
||||
"url": url,
|
||||
"preset": preset,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": str(e)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import logging
|
||||
from collections.abc import Iterable
|
||||
from numbers import Number
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.features.conditions.models import ConditionModel
|
||||
from app.features.conditions.repository import ConditionsRepository
|
||||
from app.features.conditions.schemas import Condition
|
||||
from app.features.ytdlp.mini_filter import match_str
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("feature.conditions")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _ignored_identifiers(ignore_conditions: Iterable[str | Number] | None) -> tuple[set[str], bool]:
|
||||
def _ignored_identifiers(ignore_conditions: Iterable[str | int | float] | None) -> tuple[set[str], bool]:
|
||||
ignored: set[str] = set()
|
||||
ignore_all = False
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ def _ignored_identifiers(ignore_conditions: Iterable[str | Number] | None) -> tu
|
|||
return ignored, ignore_all
|
||||
|
||||
for value in ignore_conditions:
|
||||
if isinstance(value, bool) or not isinstance(value, (str, Number)):
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||
continue
|
||||
|
||||
identifier = str(value).strip()
|
||||
|
|
@ -55,7 +55,7 @@ class Conditions(metaclass=Singleton):
|
|||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "ConditionsRepository.run_migrations")
|
||||
|
||||
async def get_all(self) -> list[ConditionModel]:
|
||||
return await self._repo.list()
|
||||
return await self._repo.all()
|
||||
|
||||
async def save(self, item: ConditionModel | dict) -> ConditionModel:
|
||||
"""
|
||||
|
|
@ -80,7 +80,7 @@ class Conditions(metaclass=Singleton):
|
|||
if item.id is None or 0 == item.id:
|
||||
model = await repo.create(item)
|
||||
else:
|
||||
model = await repo.update(item.id, item.serialize())
|
||||
model = await repo.update(item.id, Condition.model_validate(item).model_dump())
|
||||
except KeyError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
|
|
@ -113,13 +113,15 @@ class Conditions(metaclass=Singleton):
|
|||
repo = self._repo
|
||||
return await repo.get(identifier)
|
||||
|
||||
async def match(self, info: dict, ignore_conditions: Iterable[str | Number] | None = None) -> ConditionModel | None:
|
||||
async def match(
|
||||
self, info: dict, ignore_conditions: Iterable[str | int | float] | None = None
|
||||
) -> ConditionModel | None:
|
||||
"""
|
||||
Check if any condition matches the info dict.
|
||||
|
||||
Args:
|
||||
info (dict): The info dict to check.
|
||||
ignore_conditions (Iterable[str | Number] | None): Condition ids or names to skip for this match.
|
||||
ignore_conditions (Iterable[str | int | float] | None): Condition ids or names to skip for this match.
|
||||
|
||||
Returns:
|
||||
Condition|None: The condition if found, None otherwise.
|
||||
|
|
@ -133,7 +135,7 @@ class Conditions(metaclass=Singleton):
|
|||
return None
|
||||
|
||||
repo = self._repo
|
||||
items: list[ConditionModel] = await repo.list()
|
||||
items: list[ConditionModel] = await repo.all()
|
||||
if len(items) < 1:
|
||||
return None
|
||||
|
||||
|
|
@ -145,17 +147,29 @@ class Conditions(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
if not item.filter:
|
||||
LOG.error(f"Filter is empty for '{item.name}'.")
|
||||
LOG.error(
|
||||
"Filter is empty for '%s'.", item.name, extra={"condition_id": item.id, "condition_name": item.name}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if not match_str(item.filter, info):
|
||||
continue
|
||||
|
||||
LOG.debug(f"Matched '{item.id}: {item.name}' with filter '{item.filter}'.")
|
||||
LOG.debug(
|
||||
"Matched '%s: %s' with filter '%s'.",
|
||||
item.id,
|
||||
item.name,
|
||||
item.filter,
|
||||
extra={"condition_id": item.id, "condition_name": item.name, "filter": item.filter},
|
||||
)
|
||||
return item
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to evaluate '{item.id}: {item.name}'. '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to evaluate condition '%s'.",
|
||||
item.name,
|
||||
extra={"condition_id": item.id, "condition_name": item.name, "exception_type": type(e).__name__},
|
||||
)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
|
@ -175,7 +189,7 @@ class Conditions(metaclass=Singleton):
|
|||
if not info or not isinstance(info, dict) or len(info) < 1:
|
||||
return None
|
||||
|
||||
if not (item := await self.get(identifier)) or not item.enabled or not item.filter:
|
||||
if not (item := await self.get(str(identifier))) or not item.enabled or not item.filter:
|
||||
return None
|
||||
|
||||
return item if match_str(item.filter, info) else None
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
import pytest_asyncio
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from typing import Any
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
|
|
@ -39,13 +40,13 @@ async def repo():
|
|||
|
||||
|
||||
def _json_request(path: str, payload: object) -> web.Request:
|
||||
request = make_mocked_request("POST", path)
|
||||
mock_request: Any = make_mocked_request("POST", path)
|
||||
|
||||
async def _json() -> object:
|
||||
return payload
|
||||
|
||||
request.json = _json # type: ignore[attr-defined]
|
||||
return request
|
||||
mock_request.json = _json
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestAllowInternalUrlsScope:
|
||||
|
|
@ -208,7 +209,7 @@ class TestConditionsRepository:
|
|||
await repo.create({"name": "A", "filter": "test", "priority": 1})
|
||||
await repo.create({"name": "C", "filter": "test", "priority": 2})
|
||||
|
||||
items = await repo.list()
|
||||
items = await repo.all()
|
||||
|
||||
assert items[0].name == "C", "Highest priority should be first"
|
||||
assert items[1].name == "A", "Same priority sorted alphabetically"
|
||||
|
|
@ -240,6 +241,6 @@ class TestConditionsRepository:
|
|||
|
||||
assert len(result) == 2, "Should create 2 new conditions"
|
||||
|
||||
all_items = await repo.list()
|
||||
all_items = await repo.all()
|
||||
assert len(all_items) == 2, "Should only have new conditions"
|
||||
assert all_items[0].name in ["New 1", "New 2"], "Should only have new items"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.library.config import Config
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(abc.ABC):
|
||||
|
|
@ -26,7 +27,11 @@ class Migration(abc.ABC):
|
|||
try:
|
||||
await self.migrate()
|
||||
except Exception as exc:
|
||||
LOG.exception("Feature migration '%s' failed: %s", self.name, exc)
|
||||
LOG.exception(
|
||||
"Feature migration '%s' failed.",
|
||||
self.name,
|
||||
extra={"feature": self.name, "exception_type": type(exc).__name__},
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime as SQLADateTime
|
||||
from sqlalchemy import TypeDecorator
|
||||
from sqlalchemy import Dialect, TypeDecorator
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
|
|
@ -20,16 +20,18 @@ class UTCDateTime(TypeDecorator):
|
|||
impl = SQLADateTime
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: datetime | None, _dialect) -> datetime | None:
|
||||
def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None:
|
||||
"""Convert datetime to UTC before storing."""
|
||||
_ = dialect
|
||||
if value is not None:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value: datetime | None, _dialect) -> datetime | None:
|
||||
def process_result_value(self, value: datetime | None, dialect: Dialect) -> datetime | None:
|
||||
"""Ensure datetime is timezone-aware (UTC) when loading."""
|
||||
_ = dialect
|
||||
if value is not None and value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.dl_fields.schemas import DLField
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
|
|
@ -36,7 +36,11 @@ class Migration(FeatureMigration):
|
|||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
LOG.exception(
|
||||
"Failed to read download fields migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
|
|
@ -54,7 +58,11 @@ class Migration(FeatureMigration):
|
|||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert dl field '%s': %s", normalized["name"], exc)
|
||||
LOG.exception(
|
||||
"Failed to insert download field '%s'.",
|
||||
normalized["name"],
|
||||
extra={"field_name": normalized["name"], "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s dl field(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
|
|
|||
|
|
@ -1,30 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.dl_fields.migration import Migration
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model_from_payload(payload: dict[str, Any]) -> DLFieldModel:
|
||||
model = DLFieldModel()
|
||||
for key, value in payload.items():
|
||||
if not hasattr(model, key):
|
||||
msg = f"'{key}' is an invalid keyword argument for DLFieldModel"
|
||||
raise TypeError(msg)
|
||||
setattr(model, key, value)
|
||||
return model
|
||||
|
||||
|
||||
def _coerce_model(payload: DLFieldModel | dict[str, Any]) -> DLFieldModel:
|
||||
if isinstance(payload, DLFieldModel):
|
||||
return payload
|
||||
return _model_from_payload(payload)
|
||||
|
||||
|
||||
class DLFieldsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
|
|
@ -37,7 +56,7 @@ class DLFieldsRepository(metaclass=Singleton):
|
|||
def get_instance() -> DLFieldsRepository:
|
||||
return DLFieldsRepository()
|
||||
|
||||
async def list(self) -> list[DLFieldModel]:
|
||||
async def all(self) -> list[DLFieldModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(
|
||||
select(DLFieldModel).order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
|
||||
|
|
@ -90,9 +109,9 @@ class DLFieldsRepository(metaclass=Singleton):
|
|||
result: Result[tuple[DLFieldModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: DLFieldModel | dict) -> DLFieldModel:
|
||||
async def create(self, payload: DLFieldModel | dict[str, Any]) -> DLFieldModel:
|
||||
async with self.session() as session:
|
||||
model: DLFieldModel = DLFieldModel(**payload) if isinstance(payload, dict) else payload
|
||||
model = _coerce_model(payload)
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"DL field with name '{model.name}' already exists."
|
||||
|
|
@ -153,13 +172,16 @@ class DLFieldsRepository(metaclass=Singleton):
|
|||
await session.commit()
|
||||
return model
|
||||
|
||||
async def replace_all(self, items: Iterable[dict | DLFieldModel]) -> list[DLFieldModel]:
|
||||
async def replace_all(self, items: Iterable[dict[str, Any] | DLFieldModel]) -> list[DLFieldModel]:
|
||||
async with self.session() as session:
|
||||
try:
|
||||
await session.execute(delete(DLFieldModel))
|
||||
models: list[DLFieldModel] = [
|
||||
DLFieldModel(**item) if isinstance(item, dict) else item for item in items
|
||||
]
|
||||
models: list[DLFieldModel] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
models.append(DLFieldModel(**cast("dict[str, Any]", item)))
|
||||
else:
|
||||
models.append(item)
|
||||
session.add_all(models)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -11,9 +10,10 @@ from app.features.dl_fields.schemas import DLField, DLFieldList, DLFieldPatch
|
|||
from app.features.dl_fields.service import DLFields
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model(model: Any) -> DLField:
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.features.dl_fields.schemas import DLField
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aiohttp import web
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("feature.dl_fields")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class DLFields(metaclass=Singleton):
|
||||
|
|
@ -33,10 +33,10 @@ class DLFields(metaclass=Singleton):
|
|||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "DLFieldsRepository.run_migrations")
|
||||
|
||||
async def get_all(self) -> list[DLFieldModel]:
|
||||
return await self._repo.list()
|
||||
return await self._repo.all()
|
||||
|
||||
async def get_all_serialized(self) -> list[dict[str, Any]]:
|
||||
items = await self._repo.list()
|
||||
items = await self._repo.all()
|
||||
return [DLField.model_validate(item).model_dump() for item in items]
|
||||
|
||||
async def save(self, item: DLField | dict) -> DLFieldModel:
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ class TestDLFieldsRepository:
|
|||
await repo.create({"name": "a", "description": "a", "field": "--a", "kind": "text", "order": 1})
|
||||
await repo.create({"name": "c", "description": "c", "field": "--c", "kind": "text", "order": 0})
|
||||
|
||||
items = await repo.list()
|
||||
items = await repo.all()
|
||||
|
||||
assert items[0].name == "c", "Lowest order should be first"
|
||||
assert items[1].name == "a", "Same order sorted alphabetically"
|
||||
|
|
@ -243,6 +243,6 @@ class TestDLFieldsRepository:
|
|||
|
||||
assert len(result) == 2, "Should create 2 new fields"
|
||||
|
||||
all_items = await repo.list()
|
||||
all_items = await repo.all()
|
||||
assert len(all_items) == 2, "Should only have new fields"
|
||||
assert {item.name for item in all_items} == {"new_1", "new_2"}, "Should only have new items"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
|
|
@ -9,11 +8,12 @@ from app.features.core.migration import Migration as FeatureMigration
|
|||
from app.features.notifications.schemas import NotificationEvents
|
||||
from app.features.presets.service import Presets
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
|
|
@ -37,7 +37,11 @@ class Migration(FeatureMigration):
|
|||
try:
|
||||
items: list[dict] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
LOG.exception(
|
||||
"Failed to read notifications migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
|
|
@ -56,7 +60,11 @@ class Migration(FeatureMigration):
|
|||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert notification '%s': %s", normalized.get("name"), exc)
|
||||
LOG.exception(
|
||||
"Failed to insert notification target '%s'.",
|
||||
normalized.get("name"),
|
||||
extra={"target_name": normalized.get("name"), "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s notification target(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
|
@ -8,23 +7,43 @@ from sqlalchemy import func, or_, select
|
|||
from app.features.core.deps import get_session
|
||||
from app.features.notifications.migration import Migration
|
||||
from app.features.notifications.models import NotificationModel
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model_from_payload(payload: dict[str, Any]) -> NotificationModel:
|
||||
model = NotificationModel()
|
||||
for key, value in payload.items():
|
||||
if not hasattr(model, key):
|
||||
msg = f"'{key}' is an invalid keyword argument for NotificationModel"
|
||||
raise TypeError(msg)
|
||||
setattr(model, key, value)
|
||||
return model
|
||||
|
||||
|
||||
def _coerce_model(payload: NotificationModel | dict[str, Any]) -> NotificationModel:
|
||||
if isinstance(payload, NotificationModel):
|
||||
return payload
|
||||
return _model_from_payload(payload)
|
||||
|
||||
|
||||
class NotificationsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
|
|
@ -37,7 +56,7 @@ class NotificationsRepository(metaclass=Singleton):
|
|||
def get_instance() -> NotificationsRepository:
|
||||
return NotificationsRepository()
|
||||
|
||||
async def list(self) -> list[NotificationModel]:
|
||||
async def all(self) -> list[NotificationModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).order_by(NotificationModel.name.asc())
|
||||
|
|
@ -92,11 +111,11 @@ class NotificationsRepository(metaclass=Singleton):
|
|||
result: Result[tuple[NotificationModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: NotificationModel | dict) -> NotificationModel:
|
||||
async def create(self, payload: NotificationModel | dict[str, Any]) -> NotificationModel:
|
||||
async with self.session() as session:
|
||||
model: NotificationModel = NotificationModel(**payload) if isinstance(payload, dict) else payload
|
||||
model = _coerce_model(payload)
|
||||
if model.id is not None:
|
||||
model.id = None
|
||||
model.id = None # ty: ignore
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Notification target with name '{model.name}' already exists."
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -11,9 +10,10 @@ from app.features.notifications.schemas import Notification, NotificationEvents,
|
|||
from app.features.notifications.service import Notifications
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model(model: Any) -> Notification:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.notifications.models import NotificationModel
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
|
|
@ -23,6 +21,7 @@ from app.library.encoder import Encoder
|
|||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.httpx_client import async_client
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -31,7 +30,7 @@ if TYPE_CHECKING:
|
|||
import httpx
|
||||
from aiohttp import web
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("feature.notifications")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Notifications(metaclass=Singleton):
|
||||
|
|
@ -65,10 +64,10 @@ class Notifications(metaclass=Singleton):
|
|||
await self._repo.run_migrations()
|
||||
|
||||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "NotificationsRepository.run_migrations")
|
||||
EventBus.get_instance().subscribe(NotificationEvents.events(), self.emit, f"{__class__.__name__}.emit")
|
||||
EventBus.get_instance().subscribe(NotificationEvents.events(), self.emit, f"{type(self).__name__}.emit")
|
||||
|
||||
async def list(self) -> list[NotificationModel]:
|
||||
return await self._repo.list()
|
||||
async def all(self) -> list[NotificationModel]:
|
||||
return await self._repo.all()
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[NotificationModel], int, int, int]:
|
||||
return await self._repo.list_paginated(page, per_page)
|
||||
|
|
@ -93,8 +92,8 @@ class Notifications(metaclass=Singleton):
|
|||
async def delete(self, identifier: int | str) -> NotificationModel:
|
||||
return await self._repo.delete(identifier)
|
||||
|
||||
async def send(self, ev: Event, wait: bool = True) -> list[dict] | list[Awaitable[dict]]:
|
||||
targets = await self._repo.list()
|
||||
async def send(self, ev: Event, wait: bool = True) -> list[dict | Awaitable[dict]]:
|
||||
targets = await self._repo.all()
|
||||
if len(targets) < 1:
|
||||
return []
|
||||
|
||||
|
|
@ -127,7 +126,7 @@ class Notifications(metaclass=Singleton):
|
|||
if wait:
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
return tasks
|
||||
return cast("list[dict | Awaitable[dict]]", tasks)
|
||||
|
||||
def emit(self, e: Event, _, **__) -> None:
|
||||
if not NotificationEvents.is_valid(e.event):
|
||||
|
|
@ -272,14 +271,27 @@ class Notifications(metaclass=Singleton):
|
|||
|
||||
if not status:
|
||||
msg = "Apprise failed to send notification."
|
||||
raise RuntimeError(msg) # noqa: TRY301
|
||||
self._raise_apprise_error(msg)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.error("Error sending Apprise notification: %s", exc)
|
||||
LOG.exception(
|
||||
"Failed to send Apprise notification for event '%s'.",
|
||||
ev.event,
|
||||
extra={
|
||||
"event_id": ev.id,
|
||||
"event": ev.event,
|
||||
"target_count": len(targets),
|
||||
"targets": [t.name for t in targets],
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return {"error": str(exc), "event": ev.event, "id": ev.id, "targets": [t.name for t in targets]}
|
||||
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _raise_apprise_error(msg: str) -> None:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
async def _send(self, target: Notification, ev: Event) -> dict:
|
||||
try:
|
||||
LOG.info("Sending notification event '%s: %s' to '%s'.", ev.event, ev.id, target.name)
|
||||
|
|
@ -335,14 +347,17 @@ class Notifications(metaclass=Singleton):
|
|||
|
||||
return resp_data
|
||||
except Exception as exc:
|
||||
err_msg = str(exc) or type(exc).__name__
|
||||
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
LOG.error(
|
||||
"Error sending notification event '%s: %s' to '%s'. '%s'. %s",
|
||||
LOG.exception(
|
||||
"Failed to send notification event '%s: %s' to '%s'.",
|
||||
ev.event,
|
||||
ev.id,
|
||||
target.name,
|
||||
err_msg,
|
||||
tb,
|
||||
extra={
|
||||
"event_id": ev.id,
|
||||
"event": ev.event,
|
||||
"target_name": target.name,
|
||||
"url": target.request.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return {"url": target.request.url, "status": 500, "text": str(ev)}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class TestNotificationsRepository:
|
|||
@pytest.mark.asyncio
|
||||
async def test_list_empty(self, repo):
|
||||
"""List returns empty when no notifications exist."""
|
||||
notifications = await repo.list()
|
||||
notifications = await repo.all()
|
||||
assert notifications == [], "Should return empty list when no notifications exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
|
@ -9,11 +8,12 @@ from app.features.core.migration import Migration as FeatureMigration
|
|||
from app.features.presets.schemas import Preset
|
||||
from app.features.presets.utils import preset_name
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
|
|
@ -32,7 +32,11 @@ class Migration(FeatureMigration):
|
|||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
LOG.exception(
|
||||
"Failed to read presets migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
|
|
@ -42,7 +46,7 @@ class Migration(FeatureMigration):
|
|||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {preset_name(preset.name): 1 for preset in await self._repo.list()}
|
||||
seen_names: dict[str, int] = {preset_name(preset.name): 1 for preset in await self._repo.all()}
|
||||
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := self._normalize(item, index, seen_names)):
|
||||
|
|
@ -51,7 +55,11 @@ class Migration(FeatureMigration):
|
|||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert preset '%s': %s", normalized["name"], exc)
|
||||
LOG.exception(
|
||||
"Failed to insert preset '%s'.",
|
||||
normalized["name"],
|
||||
extra={"preset": normalized["name"], "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s preset(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
|
@ -12,6 +11,7 @@ from app.features.presets.models import PresetModel
|
|||
from app.features.presets.utils import preset_name, seed_defaults
|
||||
from app.library.config import Config
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
|
|
@ -27,7 +27,31 @@ if TYPE_CHECKING:
|
|||
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _payload_data(payload: PresetModel | dict[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(payload, dict):
|
||||
data: dict[str, Any] = {}
|
||||
for key, value in payload.items():
|
||||
if not isinstance(key, str):
|
||||
msg = "Preset payload keys must be strings."
|
||||
raise TypeError(msg)
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
return {
|
||||
"name": payload.name,
|
||||
"description": payload.description,
|
||||
"folder": payload.folder,
|
||||
"template": payload.template,
|
||||
"cookies": payload.cookies,
|
||||
"cli": payload.cli,
|
||||
"default": payload.default,
|
||||
"priority": payload.priority,
|
||||
"created_at": payload.created_at,
|
||||
"updated_at": payload.updated_at,
|
||||
}
|
||||
|
||||
|
||||
class PresetsRepository(metaclass=Singleton):
|
||||
|
|
@ -73,15 +97,15 @@ class PresetsRepository(metaclass=Singleton):
|
|||
LOG.debug("Refreshing presets cache due to configuration update.")
|
||||
await self._update_cache()
|
||||
|
||||
Services.get_instance().add(__class__.__name__, self)
|
||||
Services.get_instance().add(PresetsRepository.__name__, self)
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.STARTED, handle_event, f"{__class__.__name__}.run_migrations"
|
||||
Events.STARTED, handle_event, f"{PresetsRepository.__name__}.run_migrations"
|
||||
).subscribe(Events.CONFIG_UPDATE, handler, "Presets.refresh_cache")
|
||||
|
||||
async def _update_cache(self) -> None:
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
await Presets.get_instance().refresh_cache(await self.list())
|
||||
await Presets.get_instance().refresh_cache(await self.all())
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> PresetsRepository:
|
||||
|
|
@ -97,7 +121,7 @@ class PresetsRepository(metaclass=Singleton):
|
|||
LOG.error("Default preset '%s' not found, using 'default' preset.", default_name)
|
||||
config.default_preset = "default"
|
||||
|
||||
async def list(self) -> list[PresetModel]:
|
||||
async def all(self) -> list[PresetModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[PresetModel]] = await session.execute(
|
||||
select(PresetModel).order_by(PresetModel.priority.desc(), PresetModel.name.asc())
|
||||
|
|
@ -238,24 +262,9 @@ class PresetsRepository(metaclass=Singleton):
|
|||
result: Result[tuple[PresetModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: PresetModel | dict) -> PresetModel:
|
||||
async def create(self, payload: PresetModel | dict[str, Any]) -> PresetModel:
|
||||
async with self.session() as session:
|
||||
data: dict[str, Any]
|
||||
if isinstance(payload, dict):
|
||||
data = dict(payload)
|
||||
else:
|
||||
data = {
|
||||
"name": payload.name,
|
||||
"description": payload.description,
|
||||
"folder": payload.folder,
|
||||
"template": payload.template,
|
||||
"cookies": payload.cookies,
|
||||
"cli": payload.cli,
|
||||
"default": payload.default,
|
||||
"priority": payload.priority,
|
||||
"created_at": payload.created_at,
|
||||
"updated_at": payload.updated_at,
|
||||
}
|
||||
data = _payload_data(payload)
|
||||
|
||||
data.pop("id", None)
|
||||
model = PresetModel(**data)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class Presets(metaclass=Singleton):
|
|||
def __init__(self, repo: PresetsRepository | None = None) -> None:
|
||||
self._repo: PresetsRepository = repo or PresetsRepository.get_instance()
|
||||
self._cache: list[tuple[int, str, Preset]] = []
|
||||
Services.get_instance().add(__class__.__name__, self)
|
||||
Services.get_instance().add(type(self).__name__, self)
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> Presets:
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TestPresetsRepository:
|
|||
await repo.create({"name": "A", "priority": 1})
|
||||
await repo.create({"name": "C", "priority": 2})
|
||||
|
||||
items = await repo.list()
|
||||
items = await repo.all()
|
||||
|
||||
assert items[0].name == "c", "Highest priority should be first"
|
||||
assert items[1].name == "a", "Same priority should sort by name"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
NAME_WHITESPACE_PATTERN = re.compile(r"\s+")
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
async def seed_defaults(repo) -> None:
|
||||
|
|
@ -48,7 +49,11 @@ async def seed_defaults(repo) -> None:
|
|||
|
||||
await repo.update(existing.id, payload)
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to seed default preset '%s': %s", preset.get("name"), exc)
|
||||
LOG.exception(
|
||||
"Failed to seed default preset '%s'.",
|
||||
preset.get("name"),
|
||||
extra={"preset": preset.get("name"), "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
|
||||
def preset_name(value: str) -> str:
|
||||
|
|
|
|||
|
|
@ -5,18 +5,16 @@ Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
|
|||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
import os
|
||||
import subprocess # qa: ignore
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from app.features.streaming.types import FFProbeError
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import timed_lru_cache
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("streaming.ffprobe")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class FFStream:
|
||||
|
|
@ -39,21 +37,26 @@ class FFStream:
|
|||
|
||||
def __repr__(self):
|
||||
if "codec_long_name" not in self.__dict__:
|
||||
self.codec_long_name = self.__dict__.get("codec_name", "")
|
||||
self.__dict__["codec_long_name"] = self.__dict__.get("codec_name", "")
|
||||
|
||||
index = self.__dict__.get("index", "?")
|
||||
codec_type = self.__dict__.get("codec_type", "unknown")
|
||||
codec_long_name = self.__dict__.get("codec_long_name", "")
|
||||
|
||||
if self.is_video():
|
||||
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, {self.framerate}, ({self.width}x{self.height})>"
|
||||
return f"<Stream: #{index} [{codec_type}] {codec_long_name}, {self.__dict__.get('framerate')}, ({self.__dict__.get('width')}x{self.__dict__.get('height')})>"
|
||||
|
||||
if self.is_audio():
|
||||
return (
|
||||
f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, channels: {self.channels} ({self.channel_layout}), "
|
||||
"{sample_rate}Hz> "
|
||||
f"<Stream: #{index} [{codec_type}] {codec_long_name}, "
|
||||
f"channels: {self.__dict__.get('channels')} ({self.__dict__.get('channel_layout')}), "
|
||||
f"{self.__dict__.get('sample_rate')}Hz> "
|
||||
)
|
||||
|
||||
if self.is_subtitle() or self.is_attachment():
|
||||
return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}>"
|
||||
return f"<Stream: #{index} [{codec_type}] {codec_long_name}>"
|
||||
|
||||
return f"<Stream: #{self.index} [{self.codec_type}]>"
|
||||
return f"<Stream: #{index} [{codec_type}]>"
|
||||
|
||||
def is_audio(self):
|
||||
"""
|
||||
|
|
@ -252,14 +255,14 @@ async def ffprobe(file: Path | str) -> FFProbeResult:
|
|||
raise OSError(msg)
|
||||
|
||||
try:
|
||||
async with await anyio.open_file(os.devnull, "w") as tempf:
|
||||
await asyncio.create_subprocess_exec(
|
||||
"ffprobe",
|
||||
"-h",
|
||||
stdout=tempf,
|
||||
stderr=tempf,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe",
|
||||
"-h",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
await proc.wait()
|
||||
except FileNotFoundError as e:
|
||||
msg = "ffprobe not found."
|
||||
raise OSError(msg) from e
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ class M3u8:
|
|||
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
|
||||
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
||||
|
||||
segmentSize: float = f"{self.duration:.6f}"
|
||||
segmentSize: str = f"{self.duration:.6f}"
|
||||
splits: int = math.ceil(duration / self.duration)
|
||||
|
||||
segmentParams: dict = {}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class Playlist:
|
|||
"The path where files are downloaded."
|
||||
|
||||
async def make(self, file: Path) -> str:
|
||||
ref: str = Path(str(file.relative_to(self.download_path)).strip("/"))
|
||||
ref: Path = Path(str(file.relative_to(self.download_path)).strip("/"))
|
||||
|
||||
try:
|
||||
ff = await ffprobe(file)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# flake8: noqa: ARG002
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
|
|
@ -16,9 +16,13 @@ def detect_qsv_capabilities() -> dict[str, dict[str, bool]]:
|
|||
Returns a dict where keys are codec names (e.g. "h264", "hevc", "vp9") and
|
||||
values are dicts with keys "full" and "lp" booleans.
|
||||
"""
|
||||
vainfo = shutil.which("vainfo")
|
||||
if not vainfo:
|
||||
return {}
|
||||
|
||||
try:
|
||||
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
["vainfo"], # noqa: S607
|
||||
[vainfo],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
|
|
@ -87,9 +91,13 @@ def ffmpeg_encoders() -> set[str]:
|
|||
"""
|
||||
from app.library.config import SUPPORTED_CODECS
|
||||
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
return set()
|
||||
|
||||
try:
|
||||
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], # noqa: S607
|
||||
[ffmpeg, "-hide_banner", "-loglevel", "error", "-encoders"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
|
|
@ -149,9 +157,11 @@ class _BaseBuilder:
|
|||
codec_name: str
|
||||
|
||||
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
_ = ctx
|
||||
return []
|
||||
|
||||
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
|
||||
_ = ctx
|
||||
return [*args, "-codec:v", self.codec_name]
|
||||
|
||||
|
||||
|
|
@ -312,4 +322,4 @@ def encoder_fallback_chain(codec: str) -> tuple[str, ...]:
|
|||
"libx264": [],
|
||||
}
|
||||
|
||||
return chains.get(codec, chains["libx264"])
|
||||
return tuple(chains.get(codec, chains["libx264"]))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess # type: ignore
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
|
@ -19,6 +18,7 @@ from app.features.streaming.library.segment_encoders import (
|
|||
select_encoder,
|
||||
)
|
||||
from app.library.config import SUPPORTED_CODECS, Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio.subprocess import Process
|
||||
|
|
@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
|||
from .ffprobe import FFProbeResult
|
||||
from .segment_encoders import EncoderBuilder
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("player.segments")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Segments:
|
||||
|
|
@ -170,9 +170,15 @@ class Segments:
|
|||
|
||||
stderr_task: asyncio.Task[None] = asyncio.create_task(_drain_stderr())
|
||||
|
||||
LOG.debug(f"Streaming '{file}' segment '{self.index}'. ffmpeg: {' '.join(args)}")
|
||||
LOG.debug(
|
||||
"Streaming segment %s for '%s'.",
|
||||
self.index,
|
||||
file,
|
||||
extra={"file": str(file), "segment_index": self.index, "ffmpeg_args": args},
|
||||
)
|
||||
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
while True:
|
||||
chunk: bytes = await proc.stdout.read(1024 * 64)
|
||||
if not chunk:
|
||||
|
|
@ -191,7 +197,10 @@ class Segments:
|
|||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process did not terminate in time. Killing it.")
|
||||
LOG.warning(
|
||||
"Segment stream for '%s' did not stop ffmpeg in time after the client disconnected; killing it.",
|
||||
file,
|
||||
)
|
||||
proc.kill()
|
||||
raise
|
||||
except ConnectionResetError:
|
||||
|
|
@ -204,7 +213,10 @@ class Segments:
|
|||
try:
|
||||
rc: int = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process did not terminate in time after disconnect. Killing it.")
|
||||
LOG.warning(
|
||||
"Segment stream for '%s' did not stop ffmpeg in time after the client disconnected; killing it.",
|
||||
file,
|
||||
)
|
||||
proc.kill()
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
|
|
@ -215,7 +227,7 @@ class Segments:
|
|||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=30)
|
||||
except TimeoutError:
|
||||
LOG.error("ffmpeg process wait timed out. Killing it.")
|
||||
LOG.warning("Segment stream for '%s' did not stop ffmpeg in time; killing the process.", file)
|
||||
proc.kill()
|
||||
try:
|
||||
rc = await asyncio.wait_for(proc.wait(), timeout=5)
|
||||
|
|
@ -229,11 +241,13 @@ class Segments:
|
|||
return (wrote_any, rc, client_disconnected_local, stderr_buf.decode("utf-8", errors="ignore").strip())
|
||||
|
||||
async def stream(self, file: Path, resp: web.StreamResponse) -> None:
|
||||
codec: str = Segments._cached_vcodec if Segments._cache_initialized else self.vcodec
|
||||
codec: str = self.vcodec
|
||||
if Segments._cache_initialized and Segments._cached_vcodec:
|
||||
codec = Segments._cached_vcodec
|
||||
if not codec or codec not in SUPPORTED_CODECS:
|
||||
codec: str = select_encoder(self.vcodec or "")
|
||||
|
||||
LOG.debug(f"Selected video codec '{codec}' for segment streaming.")
|
||||
LOG.debug("Selected video codec '%s' for segment streaming.", codec, extra={"codec": codec})
|
||||
|
||||
if Segments._cached_vcodec and Segments._cache_initialized:
|
||||
codecs: list[str] = [Segments._cached_vcodec]
|
||||
|
|
@ -259,7 +273,14 @@ class Segments:
|
|||
|
||||
if 0 != rc:
|
||||
err: str = stderr_text[:500] if stderr_text else "no error output"
|
||||
LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.")
|
||||
LOG.warning(
|
||||
"Retrying segment %s for '%s' because hardware encoder '%s' failed: %s.",
|
||||
self.index,
|
||||
file,
|
||||
s_codec,
|
||||
err,
|
||||
extra={"ffmpeg_args": ffmpeg_args, "returncode": rc, "stderr": err, "codec": s_codec},
|
||||
)
|
||||
self.attempted.add(s_codec)
|
||||
finally:
|
||||
if stream_input.is_symlink():
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import pysubs2
|
||||
from pysubs2.formats.substation import SubstationFormat
|
||||
from pysubs2.time import ms_to_times
|
||||
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import ALLOWED_SUBS_EXTENSIONS, get_file_sidecar
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("player.subtitle")
|
||||
LOG = get_logger()
|
||||
|
||||
SOURCE_FORMATS: tuple[str, ...] = ("vtt", "srt", "ass")
|
||||
DELIVERY_FORMATS: dict[str, str] = {
|
||||
|
|
@ -48,7 +49,8 @@ def ms_to_timestamp(ms: int) -> str:
|
|||
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
|
||||
|
||||
|
||||
SubstationFormat.ms_to_timestamp = ms_to_timestamp
|
||||
_substation_format: Any = SubstationFormat
|
||||
_substation_format.ms_to_timestamp = ms_to_timestamp
|
||||
|
||||
|
||||
class Subtitle:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
|
@ -9,9 +8,10 @@ from pathlib import Path
|
|||
from app.features.streaming.library.ffprobe import ffprobe
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import FILES_TYPE, get_file_sidecar
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("player.thumbnail")
|
||||
LOG = get_logger()
|
||||
|
||||
IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp")
|
||||
FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart")
|
||||
|
|
@ -24,6 +24,8 @@ THUMBNAIL_MISS_TTL = 3600.0
|
|||
|
||||
_LOCK = asyncio.Lock()
|
||||
_IN_PROCESS: dict[str, asyncio.Task[Path | None]] = {}
|
||||
|
||||
|
||||
_SEM: asyncio.Semaphore | None = None
|
||||
_SEM_LIMIT: int | None = None
|
||||
|
||||
|
|
@ -35,7 +37,7 @@ def _get_semaphore() -> asyncio.Semaphore:
|
|||
if _SEM is None or _SEM_LIMIT != limit:
|
||||
_SEM = asyncio.Semaphore(limit)
|
||||
_SEM_LIMIT = limit
|
||||
LOG.info(f"Configured thumbnail generation concurrency limit: {limit}")
|
||||
LOG.info("Configured thumbnail generation to run %s job(s) at a time.", limit, extra={"limit": limit})
|
||||
|
||||
return _SEM
|
||||
|
||||
|
|
@ -129,7 +131,11 @@ def _build_ffmpeg_args(media_file: Path, output_file: Path, *, seek_seconds: flo
|
|||
async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
|
||||
ff_info = await ffprobe(media_file)
|
||||
if not ff_info.has_video():
|
||||
LOG.debug(f"Skipping thumbnail generation for '{media_file}' because no video stream exists.")
|
||||
LOG.debug(
|
||||
"Skipping thumbnail generation for '%s' because no video stream exists.",
|
||||
media_file,
|
||||
extra={"media_file": str(media_file)},
|
||||
)
|
||||
return None
|
||||
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -146,7 +152,12 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
|
|||
sem = _get_semaphore()
|
||||
if sem.locked():
|
||||
limit = _SEM_LIMIT or 1
|
||||
LOG.debug(f"Waiting for thumbnail generation slot for '{media_file}'. limit={limit}")
|
||||
LOG.debug(
|
||||
"Waiting for a thumbnail generation slot for '%s' (limit=%s).",
|
||||
media_file,
|
||||
limit,
|
||||
extra={"media_file": str(media_file), "limit": limit},
|
||||
)
|
||||
|
||||
async with sem:
|
||||
last_error: str = "ffmpeg produced an empty thumbnail file"
|
||||
|
|
@ -154,7 +165,17 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
|
|||
temp_file.unlink(missing_ok=True)
|
||||
args: list[str] = _build_ffmpeg_args(media_file, temp_file, seek_seconds=attempt_seek)
|
||||
LOG.debug(
|
||||
f"Generating thumbnail for '{media_file}'. attempt={idx}/{len(attempts)} seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}s'}"
|
||||
"Generating thumbnail for '%s'. attempt=%s/%s seek=%s",
|
||||
media_file,
|
||||
idx,
|
||||
len(attempts),
|
||||
"none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
extra={
|
||||
"media_file": str(media_file),
|
||||
"attempt": idx,
|
||||
"attempt_count": len(attempts),
|
||||
"seek": "none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -171,7 +192,12 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
|
|||
stdout, stderr = await proc.communicate()
|
||||
if 0 == proc.returncode and temp_file.exists() and temp_file.stat().st_size > 0:
|
||||
temp_file.replace(output_file)
|
||||
LOG.info(f"Generated thumbnail '{output_file}' for '{media_file}'.")
|
||||
LOG.info(
|
||||
"Generated thumbnail '%s' for '%s'.",
|
||||
output_file,
|
||||
media_file,
|
||||
extra={"media_file": str(media_file), "output_file": str(output_file)},
|
||||
)
|
||||
return output_file
|
||||
|
||||
if 0 != proc.returncode:
|
||||
|
|
@ -184,7 +210,13 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
|
|||
last_error: str = "ffmpeg produced an empty thumbnail file"
|
||||
|
||||
LOG.debug(
|
||||
f"Thumbnail generation attempt failed for '{media_file}'. seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}s'}"
|
||||
"Thumbnail generation attempt failed for '%s'. seek=%s",
|
||||
media_file,
|
||||
"none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
extra={
|
||||
"media_file": str(media_file),
|
||||
"seek": "none" if attempt_seek is None else f"{attempt_seek:.3f}s",
|
||||
},
|
||||
)
|
||||
|
||||
temp_file.unlink(missing_ok=True)
|
||||
|
|
@ -224,11 +256,16 @@ async def ensure_thumb(media_file: Path, cache_root: Path, item_id: str | None =
|
|||
|
||||
task = _IN_PROCESS.get(thumb_id)
|
||||
if task is not None and not task.done():
|
||||
LOG.debug(f"Waiting for thumbnail generation for '{media_file}'.")
|
||||
LOG.debug("Waiting for thumbnail generation for '%s'.", media_file, extra={"media_file": str(media_file)})
|
||||
else:
|
||||
task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{item_id or media_file.stem}")
|
||||
_IN_PROCESS[thumb_id] = task
|
||||
LOG.debug(f"Starting thumbnail generation task for '{media_file}' -> '{cache_file}'.")
|
||||
LOG.debug(
|
||||
"Starting thumbnail generation for '%s' -> '%s'.",
|
||||
media_file,
|
||||
cache_file,
|
||||
extra={"media_file": str(media_file), "cache_file": str(cache_file)},
|
||||
)
|
||||
|
||||
try:
|
||||
result: Path | None = await task
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from aiohttp.web_response import StreamResponse
|
||||
|
||||
from app.features.streaming.library.m3u8 import M3u8
|
||||
from app.features.streaming.library.playlist import Playlist
|
||||
|
|
@ -12,10 +12,11 @@ from app.features.streaming.library.segments import Segments
|
|||
from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks
|
||||
from app.features.streaming.types import StreamingError
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
from app.library.Utils import get_file
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("streaming")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
|
||||
|
|
@ -32,7 +33,7 @@ async def playlist_create(request: Request, config: Config, app: web.Application
|
|||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
file: str | None = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
|
@ -83,8 +84,8 @@ async def m3u8_create(request: Request, config: Config, app: web.Application) ->
|
|||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
mode: str = request.match_info.get("mode")
|
||||
file: str | None = request.match_info.get("file")
|
||||
mode: str | None = request.match_info.get("mode")
|
||||
|
||||
if mode not in ["video", "subtitle"]:
|
||||
return web.json_response(
|
||||
|
|
@ -94,13 +95,14 @@ async def m3u8_create(request: Request, config: Config, app: web.Application) ->
|
|||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration = request.query.get("duration", None)
|
||||
duration: float | None = None
|
||||
duration_arg = request.query.get("duration", None)
|
||||
|
||||
if "subtitle" in mode:
|
||||
if not duration:
|
||||
if not duration_arg:
|
||||
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
duration = float(duration)
|
||||
duration = float(duration_arg)
|
||||
|
||||
base_path: str = config.base_path.rstrip("/")
|
||||
|
||||
|
|
@ -124,11 +126,19 @@ async def m3u8_create(request: Request, config: Config, app: web.Application) ->
|
|||
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
|
||||
|
||||
if "subtitle" in mode:
|
||||
if duration is None:
|
||||
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
|
||||
text = await cls.make_subtitle(file=realFile, duration=duration)
|
||||
else:
|
||||
text = await cls.make_stream(file=realFile)
|
||||
except StreamingError as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to create %s streaming playlist for '%s': %s.",
|
||||
mode,
|
||||
file,
|
||||
e,
|
||||
extra={"route": "streaming.playlist", "file_path": file, "mode": mode, "exception_type": type(e).__name__},
|
||||
)
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.Response(
|
||||
|
|
@ -143,7 +153,7 @@ async def m3u8_create(request: Request, config: Config, app: web.Application) ->
|
|||
|
||||
|
||||
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
|
||||
async def segments_stream(request: Request, config: Config, app: web.Application) -> Response:
|
||||
async def segments_stream(request: Request, config: Config, app: web.Application) -> StreamResponse:
|
||||
"""
|
||||
Get the segments.
|
||||
|
||||
|
|
@ -156,9 +166,9 @@ async def segments_stream(request: Request, config: Config, app: web.Application
|
|||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
segment: int = request.match_info.get("segment")
|
||||
sd: int = request.query.get("sd")
|
||||
file: str | None = request.match_info.get("file")
|
||||
segment: str | None = request.match_info.get("segment")
|
||||
sd: str | None = request.query.get("sd")
|
||||
vc: int = int(request.query.get("vc", 0))
|
||||
ac: int = int(request.query.get("ac", 0))
|
||||
|
||||
|
|
@ -235,7 +245,7 @@ async def subtitles_get(request: Request, config: Config, app: web.Application)
|
|||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
file: str | None = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
|
@ -293,7 +303,7 @@ async def subtitles_manifest_get(request: Request, config: Config, app: web.Appl
|
|||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
file: str | None = request.match_info.get("file")
|
||||
|
||||
if not file:
|
||||
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
|
@ -348,7 +358,7 @@ async def subtitles_track_get(request: Request, config: Config, app: web.Applica
|
|||
Response: The response object.
|
||||
|
||||
"""
|
||||
file: str = request.match_info.get("file")
|
||||
file: str | None = request.match_info.get("file")
|
||||
source_format: str | None = request.match_info.get("source_format")
|
||||
|
||||
if not file:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from pathlib import Path
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from app.features.streaming.library.segments import Segments
|
||||
from app.tests.helpers import get_test_system_temp_root
|
||||
|
|
@ -148,18 +149,18 @@ class _FakeProc:
|
|||
self.killed = True
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, fail_with: Exception | None = None) -> None:
|
||||
class _FakeResp(web.StreamResponse):
|
||||
def __init__(self, fail_with: BaseException | None = None) -> None:
|
||||
self.data: bytearray = bytearray()
|
||||
self.eof = False
|
||||
self._exc = fail_with
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
async def write(self, data: bytes | bytearray | memoryview, *_args: Any, **_kwargs: Any) -> None:
|
||||
if self._exc:
|
||||
raise self._exc
|
||||
self.data.extend(data)
|
||||
|
||||
async def write_eof(self) -> None:
|
||||
async def write_eof(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
self.eof = True
|
||||
|
||||
|
||||
|
|
@ -255,14 +256,14 @@ async def test_stream_gpu_fallback(
|
|||
# Encourage GPU preference
|
||||
seg.vcodec = "" # empty -> try GPUs first
|
||||
resp = _FakeResp()
|
||||
with caplog.at_level(logging.WARNING, logger="player.segments"):
|
||||
with caplog.at_level(logging.WARNING, logger="ytptube"):
|
||||
await seg.stream(tmp_path / "file.mp4", resp)
|
||||
|
||||
# Ensure fallback path streamed data
|
||||
assert len(resp.data) > 0
|
||||
# Ensure we logged the reason for GPU failure (message text may vary)
|
||||
assert any(
|
||||
("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message) for r in caplog.records
|
||||
("hardware encoder" in r.message.lower()) or ("transcoding has failed" in r.message) for r in caplog.records
|
||||
)
|
||||
assert any("nvenc failure" in r.message for r in caplog.records)
|
||||
# Verify second invocation switched codec to a safe fallback (software)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -260,7 +261,7 @@ async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
output = temp_dir / ".jpg"
|
||||
media.write_text("video")
|
||||
|
||||
ff_info = FFProbeResult()
|
||||
ff_info: Any = FFProbeResult()
|
||||
ff_info.metadata = {"duration": "120.0"}
|
||||
ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()]
|
||||
|
||||
|
|
@ -321,7 +322,7 @@ async def test_limit_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
media1.write_text("video")
|
||||
media2.write_text("video")
|
||||
|
||||
ff_info = FFProbeResult()
|
||||
ff_info: Any = FFProbeResult()
|
||||
ff_info.metadata = {"duration": "60.0"}
|
||||
ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()]
|
||||
monkeypatch.setattr(thumbnail, "ffprobe", AsyncMock(return_value=ff_info))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
# flake8: noqa: ARG004
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult
|
||||
|
|
@ -12,6 +11,7 @@ if TYPE_CHECKING:
|
|||
class BaseHandler:
|
||||
@staticmethod
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
_ = task
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -24,6 +24,7 @@ class BaseHandler:
|
|||
|
||||
@staticmethod
|
||||
def parse(url: str) -> Any | None:
|
||||
_ = url
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import asyncio
|
|||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
|
@ -26,6 +25,7 @@ from app.features.ytdlp.utils import get_archive_id
|
|||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from parsel.selector import SelectorList
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("handlers.generic")
|
||||
LOG = get_logger()
|
||||
CACHE: Cache = Cache()
|
||||
|
||||
|
||||
|
|
@ -64,12 +64,15 @@ class GenericTaskHandler(BaseHandler):
|
|||
from app.features.tasks.definitions.utils import model_to_schema
|
||||
|
||||
repo = TaskDefinitionsRepository.get_instance()
|
||||
models = await repo.list()
|
||||
models = await repo.all()
|
||||
|
||||
cls._definitions = [model_to_schema(model) for model in models]
|
||||
return cls._definitions
|
||||
except Exception as exc:
|
||||
LOG.error(f"Failed to load task definitions from database: {exc}")
|
||||
LOG.exception(
|
||||
"Failed to load generic task definitions.",
|
||||
extra={"error": str(exc), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
|
|
@ -102,7 +105,15 @@ class GenericTaskHandler(BaseHandler):
|
|||
if pattern_str and re.match(pattern_str, url):
|
||||
return definition
|
||||
except Exception as exc:
|
||||
LOG.error(f"Error while matching definition '{definition.name}': {exc}")
|
||||
LOG.exception(
|
||||
"Failed to match a generic task definition.",
|
||||
extra={
|
||||
"definition": definition.name,
|
||||
"url": url,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -120,13 +131,18 @@ class GenericTaskHandler(BaseHandler):
|
|||
"""
|
||||
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
|
||||
if definition:
|
||||
LOG.debug(f"'{task.name}': Matched generic task definition '{definition.name}'.")
|
||||
LOG.debug(
|
||||
"Task '%s' matched a generic task definition.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url, "definition": definition.name},
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: # noqa: ARG004
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
|
||||
if not definition:
|
||||
return TaskFailure(message="No generic task definition matched the provided URL.")
|
||||
|
|
@ -134,7 +150,16 @@ class GenericTaskHandler(BaseHandler):
|
|||
ytdlp_opts: dict[str, Any] = task.get_ytdlp_opts().get_all()
|
||||
target_url: str = definition.definition.request.url or task.url
|
||||
|
||||
LOG.debug(f"{task.name!r}: Fetching '{target_url}' using engine '{definition.definition.engine.type}'.")
|
||||
LOG.debug(
|
||||
"Fetching content for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_name": task.name,
|
||||
"definition": definition.name,
|
||||
"url": target_url,
|
||||
"engine": definition.definition.engine.type,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
body_text, json_data = await GenericTaskHandler._fetch_content(
|
||||
|
|
@ -143,7 +168,16 @@ class GenericTaskHandler(BaseHandler):
|
|||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch content for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_name": task.name,
|
||||
"definition": definition.name,
|
||||
"url": target_url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
|
||||
|
||||
if "json" == definition.definition.response.type and json_data is None:
|
||||
|
|
@ -181,7 +215,9 @@ class GenericTaskHandler(BaseHandler):
|
|||
continue
|
||||
else:
|
||||
LOG.warning(
|
||||
f"[{definition.name}]: '{task.name}': Unable to generate static archive id for '{url}' in feed. Doing real request to fetch yt-dlp archive id."
|
||||
"Task '%s' could not generate a static archive ID. Fetching it with yt-dlp.",
|
||||
task.name,
|
||||
extra={"definition": definition.name, "task_name": task.name, "url": url},
|
||||
)
|
||||
|
||||
(info, _) = await fetch_info(
|
||||
|
|
@ -194,14 +230,18 @@ class GenericTaskHandler(BaseHandler):
|
|||
|
||||
if not info:
|
||||
LOG.error(
|
||||
f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping."
|
||||
"Task '%s' failed to extract info to generate an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"definition": definition.name, "task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
||||
if not info.get("id") or not info.get("extractor_key"):
|
||||
LOG.error(
|
||||
f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping."
|
||||
"Task '%s' returned incomplete info while generating an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"definition": definition.name, "task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
|
@ -297,7 +337,17 @@ class GenericTaskHandler(BaseHandler):
|
|||
try:
|
||||
json_data: dict[str, Any] = response.json()
|
||||
except Exception as exc:
|
||||
LOG.error(f"Failed to decode JSON response from '{url}': {exc}")
|
||||
LOG.exception(
|
||||
"Task definition '%s' returned invalid JSON for '%s'.",
|
||||
definition.name,
|
||||
url,
|
||||
extra={
|
||||
"definition": definition.name,
|
||||
"url": url,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return response.text, None
|
||||
|
||||
return response.text, json_data
|
||||
|
|
@ -327,7 +377,11 @@ class GenericTaskHandler(BaseHandler):
|
|||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
except ImportError as exc:
|
||||
LOG.error(f"Selenium engine requested but selenium is not installed: {exc!s}.")
|
||||
LOG.exception(
|
||||
"Task definition '%s' requested Selenium, but Selenium is not installed.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name, "error": str(exc), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return (None, None)
|
||||
|
||||
options_map: dict[str, Any] = definition.definition.engine.options
|
||||
|
|
@ -340,7 +394,12 @@ class GenericTaskHandler(BaseHandler):
|
|||
browser: str = str(options_map.get("browser", "chrome")).lower()
|
||||
|
||||
if "chrome" != browser:
|
||||
LOG.error(f"Unsupported selenium browser '{browser}'. Only 'chrome' is supported.")
|
||||
LOG.error(
|
||||
"Task definition '%s' requested unsupported Selenium browser '%s'.",
|
||||
definition.name,
|
||||
browser,
|
||||
extra={"definition": definition.name, "browser": browser},
|
||||
)
|
||||
return (None, None)
|
||||
|
||||
arguments: list[str] | str = options_map.get("arguments", ["--headless", "--disable-gpu"])
|
||||
|
|
@ -422,7 +481,9 @@ class GenericTaskHandler(BaseHandler):
|
|||
|
||||
link_values: list[str] = extracted.get("link", [])
|
||||
if not link_values:
|
||||
LOG.debug(f"Definition '{definition.name}' produced no link values.")
|
||||
LOG.debug(
|
||||
"Definition '%s' produced no link values.", definition.name, extra={"definition": definition.name}
|
||||
)
|
||||
return []
|
||||
|
||||
total_items: int = len(link_values)
|
||||
|
|
@ -457,7 +518,11 @@ class GenericTaskHandler(BaseHandler):
|
|||
base_url: str,
|
||||
) -> list[dict[str, str]]:
|
||||
if json_data is None:
|
||||
LOG.debug(f"Definition '{definition.name}' expects JSON but no data was parsed.")
|
||||
LOG.debug(
|
||||
"Definition '%s' expects JSON but no data was parsed.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name},
|
||||
)
|
||||
return []
|
||||
|
||||
if definition.definition.parse.get("items"):
|
||||
|
|
@ -496,7 +561,11 @@ class GenericTaskHandler(BaseHandler):
|
|||
container_type = container.get("type", "css")
|
||||
container_selector = container.get("selector") or container.get("expression") or ""
|
||||
if not container_selector:
|
||||
LOG.error(f"Container missing selector/expression. Definition '{definition.name}'.")
|
||||
LOG.error(
|
||||
"Task definition '%s' is missing an item container selector.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name},
|
||||
)
|
||||
return []
|
||||
container_fields = container.get("fields", {})
|
||||
|
||||
|
|
@ -550,7 +619,11 @@ class GenericTaskHandler(BaseHandler):
|
|||
container_fields = container.get("fields", {})
|
||||
|
||||
if "jsonpath" != container_type:
|
||||
LOG.error(f"JSON response requires container selector type 'jsonpath'. Definition '{definition.name}'.")
|
||||
LOG.error(
|
||||
"JSON response requires container selector type 'jsonpath'. Definition '%s'.",
|
||||
definition.name,
|
||||
extra={"definition": definition.name, "container_type": container_type},
|
||||
)
|
||||
return []
|
||||
|
||||
nodes: Any = GenericTaskHandler._json_search(json_data, container_selector)
|
||||
|
|
@ -606,7 +679,16 @@ class GenericTaskHandler(BaseHandler):
|
|||
try:
|
||||
pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
|
||||
except re.error as exc:
|
||||
LOG.error(f"Invalid regex expression '{rule.expression}': {exc}")
|
||||
LOG.exception(
|
||||
"Invalid regex expression '%s'.",
|
||||
rule.expression,
|
||||
extra={
|
||||
"field": field,
|
||||
"expression": rule.expression,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return values
|
||||
|
||||
for match in pattern.finditer(target):
|
||||
|
|
@ -617,7 +699,12 @@ class GenericTaskHandler(BaseHandler):
|
|||
|
||||
return values
|
||||
|
||||
LOG.error(f"Unsupported extraction type '{rule.type}' for JSON data in field '{field}'.")
|
||||
LOG.error(
|
||||
"Unsupported extraction type '%s' for JSON data in field '%s'.",
|
||||
rule.type,
|
||||
field,
|
||||
extra={"field": field, "rule_type": rule.type},
|
||||
)
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -625,7 +712,11 @@ class GenericTaskHandler(BaseHandler):
|
|||
try:
|
||||
return jmespath.search(expression, data)
|
||||
except Exception as exc:
|
||||
LOG.error(f"JSONPath search failed for expression '{expression}': {exc}")
|
||||
LOG.exception(
|
||||
"JSONPath search failed for expression '%s'.",
|
||||
expression,
|
||||
extra={"expression": expression, "error": str(exc), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -660,7 +751,16 @@ class GenericTaskHandler(BaseHandler):
|
|||
try:
|
||||
pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
|
||||
except re.error as exc:
|
||||
LOG.error(f"Invalid regex expression '{rule.expression}': {exc}")
|
||||
LOG.exception(
|
||||
"Invalid regex expression '%s'.",
|
||||
rule.expression,
|
||||
extra={
|
||||
"field": field,
|
||||
"expression": rule.expression,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return values
|
||||
|
||||
for match in pattern.finditer(html):
|
||||
|
|
@ -672,7 +772,7 @@ class GenericTaskHandler(BaseHandler):
|
|||
return values
|
||||
|
||||
if "jsonpath" == rule.type:
|
||||
LOG.error("Extraction type 'jsonpath' is only valid for JSON responses.")
|
||||
LOG.error("Field '%s' uses 'jsonpath' on a non-JSON response.", field, extra={"field": field})
|
||||
return values
|
||||
|
||||
selection: SelectorList[Selector] = (
|
||||
|
|
@ -704,7 +804,12 @@ class GenericTaskHandler(BaseHandler):
|
|||
try:
|
||||
return match.group(attribute)
|
||||
except (IndexError, KeyError):
|
||||
LOG.debug(f"Regex group '{attribute}' not found in pattern '{match.re.pattern}'.")
|
||||
LOG.debug(
|
||||
"Regex group '%s' not found in pattern '%s'.",
|
||||
attribute,
|
||||
match.re.pattern,
|
||||
extra={"attribute": attribute, "pattern": match.re.pattern},
|
||||
)
|
||||
return None
|
||||
|
||||
if match.groupdict():
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
|
@ -10,13 +9,15 @@ from app.features.tasks.definitions.results import HandleTask, TaskFailure, Task
|
|||
from app.features.ytdlp.extractor import fetch_info
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("handlers.rss")
|
||||
LOG = get_logger()
|
||||
CACHE: Cache = Cache()
|
||||
|
||||
|
||||
|
|
@ -28,7 +29,11 @@ class RssGenericHandler(BaseHandler):
|
|||
|
||||
@staticmethod
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable RSS feed.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return RssGenericHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -52,7 +57,11 @@ class RssGenericHandler(BaseHandler):
|
|||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
feed_url: str = parsed["url"]
|
||||
LOG.debug(f"'{task.name}': Fetching RSS/Atom feed from {feed_url}")
|
||||
LOG.debug(
|
||||
"Fetching RSS/Atom feed for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
|
||||
response = await RssGenericHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
|
@ -73,7 +82,12 @@ class RssGenericHandler(BaseHandler):
|
|||
# Try to parse as Atom feed first
|
||||
entries = root.findall("atom:entry", ns)
|
||||
if entries:
|
||||
LOG.debug(f"'{task.name}': Detected Atom feed format with {len(entries)} entries")
|
||||
LOG.debug(
|
||||
"'%s': Detected Atom feed format with %s entries",
|
||||
task.name,
|
||||
len(entries),
|
||||
extra={"task_name": task.name, "feed_url": feed_url, "entry_count": len(entries)},
|
||||
)
|
||||
for entry in entries:
|
||||
link_elem: Element | None = entry.find("atom:link[@rel='alternate']", ns)
|
||||
if link_elem is None:
|
||||
|
|
@ -84,7 +98,11 @@ class RssGenericHandler(BaseHandler):
|
|||
url = link_elem.get("href", "")
|
||||
|
||||
if not url:
|
||||
LOG.warning(f"'{task.name}': Atom entry missing URL. Skipping.")
|
||||
LOG.warning(
|
||||
"'%s': Atom entry missing URL. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
title_elem: Element | None = entry.find("atom:title", ns)
|
||||
|
|
@ -98,7 +116,12 @@ class RssGenericHandler(BaseHandler):
|
|||
else:
|
||||
# Try to parse as RSS feed
|
||||
rss_items = root.findall(".//item")
|
||||
LOG.debug(f"'{task.name}': Detected RSS feed format with {len(rss_items)} items")
|
||||
LOG.debug(
|
||||
"'%s': Detected RSS feed format with %s items",
|
||||
task.name,
|
||||
len(rss_items),
|
||||
extra={"task_name": task.name, "feed_url": feed_url, "entry_count": len(rss_items)},
|
||||
)
|
||||
|
||||
for item in rss_items:
|
||||
# Try different link element names (link, url, media:content)
|
||||
|
|
@ -119,7 +142,11 @@ class RssGenericHandler(BaseHandler):
|
|||
url = enclosure_elem.get("url", "")
|
||||
|
||||
if not url:
|
||||
LOG.warning(f"'{task.name}': RSS item missing URL. Skipping.")
|
||||
LOG.warning(
|
||||
"'%s': RSS item missing URL. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
title_elem = item.find("title")
|
||||
|
|
@ -134,12 +161,14 @@ class RssGenericHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
"""
|
||||
Extract items from an RSS/Atom feed.
|
||||
|
||||
Args:
|
||||
task (Task): The task containing the feed URL.
|
||||
config (Config | None): Optional handler configuration.
|
||||
|
||||
Returns:
|
||||
TaskResult | TaskFailure: Extraction result with parsed items or failure information.
|
||||
|
|
@ -156,7 +185,16 @@ class RssGenericHandler(BaseHandler):
|
|||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch RSS/Atom feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -176,12 +214,17 @@ class RssGenericHandler(BaseHandler):
|
|||
if CACHE.has(cache_key):
|
||||
archive_id = CACHE.get(cache_key)
|
||||
if not archive_id:
|
||||
LOG.debug(f"'{task.name}': Cached failure for URL '{url}'. Skipping.")
|
||||
LOG.debug(
|
||||
"Task '%s' has a cached archive ID lookup failure. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
continue
|
||||
else:
|
||||
LOG.warning(
|
||||
f"'{task.name}': Unable to generate static archive ID for '{url}' in feed. "
|
||||
"Doing real request to fetch yt-dlp archive ID."
|
||||
"Task '%s' could not generate a static archive ID. Fetching it with yt-dlp.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
|
||||
(info, _) = await fetch_info(
|
||||
|
|
@ -194,14 +237,18 @@ class RssGenericHandler(BaseHandler):
|
|||
|
||||
if not info:
|
||||
LOG.error(
|
||||
f"'{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping."
|
||||
"Task '%s' failed to extract info to generate an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
||||
if not info.get("id") or not info.get("extractor_key"):
|
||||
LOG.error(
|
||||
f"'{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping."
|
||||
"Task '%s' returned incomplete info while generating an archive ID. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
CACHE.set(cache_key, None)
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("handlers.tver")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TverHandler(BaseHandler):
|
||||
|
|
@ -24,7 +25,11 @@ class TverHandler(BaseHandler):
|
|||
|
||||
@staticmethod
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable Tver series URL.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return TverHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -55,7 +60,11 @@ class TverHandler(BaseHandler):
|
|||
|
||||
return {"platform_uid": platform_uid, "platform_token": platform_token}
|
||||
except Exception as exc:
|
||||
LOG.warning(f"Failed to create tver session: {exc}")
|
||||
LOG.warning(
|
||||
"Failed to create Tver session.",
|
||||
extra={"error": str(exc), "exception_type": type(exc).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -83,7 +92,11 @@ class TverHandler(BaseHandler):
|
|||
|
||||
feed_url = TverHandler.SERIES_API.format(id=series_id)
|
||||
|
||||
LOG.debug(f"Fetching '{task.name}' episodes from tver series {series_id}.")
|
||||
LOG.debug(
|
||||
"Fetching Tver episodes for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url},
|
||||
)
|
||||
|
||||
response = await TverHandler.request(
|
||||
url=feed_url,
|
||||
|
|
@ -105,7 +118,11 @@ class TverHandler(BaseHandler):
|
|||
try:
|
||||
contents = data.get("result", {}).get("contents", [])
|
||||
if not contents:
|
||||
LOG.warning(f"No contents found in tver series response for '{task.name}'.")
|
||||
LOG.warning(
|
||||
"No contents found in Tver series response for '%s'.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url},
|
||||
)
|
||||
return feed_url, items, has_items
|
||||
|
||||
season_block = contents[0] if contents else {}
|
||||
|
|
@ -116,9 +133,13 @@ class TverHandler(BaseHandler):
|
|||
continue
|
||||
|
||||
content = episode_data.get("content", {})
|
||||
episode_id = content.pop("id")
|
||||
episode_id = content.pop("id", None)
|
||||
if not episode_id:
|
||||
LOG.warning(f"Episode missing ID in '{task.name}' feed. Skipping.")
|
||||
LOG.warning(
|
||||
"Episode missing ID in '%s' feed. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
url = f"https://tver.jp/episodes/{episode_id}"
|
||||
|
|
@ -129,7 +150,9 @@ class TverHandler(BaseHandler):
|
|||
archive_id = id_dict.get("archive_id")
|
||||
if not archive_id:
|
||||
LOG.warning(
|
||||
f"Could not compute archive ID for episode '{episode_id}' in '{task.name}' feed. Skipping."
|
||||
"Task '%s' could not compute an archive ID for an episode. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "series_id": series_id, "episode_id": episode_id, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
@ -139,12 +162,24 @@ class TverHandler(BaseHandler):
|
|||
)
|
||||
|
||||
except Exception as exc:
|
||||
LOG.warning(f"Error parsing tver episodes for '{task.name}': {exc}")
|
||||
LOG.warning(
|
||||
"Failed to parse Tver episodes for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"series_id": series_id,
|
||||
"error": str(exc),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
series_id: str | None = TverHandler.parse(task.url)
|
||||
if not series_id:
|
||||
return TaskFailure(message="Unrecognized Tver series URL.")
|
||||
|
|
@ -156,7 +191,17 @@ class TverHandler(BaseHandler):
|
|||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch Tver feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"series_id": series_id,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -165,10 +210,11 @@ class TverHandler(BaseHandler):
|
|||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
task_items.append(
|
||||
TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=entry.get("metadata", {}))
|
||||
)
|
||||
archive_id: str | None = entry.get("archive_id")
|
||||
metadata = entry.get("metadata", {})
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata))
|
||||
|
||||
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from xml.etree.ElementTree import Element
|
||||
|
|
@ -7,13 +6,15 @@ import httpx
|
|||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("handlers.twitch")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TwitchHandler(BaseHandler):
|
||||
|
|
@ -23,7 +24,11 @@ class TwitchHandler(BaseHandler):
|
|||
|
||||
@staticmethod
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable Twitch URL.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return TwitchHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -36,7 +41,7 @@ class TwitchHandler(BaseHandler):
|
|||
|
||||
feed_url: str = TwitchHandler.FEED.format(handle=handle_name)
|
||||
|
||||
LOG.debug(f"Fetching '{task.name}' feed.")
|
||||
LOG.debug("Fetching '%s' feed.", task.name, extra={"task_name": task.name, "feed_url": feed_url})
|
||||
response = await TwitchHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
||||
|
|
@ -48,14 +53,22 @@ class TwitchHandler(BaseHandler):
|
|||
link_elem: Element[str] | None = entry.find("link")
|
||||
url: str = link_elem.text.strip() if link_elem is not None and link_elem.text else ""
|
||||
if not url:
|
||||
LOG.warning(f"Entry in '{task.name}' feed is missing URL. Skipping entry.")
|
||||
LOG.warning(
|
||||
"Entry in '%s' feed is missing URL. Skipping entry.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
match: re.Match[str] | None = re.search(
|
||||
r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url
|
||||
)
|
||||
if not match:
|
||||
LOG.warning(f"URL in '{task.name}' feed does not look like a VOD link: {url}")
|
||||
LOG.warning(
|
||||
"Task '%s' produced a feed entry that does not look like a Twitch VOD link. Skipping entry.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
vid: str = match.group("id")
|
||||
|
|
@ -68,7 +81,11 @@ class TwitchHandler(BaseHandler):
|
|||
id_dict = get_archive_id(url)
|
||||
archive_id: str | None = id_dict.get("archive_id")
|
||||
if not archive_id:
|
||||
LOG.warning(f"Could not compute archive ID for video '{vid}' in '{task.name}' feed. Skipping entry.")
|
||||
LOG.warning(
|
||||
"Task '%s' could not compute an archive ID for a Twitch video. Skipping entry.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "video_id": vid, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
items.append({"id": vid, "url": url, "title": title, "archive_id": archive_id})
|
||||
|
|
@ -76,7 +93,8 @@ class TwitchHandler(BaseHandler):
|
|||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
handle_name: str | None = TwitchHandler.parse(task.url)
|
||||
if not handle_name:
|
||||
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
||||
|
|
@ -88,7 +106,16 @@ class TwitchHandler(BaseHandler):
|
|||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch Twitch feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -97,7 +124,7 @@ class TwitchHandler(BaseHandler):
|
|||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
archive_id: str | None = entry.get("archive_id")
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id))
|
||||
|
||||
return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
|
@ -7,13 +6,15 @@ import httpx
|
|||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.ytdlp.utils import get_archive_id
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("handlers.youtube")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class YoutubeHandler(BaseHandler):
|
||||
|
|
@ -29,7 +30,11 @@ class YoutubeHandler(BaseHandler):
|
|||
|
||||
@staticmethod
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
||||
LOG.debug(
|
||||
"Checking if task '%s' uses a parsable YouTube URL.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "url": task.url},
|
||||
)
|
||||
return YoutubeHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -49,7 +54,7 @@ class YoutubeHandler(BaseHandler):
|
|||
from defusedxml.ElementTree import fromstring
|
||||
|
||||
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
|
||||
LOG.debug(f"'{task.name}': Fetching feed.")
|
||||
LOG.debug("'%s': Fetching feed.", task.name, extra={"task_name": task.name, "feed_url": feed_url})
|
||||
|
||||
response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params)
|
||||
response.raise_for_status()
|
||||
|
|
@ -67,7 +72,11 @@ class YoutubeHandler(BaseHandler):
|
|||
vid_elem: Element[str] | None = entry.find("yt:videoId", ns)
|
||||
vid: str = vid_elem.text if vid_elem is not None and vid_elem.text else ""
|
||||
if not vid:
|
||||
LOG.warning(f"'{task.name}': Entry in the feed is missing a video ID. Skipping.")
|
||||
LOG.warning(
|
||||
"'%s': Entry in the feed is missing a video ID. Skipping.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "feed_url": feed_url},
|
||||
)
|
||||
continue
|
||||
|
||||
url: str = f"https://www.youtube.com/watch?v={vid}"
|
||||
|
|
@ -75,7 +84,11 @@ class YoutubeHandler(BaseHandler):
|
|||
id_dict: dict[str, str | None] = get_archive_id(url)
|
||||
archive_id: str | None = id_dict.get("archive_id")
|
||||
if not archive_id:
|
||||
LOG.warning(f"'{task.name}': Could not compute archive ID for video '{vid}' in feed. Skipping.")
|
||||
LOG.warning(
|
||||
"Task '%s' could not compute an archive ID for a YouTube video. Skipping item.",
|
||||
task.name,
|
||||
extra={"task_name": task.name, "video_id": vid, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
title_elem: Element[str] | None = entry.find("atom:title", ns)
|
||||
|
|
@ -91,7 +104,8 @@ class YoutubeHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
_ = config
|
||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||
if not parsed:
|
||||
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
|
||||
|
|
@ -103,7 +117,16 @@ class YoutubeHandler(BaseHandler):
|
|||
except httpx.HTTPError as exc:
|
||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to fetch YouTube feed for task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
|
||||
|
||||
task_items: list[TaskItem] = []
|
||||
|
|
@ -112,7 +135,7 @@ class YoutubeHandler(BaseHandler):
|
|||
if not (url := entry.get("url")):
|
||||
continue
|
||||
|
||||
archive_id: str = entry.get("archive_id")
|
||||
archive_id: str | None = entry.get("archive_id")
|
||||
metadata: dict[str, Any] = {"published": entry.get("published")}
|
||||
|
||||
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata))
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
|
|
@ -48,7 +48,11 @@ class Migration(FeatureMigration):
|
|||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert task definition '%s': %s", normalized.get("name"), exc)
|
||||
LOG.exception(
|
||||
"Failed to insert task definition '%s'.",
|
||||
normalized.get("name"),
|
||||
extra={"definition": normalized.get("name"), "exception_type": type(exc).__name__},
|
||||
)
|
||||
finally:
|
||||
await self._move_file(path)
|
||||
|
||||
|
|
@ -62,13 +66,21 @@ class Migration(FeatureMigration):
|
|||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
LOG.error("Failed to read task definition '%s': %s", path, exc)
|
||||
LOG.exception(
|
||||
"Failed to read task definition '%s'.",
|
||||
path,
|
||||
extra={"path": str(path), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except Exception as exc:
|
||||
LOG.error("Failed to parse JSON for '%s': %s", path, exc)
|
||||
LOG.exception(
|
||||
"Failed to parse JSON for task definition '%s'.",
|
||||
path,
|
||||
extra={"path": str(path), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
|
@ -10,24 +9,28 @@ from app.features.core.schemas import CEFeature, ConfigEvent
|
|||
from app.features.tasks.definitions.migration import Migration
|
||||
from app.features.tasks.definitions.models import TaskDefinitionModel
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TaskDefinitionsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
|
|
@ -51,12 +54,12 @@ class TaskDefinitionsRepository(metaclass=Singleton):
|
|||
LOG.debug("Refreshing task definitions due to configuration update.")
|
||||
await GenericTaskHandler.refresh_definitions(force=True)
|
||||
|
||||
Services.get_instance().add(__class__.__name__, self)
|
||||
Services.get_instance().add(TaskDefinitionsRepository.__name__, self)
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.STARTED, handle_event, f"{__class__.__name__}.run_migrations"
|
||||
Events.STARTED, handle_event, f"{TaskDefinitionsRepository.__name__}.run_migrations"
|
||||
).subscribe(Events.CONFIG_UPDATE, handler, "GenericTaskHandler.refresh_definitions")
|
||||
|
||||
async def list(self) -> list[TaskDefinitionModel]:
|
||||
async def all(self) -> list[TaskDefinitionModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).order_by(TaskDefinitionModel.priority.asc(), TaskDefinitionModel.name.asc())
|
||||
|
|
@ -115,9 +118,9 @@ class TaskDefinitionsRepository(metaclass=Singleton):
|
|||
|
||||
async def create(self, payload: dict[str, Any]) -> TaskDefinitionModel:
|
||||
async with self.session() as session:
|
||||
model: TaskDefinitionModel = TaskDefinitionModel(**payload) if isinstance(payload, dict) else payload
|
||||
model: TaskDefinitionModel = TaskDefinitionModel(**payload)
|
||||
if model.id is not None:
|
||||
model.id = None
|
||||
model.id = None # ty: ignore
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Task definition with name '{model.name}' already exists."
|
||||
|
|
|
|||
|
|
@ -48,10 +48,13 @@ class HandleTask(TaskSchema):
|
|||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
archive_file: Path = ret.get("file")
|
||||
items: set[str] = ret.get("items", set())
|
||||
archive_file = ret.get("file")
|
||||
items = ret.get("items", set())
|
||||
if not isinstance(archive_file, Path) or not isinstance(items, set):
|
||||
return (False, "Failed to get archive information.")
|
||||
archive_items = [item for item in items if isinstance(item, str)]
|
||||
|
||||
if len(items) < 1 or not archive_add(archive_file, list(items)):
|
||||
if len(archive_items) < 1 or not archive_add(archive_file, archive_items):
|
||||
return (True, "No new items to mark as downloaded.")
|
||||
|
||||
return (True, f"Task '{self.name}' items marked as downloaded.")
|
||||
|
|
@ -70,10 +73,13 @@ class HandleTask(TaskSchema):
|
|||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
archive_file: Path = ret.get("file")
|
||||
items: set[str] = ret.get("items", set())
|
||||
archive_file = ret.get("file")
|
||||
items = ret.get("items", set())
|
||||
if not isinstance(archive_file, Path) or not isinstance(items, set):
|
||||
return (False, "Failed to get archive information.")
|
||||
archive_items = [item for item in items if isinstance(item, str)]
|
||||
|
||||
if len(items) < 1 or not archive_delete(archive_file, list(items)):
|
||||
if len(archive_items) < 1 or not archive_delete(archive_file, archive_items):
|
||||
return (True, "No items to remove from archive file.")
|
||||
|
||||
return (True, f"Removed '{self.name}' items from archive file.")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -16,9 +15,10 @@ from app.features.tasks.definitions.schemas import (
|
|||
from app.features.tasks.definitions.utils import model_to_schema, schema_to_payload
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
@route("GET", "api/tasks/definitions/", "task_definitions")
|
||||
|
|
@ -95,7 +95,11 @@ async def task_definitions_create(request: Request, encoder: Encoder, notify: Ev
|
|||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to create task definition '%s'.",
|
||||
getattr(definition_input, "name", None),
|
||||
extra={"definition": getattr(definition_input, "name", None), "exception_type": type(exc).__name__},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to create task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
@ -144,7 +148,15 @@ async def task_definitions_update(request: Request, encoder: Encoder, notify: Ev
|
|||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to update task definition '%s'.",
|
||||
definition_input.name,
|
||||
extra={
|
||||
"definition_id": identifier,
|
||||
"definition": definition_input.name,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to update task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
@ -202,7 +214,11 @@ async def task_definitions_patch(request: Request, encoder: Encoder, notify: Eve
|
|||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to patch task definition '%s'.",
|
||||
identifier,
|
||||
extra={"definition_id": identifier, "exception_type": type(exc).__name__},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to patch task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
@ -228,7 +244,11 @@ async def task_definitions_delete(request: Request, encoder: Encoder, notify: Ev
|
|||
except KeyError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to delete task definition '%s'.",
|
||||
identifier,
|
||||
extra={"definition_id": identifier, "exception_type": type(exc).__name__},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to delete task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
import random
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.tasks.definitions.handlers._base_handler import BaseHandler
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.features.ytdlp.utils import archive_read
|
||||
from app.library.downloads.queue_manager import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -22,12 +23,12 @@ if TYPE_CHECKING:
|
|||
from app.library.config import Config
|
||||
from app.library.Scheduler import Scheduler
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("definitions.service")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class TaskHandle:
|
||||
def __init__(self, scheduler: Scheduler, tasks: TasksRepository, config: Config) -> None:
|
||||
self._handlers: list[type] = []
|
||||
self._handlers: list[type[BaseHandler]] = []
|
||||
"The available handlers."
|
||||
self._repo: TasksRepository = tasks
|
||||
"The tasks manager."
|
||||
|
|
@ -35,7 +36,7 @@ class TaskHandle:
|
|||
"The scheduler."
|
||||
self._config: Config = config
|
||||
"The configuration."
|
||||
self._task_name: str = f"{__class__.__name__}._dispatcher"
|
||||
self._task_name: str = f"{TaskHandle.__name__}._dispatcher"
|
||||
"The task name for the scheduler."
|
||||
self._queued: dict[str, set[str]] = {}
|
||||
"Queued archive IDs per handler."
|
||||
|
|
@ -45,11 +46,11 @@ class TaskHandle:
|
|||
EventBus.get_instance().subscribe(
|
||||
Events.ITEM_ERROR,
|
||||
self._handle_item_error,
|
||||
f"{__class__.__name__}.item_error",
|
||||
f"{TaskHandle.__name__}.item_error",
|
||||
)
|
||||
|
||||
def load(self) -> None:
|
||||
self._handlers: list[type] = self._discover()
|
||||
self._handlers: list[type[BaseHandler]] = self._discover()
|
||||
|
||||
timer: str = self._config.tasks_handler_timer
|
||||
try:
|
||||
|
|
@ -58,21 +59,30 @@ class TaskHandle:
|
|||
CronSim(timer, datetime.now(UTC))
|
||||
except Exception as e:
|
||||
timer = "15 */1 * * *"
|
||||
LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
|
||||
LOG.error(
|
||||
"Invalid task handler timer '%s'; using default '%s'.",
|
||||
self._config.tasks_handler_timer,
|
||||
timer,
|
||||
extra={
|
||||
"timer": self._config.tasks_handler_timer,
|
||||
"default_timer": timer,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
self._scheduler.add(
|
||||
timer=timer,
|
||||
func=lambda: asyncio.create_task(self._dispatcher(), name="task-handler-dispatcher"),
|
||||
id=f"{__class__.__name__}._dispatcher",
|
||||
id=f"{TaskHandle.__name__}._dispatcher",
|
||||
)
|
||||
|
||||
async def _dispatcher(self):
|
||||
s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []}
|
||||
|
||||
handler_groups: dict[str, list[tuple[HandleTask, type]]] = {}
|
||||
dispatches: list[tuple[HandleTask, type, asyncio.Task[TaskResult | TaskFailure | None]]] = []
|
||||
handler_groups: dict[str, list[tuple[HandleTask, type[BaseHandler]]]] = {}
|
||||
dispatches: list[tuple[HandleTask, type[BaseHandler], asyncio.Task[TaskResult | TaskFailure | None]]] = []
|
||||
|
||||
tasks: list[TaskModel] = await self._repo.list()
|
||||
tasks: list[TaskModel] = await self._repo.all()
|
||||
|
||||
for task_model in tasks:
|
||||
task: HandleTask = HandleTask.model_validate(task_model)
|
||||
|
|
@ -82,12 +92,16 @@ class TaskHandle:
|
|||
continue
|
||||
|
||||
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
||||
LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
|
||||
LOG.debug(
|
||||
"Task '%s' does not have an archive file configured.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name},
|
||||
)
|
||||
s["f"].append(task.name)
|
||||
continue
|
||||
|
||||
try:
|
||||
handler: type | None = await self._find_handler(task)
|
||||
handler: type[BaseHandler] | None = await self._find_handler(task)
|
||||
if handler is None:
|
||||
s["u"].append(task.name)
|
||||
continue
|
||||
|
|
@ -97,7 +111,11 @@ class TaskHandle:
|
|||
handler_groups[handler_name] = []
|
||||
handler_groups[handler_name].append((task, handler))
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to find handler for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "exception_type": type(e).__name__},
|
||||
)
|
||||
s["f"].append(task.name)
|
||||
|
||||
for tasks_with_handlers in handler_groups.values():
|
||||
|
|
@ -114,7 +132,17 @@ class TaskHandle:
|
|||
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
|
||||
dispatches.append((task, handler, t))
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to dispatch task '{task.name}'. '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to schedule handler '%s' for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
s["f"].append(task.name)
|
||||
|
||||
if dispatches:
|
||||
|
|
@ -130,16 +158,33 @@ class TaskHandle:
|
|||
continue
|
||||
|
||||
if result is None:
|
||||
LOG.error(f"Handler '{handler.__name__}' returned no result for task '{task.name}'.")
|
||||
LOG.error(
|
||||
"Handler '%s' returned no result for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "handler_name": handler.__name__},
|
||||
)
|
||||
|
||||
s["f"].append(task.name)
|
||||
|
||||
if len(tasks) > 0:
|
||||
LOG.info(
|
||||
f"Tasks handler summary: Handled: {len(s['h'])}, Unhandled: {len(s['u'])}, Disabled: {len(s['d'])}, Failed: {len(s['f'])}."
|
||||
"Task dispatch finished: %s handled, %s unhandled, %s disabled, %s failed.",
|
||||
len(s["h"]),
|
||||
len(s["u"]),
|
||||
len(s["d"]),
|
||||
len(s["f"]),
|
||||
extra={
|
||||
"handled_count": len(s["h"]),
|
||||
"unhandled_count": len(s["u"]),
|
||||
"disabled_count": len(s["d"]),
|
||||
"failed_count": len(s["f"]),
|
||||
},
|
||||
)
|
||||
|
||||
async def _dispatch(self, task: HandleTask, handler: type, delay: float) -> TaskResult | TaskFailure | None:
|
||||
async def _dispatch(
|
||||
self, task: HandleTask, handler: type[BaseHandler], delay: float
|
||||
) -> TaskResult | TaskFailure | None:
|
||||
"""
|
||||
Dispatch a task after a random delay to avoid rate limiting.
|
||||
|
||||
|
|
@ -153,7 +198,12 @@ class TaskHandle:
|
|||
|
||||
"""
|
||||
if delay > 0:
|
||||
LOG.debug(f"Delaying dispatch of task '{task.name}' by {delay:.1f} seconds.")
|
||||
LOG.debug(
|
||||
"Delaying dispatch of task '%s' by %.1f seconds.",
|
||||
task.name,
|
||||
delay,
|
||||
extra={"task_id": task.id, "task_name": task.name, "delay_s": round(delay, 1)},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
return await self.dispatch(task, handler=handler)
|
||||
|
||||
|
|
@ -162,15 +212,29 @@ class TaskHandle:
|
|||
return
|
||||
|
||||
if exc := fut.exception():
|
||||
LOG.error(f"Exception while handling task '{task.name}': {exc}")
|
||||
LOG.exception(
|
||||
"Task handler raised after dispatch.",
|
||||
extra={"task_id": task.id, "task_name": task.name, "exception_type": type(exc).__name__},
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
|
||||
async def _find_handler(self, task: HandleTask) -> type | None:
|
||||
async def _find_handler(self, task: HandleTask) -> type[BaseHandler] | None:
|
||||
for cls in self._handlers:
|
||||
try:
|
||||
if await Services.get_instance().handle_async(handler=cls.can_handle, task=task):
|
||||
return cls
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Handler '%s' capability check failed for task '%s'.",
|
||||
cls.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": cls.__name__,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
|
@ -178,9 +242,10 @@ class TaskHandle:
|
|||
async def dispatch(
|
||||
self,
|
||||
task: HandleTask,
|
||||
handler: type | None = None,
|
||||
**kwargs, # noqa: ARG002
|
||||
handler: type[BaseHandler] | None = None,
|
||||
**kwargs,
|
||||
) -> TaskResult | TaskFailure | None:
|
||||
_ = kwargs
|
||||
"""
|
||||
Dispatch a task to the appropriate handler.
|
||||
|
||||
|
|
@ -204,11 +269,31 @@ class TaskHandle:
|
|||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler.extract, task=task, config=self._config
|
||||
)
|
||||
except NotImplementedError:
|
||||
LOG.error(f"Handler '{handler.__name__}' does not implement extract().")
|
||||
except NotImplementedError as exc:
|
||||
LOG.exception(
|
||||
"Task handler '%s' does not implement extraction for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(message="Handler does not support extraction.")
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Handler '%s' extraction failed for task '%s'.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
|
|
@ -216,12 +301,27 @@ class TaskHandle:
|
|||
if extraction.error and extraction.error != extraction.message:
|
||||
msg = f"{msg} {extraction.error}"
|
||||
|
||||
LOG.error(f"Handler '{handler.__name__}' failed to extract items for task '{task.name}': {msg}")
|
||||
LOG.error(
|
||||
"Handler '%s' failed to extract items for task '%s' because %s.",
|
||||
handler.__name__,
|
||||
task.name,
|
||||
msg,
|
||||
extra={"task_id": task.id, "task_name": task.name, "handler_name": handler.__name__, "error": msg},
|
||||
)
|
||||
return extraction
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
f"Handler '{handler.__name__}' returned unexpected result type '{type(extraction).__name__}'.",
|
||||
"Handler '%s' returned unexpected result type '%s' for task '%s'.",
|
||||
handler.__name__,
|
||||
type(extraction).__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"result_type": type(extraction).__name__,
|
||||
},
|
||||
)
|
||||
return TaskFailure(
|
||||
message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__}
|
||||
|
|
@ -249,7 +349,18 @@ class TaskHandle:
|
|||
|
||||
for item in raw_items:
|
||||
if not isinstance(item, TaskItem):
|
||||
LOG.warning(f"Handler '{handler.__name__}' returned unexpected result: {item!r}")
|
||||
LOG.warning(
|
||||
"Handler '%s' returned unexpected item type '%s' for task '%s'.",
|
||||
handler.__name__,
|
||||
type(item).__name__,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"item_type": type(item).__name__,
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
url: str = item.url
|
||||
|
|
@ -258,7 +369,13 @@ class TaskHandle:
|
|||
|
||||
archive_id: str | None = item.archive_id
|
||||
if not archive_id:
|
||||
LOG.warning(f"'{task.name}': Item with URL '{url}' is missing an archive ID. Skipping.")
|
||||
LOG.warning(
|
||||
"Handler '%s' skipped '%s' for task '%s' because it has no archive ID.",
|
||||
handler.__name__,
|
||||
url,
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "handler_name": handler.__name__, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
if archive_id in queued:
|
||||
|
|
@ -287,12 +404,31 @@ class TaskHandle:
|
|||
if not filtered:
|
||||
if raw_items:
|
||||
LOG.debug(
|
||||
f"Handler '{handler.__name__}' produced '{len(raw_items)}' for '{task.name}' items, none queued after filtering."
|
||||
"Handler '%s' found %s item(s) for task '%s', but none were queued.",
|
||||
handler.__name__,
|
||||
len(raw_items),
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"raw_count": len(raw_items),
|
||||
},
|
||||
)
|
||||
return TaskResult(items=[], metadata=metadata)
|
||||
|
||||
LOG.info(
|
||||
f"Handler '{handler.__name__}' Found '{len(filtered)}' new items for '{task.name}' (raw={len(raw_items)})."
|
||||
"Handler '%s' found %s new item(s) for task '%s'.",
|
||||
handler.__name__,
|
||||
len(filtered),
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"handler_name": handler.__name__,
|
||||
"item_count": len(filtered),
|
||||
"raw_count": len(raw_items),
|
||||
},
|
||||
)
|
||||
|
||||
base_item = Item.format(
|
||||
|
|
@ -367,7 +503,12 @@ class TaskHandle:
|
|||
try:
|
||||
matched = await services.handle_async(handler=handler_cls.can_handle, task=task)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Handler '%s' inspection capability check failed for '%s'.",
|
||||
handler_cls.__name__,
|
||||
url,
|
||||
extra={"handler_name": handler_cls.__name__, "url": url, "exception_type": type(exc).__name__},
|
||||
)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
|
|
@ -405,7 +546,12 @@ class TaskHandle:
|
|||
metadata={**base_metadata, "supported": False},
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Handler '%s' manual inspection failed for '%s'.",
|
||||
handler_cls.__name__,
|
||||
url,
|
||||
extra={"handler_name": handler_cls.__name__, "url": url, "exception_type": type(exc).__name__},
|
||||
)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
|
|
@ -426,7 +572,11 @@ class TaskHandle:
|
|||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
f"Handler '{handler_cls.__name__}' returned unexpected result type '{type(extraction).__name__}' during inspection.",
|
||||
"Handler '%s' returned unexpected result type '%s' while inspecting '%s'.",
|
||||
handler_cls.__name__,
|
||||
type(extraction).__name__,
|
||||
url,
|
||||
extra={"handler_name": handler_cls.__name__, "url": url, "result_type": type(extraction).__name__},
|
||||
)
|
||||
extraction = TaskResult()
|
||||
|
||||
|
|
@ -436,11 +586,11 @@ class TaskHandle:
|
|||
|
||||
return TaskResult(items=list(extraction.items), metadata=combined_metadata)
|
||||
|
||||
def _discover(self) -> list[type]:
|
||||
def _discover(self) -> list[type[BaseHandler]]:
|
||||
"""Discover all available task handlers."""
|
||||
import app.features.tasks.definitions.handlers as handlers_pkg
|
||||
|
||||
handlers: list[type] = []
|
||||
handlers: list[type[BaseHandler]] = []
|
||||
|
||||
for _, module_name, _ in pkgutil.iter_modules(handlers_pkg.__path__):
|
||||
if module_name.startswith("_"):
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from app.features.tasks.definitions.results import TaskFailure, TaskResult
|
|||
from app.features.tasks.definitions.schemas import (
|
||||
Definition,
|
||||
EngineConfig,
|
||||
Parse,
|
||||
RequestConfig,
|
||||
ResponseConfig,
|
||||
TaskDefinition,
|
||||
|
|
@ -30,10 +31,12 @@ def test_build_def_payload():
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)"},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
},
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)"},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
|
|
@ -54,15 +57,17 @@ def test_build_def_container():
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"selector": ".cards .card",
|
||||
"fields": {
|
||||
"link": {"type": "css", "expression": ".card-header a", "attribute": "href"},
|
||||
"title": {"type": "css", "expression": ".card-header a", "attribute": "text"},
|
||||
},
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"selector": ".cards .card",
|
||||
"fields": {
|
||||
"link": {"type": "css", "expression": ".card-header a", "attribute": "href"},
|
||||
"title": {"type": "css", "expression": ".card-header a", "attribute": "text"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
|
|
@ -82,16 +87,18 @@ def test_build_def_json():
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
|
|
@ -112,11 +119,13 @@ def test_parse_items_basic():
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)", "attribute": None},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
"id": {"type": "css", "expression": ".article", "attribute": "data-id"},
|
||||
},
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)", "attribute": None},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
"id": {"type": "css", "expression": ".article", "attribute": "data-id"},
|
||||
}
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
|
|
@ -154,34 +163,36 @@ def test_parse_items_cards():
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "css",
|
||||
"selector": ".columns .card",
|
||||
"fields": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "href",
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "css",
|
||||
"selector": ".columns .card",
|
||||
"fields": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "href",
|
||||
},
|
||||
"title": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "text",
|
||||
},
|
||||
"poet": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:first-child a",
|
||||
"attribute": "text",
|
||||
},
|
||||
"category": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:nth-child(2) a",
|
||||
"attribute": "text",
|
||||
},
|
||||
},
|
||||
"title": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "text",
|
||||
},
|
||||
"poet": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:first-child a",
|
||||
"attribute": "text",
|
||||
},
|
||||
"category": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:nth-child(2) a",
|
||||
"attribute": "text",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
|
|
@ -249,17 +260,19 @@ def test_parse_items_json():
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "entries",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
"id": {"type": "jsonpath", "expression": "id"},
|
||||
},
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "entries",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
"id": {"type": "jsonpath", "expression": "id"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
|
|
@ -297,16 +310,18 @@ async def test_generic_task_handler_inspect(monkeypatch):
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
|
|
@ -351,16 +366,18 @@ def test_parse_items_json_list():
|
|||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "[]",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
parse=Parse.model_validate(
|
||||
{
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "[]",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class TestTaskDefinitionsRepository:
|
|||
await repo.create(_sample_definition("Alpha", priority=2))
|
||||
await repo.create(_sample_definition("Beta", priority=1))
|
||||
|
||||
items = await repo.list()
|
||||
items = await repo.all()
|
||||
assert len(items) == 2, "Should return two task definitions"
|
||||
assert [item.name for item in items] == ["Beta", "Alpha"], "Should sort by priority then name"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
from typing import Any
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from app.features.tasks.definitions.models import TaskDefinitionModel
|
||||
from app.features.tasks.definitions.schemas import Definition, TaskDefinition, TaskDefinitionSummary
|
||||
|
||||
|
||||
@overload
|
||||
def model_to_schema(model: TaskDefinitionModel, summary: Literal[False] = False) -> TaskDefinition: ...
|
||||
|
||||
|
||||
@overload
|
||||
def model_to_schema(model: TaskDefinitionModel, summary: Literal[True]) -> TaskDefinitionSummary: ...
|
||||
|
||||
|
||||
def model_to_schema(model: TaskDefinitionModel, summary: bool = False) -> TaskDefinition | TaskDefinitionSummary:
|
||||
"""
|
||||
Convert a TaskDefinitionModel to a TaskDefinition or TaskDefinitionSummary schema.
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.tasks.schemas import Task
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
|
|
@ -36,7 +36,11 @@ class Migration(FeatureMigration):
|
|||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
LOG.exception(
|
||||
"Failed to read tasks migration file '%s'. Ignoring.",
|
||||
self._source_file,
|
||||
extra={"file_path": str(self._source_file), "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
|
|
@ -54,7 +58,11 @@ class Migration(FeatureMigration):
|
|||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert task '%s': %s", normalized["name"], exc)
|
||||
LOG.exception(
|
||||
"Failed to insert task '%s'.",
|
||||
normalized["name"],
|
||||
extra={"task_name": normalized["name"], "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
LOG.info("Migrated %s task(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _model_from_payload(payload: dict[str, Any]) -> TaskModel:
|
||||
model = TaskModel()
|
||||
for key, value in payload.items():
|
||||
if not hasattr(model, key):
|
||||
msg = f"'{key}' is an invalid keyword argument for TaskModel"
|
||||
raise TypeError(msg)
|
||||
setattr(model, key, value)
|
||||
return model
|
||||
|
||||
|
||||
def _coerce_model(payload: TaskModel | dict[str, Any]) -> TaskModel:
|
||||
if isinstance(payload, TaskModel):
|
||||
return payload
|
||||
return _model_from_payload(payload)
|
||||
|
||||
|
||||
class TasksRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
def __init__(self, session: SessionFactory | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session = session or get_session
|
||||
self.session: SessionFactory = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
|
|
@ -38,7 +57,7 @@ class TasksRepository(metaclass=Singleton):
|
|||
def get_instance() -> TasksRepository:
|
||||
return TasksRepository()
|
||||
|
||||
async def list(self) -> list[TaskModel]:
|
||||
async def all(self) -> list[TaskModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).order_by(TaskModel.name.asc()))
|
||||
return list(result.scalars().all())
|
||||
|
|
@ -90,7 +109,7 @@ class TasksRepository(metaclass=Singleton):
|
|||
"""Get all enabled tasks."""
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskModel]] = await session.execute(
|
||||
select(TaskModel).where(TaskModel.enabled == True).order_by(TaskModel.name.asc()) # noqa: E712
|
||||
select(TaskModel).where(TaskModel.enabled).order_by(TaskModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
|
@ -102,11 +121,11 @@ class TasksRepository(metaclass=Singleton):
|
|||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, payload: TaskModel | dict) -> TaskModel:
|
||||
async def create(self, payload: TaskModel | dict[str, Any]) -> TaskModel:
|
||||
async with self.session() as session:
|
||||
model: TaskModel = TaskModel(**payload) if isinstance(payload, dict) else payload
|
||||
model = _coerce_model(payload)
|
||||
if model.id is not None:
|
||||
model.id = None
|
||||
model.id = None # ty: ignore
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Task with name '{model.name}' already exists."
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
|
|
@ -18,6 +17,7 @@ from app.library.ag_utils import ag
|
|||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
from app.library.Utils import get_channel_images, get_file, validate_url
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
|||
from pathlib import Path
|
||||
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
TIMER_SLOTS_PER_HOUR: int = 12
|
||||
|
||||
|
|
@ -94,7 +94,11 @@ def _offset_timer(timer: str, index: int) -> str:
|
|||
|
||||
return f"{minute} {hour} {dom} {month} {dow}"
|
||||
except Exception as e:
|
||||
LOG.warning(f"Failed to offset timer '{timer}': {e}")
|
||||
LOG.warning(
|
||||
"Failed to offset task timer.",
|
||||
extra={"timer": timer, "index": index, "error": str(e), "exception_type": type(e).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return timer
|
||||
|
||||
|
||||
|
|
@ -137,10 +141,15 @@ async def _get_info(url: str, preset: str) -> tuple[str | None, str | None]:
|
|||
|
||||
return (name, converted_url)
|
||||
except TimeoutError:
|
||||
LOG.debug(f"Timeout while inferring name from '{url}'")
|
||||
LOG.debug("Timeout while inferring name from '%s'.", url, extra={"url": url, "preset": preset})
|
||||
return (None, None)
|
||||
except Exception as e:
|
||||
LOG.debug(f"Failed to infer name from '{url}': {e}")
|
||||
LOG.debug(
|
||||
"Failed to infer a task name from '%s' because %s.",
|
||||
url,
|
||||
e,
|
||||
extra={"url": url, "preset": preset, "error": str(e)},
|
||||
)
|
||||
return (None, None)
|
||||
|
||||
|
||||
|
|
@ -234,12 +243,12 @@ async def tasks_add(
|
|||
|
||||
for idx, item in enumerate(data):
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning(f"Skipping item {idx}: not a dict")
|
||||
LOG.warning("Skipping item %s: not a dict.", idx, extra={"index": idx})
|
||||
continue
|
||||
|
||||
url = str(item.get("url", "")).strip()
|
||||
if not url:
|
||||
LOG.debug(f"Skipping item {idx}: empty URL")
|
||||
LOG.debug("Skipping item %s: empty URL.", idx, extra={"index": idx})
|
||||
continue
|
||||
|
||||
inferred_name, converted_url = await _get_info(url, item.get("preset", first_task.preset))
|
||||
|
|
@ -293,7 +302,11 @@ async def tasks_add(
|
|||
data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved),
|
||||
)
|
||||
except ValueError as exc:
|
||||
LOG.warning(f"Failed to create task {idx}: {exc}")
|
||||
LOG.warning(
|
||||
"Failed to create task from request item %s.",
|
||||
idx,
|
||||
extra={"index": idx, "error": str(exc), "exception_type": type(exc).__name__},
|
||||
)
|
||||
continue
|
||||
|
||||
if len(created_tasks) == 0:
|
||||
|
|
@ -482,7 +495,16 @@ async def task_handler_inspect(request: Request, handler: TaskHandle, encoder: E
|
|||
url=url, preset=preset, handler_name=handler_name, static_only=static_only
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to inspect task handler for '%s'.",
|
||||
url,
|
||||
extra={
|
||||
"handler_name": handler_name,
|
||||
"url": url,
|
||||
"static_only": static_only,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": "Failed to inspect handler.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
@ -631,7 +653,12 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
|
|||
if not save_path.exists():
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
LOG.warning(f"Failed to resolve final path from outtmpl. '{e!s}'")
|
||||
LOG.warning(
|
||||
"Failed to resolve the metadata output path for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "error": str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
info = {
|
||||
"id": ag(metadata, ["id", "channel_id"]),
|
||||
|
|
@ -649,7 +676,12 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
|
|||
data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
LOG.info(f"Generating metadata for task '{task.name}' in '{save_path!s}'")
|
||||
LOG.info(
|
||||
"Generating metadata for task '%s' in '%s'.",
|
||||
task.name,
|
||||
save_path,
|
||||
extra={"task_id": task.id, "task_name": task.name, "save_path": str(save_path)},
|
||||
)
|
||||
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
|
|
@ -673,8 +705,9 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
|
|||
xml_content += f' <uniqueid type="{NFOMakerPP._escape_text(info.get("id_type"))}" default="true">{NFOMakerPP._escape_text(info.get("id"))}</uniqueid>\n'
|
||||
if info.get("uploader"):
|
||||
xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n"
|
||||
if info.get("tags", []):
|
||||
for tag in info.get("tags", []):
|
||||
tags = info.get("tags", [])
|
||||
if isinstance(tags, list):
|
||||
for tag in tags:
|
||||
xml_content += f" <tag>{NFOMakerPP._escape_text(tag)}</tag>\n"
|
||||
if info.get("year"):
|
||||
xml_content += f" <year>{info.get('year')}</year>\n"
|
||||
|
|
@ -706,14 +739,25 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
|
|||
url: str | None = None
|
||||
try:
|
||||
url = thumbnails.get(key)
|
||||
LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
|
||||
LOG.info(
|
||||
"Fetching '%s' thumbnail for task '%s'.",
|
||||
key,
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "thumbnail": key, "url": url},
|
||||
)
|
||||
if not url:
|
||||
continue
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||
except ValueError:
|
||||
LOG.warning(f"Invalid thumbnail url '{url}'")
|
||||
except ValueError as exc:
|
||||
LOG.warning(
|
||||
"Task '%s' has an invalid '%s' thumbnail URL because %s.",
|
||||
task.name,
|
||||
key,
|
||||
exc,
|
||||
extra={"task_id": task.id, "task_name": task.name, "thumbnail": key, "url": url},
|
||||
)
|
||||
continue
|
||||
|
||||
resp = await client.request(
|
||||
|
|
@ -727,11 +771,27 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
|
|||
img_file.write_bytes(resp.content)
|
||||
thumbnails[key] = str(img_file.relative_to(config.download_path))
|
||||
except Exception as e:
|
||||
url_log = url or "unknown"
|
||||
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url_log}'. '{e!s}'")
|
||||
LOG.warning(
|
||||
"Failed to fetch '%s' thumbnail for task '%s'.",
|
||||
key,
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"thumbnail": key,
|
||||
"url": url,
|
||||
"error": str(e),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")
|
||||
LOG.warning(
|
||||
"Failed to fetch metadata thumbnails for task '%s'.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name, "error": str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except ValueError as e:
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.features.tasks.utils import cron_time
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
|
@ -14,7 +14,7 @@ from app.library.Singleton import Singleton
|
|||
if TYPE_CHECKING:
|
||||
from aiohttp import web
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("tasks.service")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Tasks(metaclass=Singleton):
|
||||
|
|
@ -51,7 +51,7 @@ class Tasks(metaclass=Singleton):
|
|||
pass
|
||||
|
||||
async def _load_tasks(self) -> None:
|
||||
tasks = await self._repo.list()
|
||||
tasks = await self._repo.all()
|
||||
|
||||
for task in tasks:
|
||||
if not task.timer or not task.enabled:
|
||||
|
|
@ -59,10 +59,23 @@ class Tasks(metaclass=Singleton):
|
|||
|
||||
try:
|
||||
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=f"task-cronjob-{task.id}")
|
||||
LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
|
||||
LOG.info(
|
||||
"Queued task '%s' to run at '%s'.",
|
||||
task.name,
|
||||
cron_time(task.timer),
|
||||
extra={"task_id": task.id, "task_name": task.name, "timer": task.timer},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to queue task '{task.name}'. '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to queue task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"timer": task.timer,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
async def _init_handlers_service(self, scheduler) -> None:
|
||||
"""Initialize the handlers service after migrations."""
|
||||
|
|
@ -95,7 +108,12 @@ class Tasks(metaclass=Singleton):
|
|||
|
||||
if task.timer and task.enabled:
|
||||
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task_id)
|
||||
LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
|
||||
LOG.info(
|
||||
"Queued task '%s' to run at '%s'.",
|
||||
task.name,
|
||||
cron_time(task.timer),
|
||||
extra={"task_id": task.id, "task_name": task.name, "timer": task.timer},
|
||||
)
|
||||
|
||||
async def _runner(self, task: TaskModel) -> None:
|
||||
"""
|
||||
|
|
@ -113,17 +131,29 @@ class Tasks(metaclass=Singleton):
|
|||
from app.library.ItemDTO import Item
|
||||
|
||||
timeNow: str = datetime.now(UTC).isoformat()
|
||||
task_id = task.id
|
||||
task_name = task.name
|
||||
try:
|
||||
if not (task := await self._repo.get(task.id)):
|
||||
LOG.info(f"Task '{task.name}' no longer exists.")
|
||||
current_task = await self._repo.get(task_id)
|
||||
if not current_task:
|
||||
LOG.info("Task '%s' no longer exists.", task_name, extra={"task_id": task_id, "task_name": task_name})
|
||||
return
|
||||
task = current_task
|
||||
|
||||
if not task.enabled:
|
||||
LOG.debug(f"Task '{task.name}' is disabled. Skipping execution.")
|
||||
LOG.debug(
|
||||
"Task '%s' is disabled. Skipping execution.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name},
|
||||
)
|
||||
return
|
||||
|
||||
if not task.url:
|
||||
LOG.error(f"Failed to dispatch '{task.name}'. No URL found.")
|
||||
LOG.error(
|
||||
"Failed to dispatch task '%s' because it has no URL.",
|
||||
task.name,
|
||||
extra={"task_id": task.id, "task_name": task.name},
|
||||
)
|
||||
return
|
||||
|
||||
started: float = time.time()
|
||||
|
|
@ -156,7 +186,19 @@ class Tasks(metaclass=Singleton):
|
|||
|
||||
timeNow = datetime.now(UTC).isoformat()
|
||||
ended: float = time.time()
|
||||
LOG.info(f"Task '{task.name}' completed at '{timeNow}' took '{ended - started:.2f}' seconds.")
|
||||
LOG.info(
|
||||
"Task '%s' completed in %.2f seconds.",
|
||||
task.name,
|
||||
ended - started,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"preset": preset,
|
||||
"elapsed_s": round(ended - started, 2),
|
||||
"status": status.get("status") if isinstance(status, dict) else None,
|
||||
},
|
||||
)
|
||||
|
||||
notify.emit(
|
||||
Events.TASK_DISPATCHED,
|
||||
|
|
@ -171,7 +213,17 @@ class Tasks(metaclass=Singleton):
|
|||
message=f"Task '{task.name}' completed in '{ended - started:.2f}'.",
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to execute scheduled task '%s'.",
|
||||
task.name,
|
||||
extra={
|
||||
"task_id": task.id,
|
||||
"task_name": task.name,
|
||||
"url": task.url,
|
||||
"time": timeNow,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
EventBus.get_instance().emit(
|
||||
Events.LOG_ERROR,
|
||||
data={"preset": task.preset},
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ class TestTasksRepository:
|
|||
await repo.create({"name": "Alice", "url": "https://example.com"})
|
||||
await repo.create({"name": "Bob", "url": "https://example.com"})
|
||||
|
||||
items = await repo.list()
|
||||
items = await repo.all()
|
||||
|
||||
assert items[0].name == "Alice", "Should be alphabetically first"
|
||||
assert items[1].name == "Bob", "Should be alphabetically second"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
|
@ -41,13 +42,13 @@ def patch_get_info(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
|
||||
|
||||
def _json_request(method: str, path: str, payload: object, *, match_info: dict[str, str] | None = None) -> web.Request:
|
||||
request = make_mocked_request(method, path, match_info=match_info or {}, app=SimpleNamespace())
|
||||
mock_request: Any = make_mocked_request(method, path, match_info=match_info or {}, app=SimpleNamespace())
|
||||
|
||||
async def _json() -> object:
|
||||
return payload
|
||||
|
||||
request.json = _json # type: ignore[attr-defined]
|
||||
return request
|
||||
mock_request.json = _json
|
||||
return mock_request
|
||||
|
||||
|
||||
class _Notify:
|
||||
|
|
@ -85,7 +86,7 @@ async def test_add_requires_timer_without_handler(repo) -> None:
|
|||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
assert b"requires a timer" in response.body
|
||||
assert await repo.list() == []
|
||||
assert await repo.all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -109,7 +110,7 @@ async def test_add_all_or_nothing(repo) -> None:
|
|||
|
||||
assert response.status == web.HTTPBadRequest.status_code
|
||||
assert b"requires a timer" in response.body
|
||||
assert await repo.list() == []
|
||||
assert await repo.all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -123,7 +124,7 @@ async def test_add_allows_handler_only(repo) -> None:
|
|||
response = await router.tasks_add(request, repo, Encoder(), _Notify(), _Handler(matched=True))
|
||||
|
||||
assert response.status == web.HTTPOk.status_code
|
||||
items = await repo.list()
|
||||
items = await repo.all()
|
||||
assert len(items) == 1
|
||||
assert items[0].name == "Handler Only"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import logging
|
||||
from app.library.log import get_logger
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def cron_time(timer: str) -> str:
|
||||
|
|
@ -12,5 +12,9 @@ def cron_time(timer: str) -> str:
|
|||
cs = CronSim(timer, datetime.now(UTC))
|
||||
return cs.explain()
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Failed to explain task timer '%s'.",
|
||||
timer,
|
||||
extra={"timer": timer, "exception_type": type(exc).__name__},
|
||||
)
|
||||
return timer
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import ThreadSafe
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("Archiver")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class _Entry:
|
||||
|
|
@ -169,12 +169,21 @@ class Archiver(metaclass=ThreadSafe):
|
|||
continue
|
||||
ids.add(s)
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed to read archive file '{key}': {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to read archive file '%s'.",
|
||||
key,
|
||||
extra={"archive_file": key, "operation": "read", "exception_type": type(e).__name__},
|
||||
)
|
||||
ids = set()
|
||||
|
||||
try:
|
||||
elapsed_ms: float = (time.perf_counter() - start) * 1000.0
|
||||
LOG.debug(f"_ensure_loaded took {elapsed_ms:.2f}ms (loaded={len(ids)})")
|
||||
LOG.debug(
|
||||
"_ensure_loaded took %.2fms (loaded=%s)",
|
||||
elapsed_ms,
|
||||
len(ids),
|
||||
extra={"archive_file": key, "elapsed_ms": round(elapsed_ms, 2), "loaded_count": len(ids)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -283,7 +292,17 @@ class Archiver(metaclass=ThreadSafe):
|
|||
f.write("".join(f"{x}\n" for x in new_ids))
|
||||
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed to write to archive file '{key}': {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to write %s item(s) to archive file '%s'.",
|
||||
len(new_ids),
|
||||
key,
|
||||
extra={
|
||||
"archive_file": key,
|
||||
"operation": "add",
|
||||
"item_count": len(new_ids),
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
entry.ids.update(new_ids)
|
||||
|
|
@ -335,7 +354,17 @@ class Archiver(metaclass=ThreadSafe):
|
|||
continue
|
||||
kept_lines.append(line)
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed reading archive for delete '{key}': {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to read archive file '%s' before deleting %s item(s).",
|
||||
key,
|
||||
len(remove_ids),
|
||||
extra={
|
||||
"archive_file": key,
|
||||
"operation": "delete_read",
|
||||
"item_count": len(remove_ids),
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
if not changed:
|
||||
|
|
@ -351,7 +380,17 @@ class Archiver(metaclass=ThreadSafe):
|
|||
with path.open("w", encoding="utf-8") as f:
|
||||
f.writelines(kept_lines)
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed writing archive after delete '{key}': {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to write archive file '%s' after deleting %s item(s).",
|
||||
key,
|
||||
len(remove_ids),
|
||||
extra={
|
||||
"archive_file": key,
|
||||
"operation": "delete_write",
|
||||
"item_count": len(remove_ids),
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
if entry.loaded:
|
||||
|
|
|
|||
|
|
@ -12,25 +12,43 @@ from aiohttp import web
|
|||
|
||||
from app.features.ytdlp.utils import _DATA, LogWrapper, get_archive_id
|
||||
from app.features.ytdlp.ytdlp import YTDLP
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("downloads.extractor")
|
||||
LOG = get_logger()
|
||||
|
||||
LIVE_REEXTRACT_STATUSES: set[str] = {"is_live", "post_live"}
|
||||
REEXTRACT_INFO_KEY = "_ytptube_reextract"
|
||||
|
||||
|
||||
def _ytdlp_logger(target: logging.Logger):
|
||||
def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||
return _YTDLPLogger(target)
|
||||
|
||||
|
||||
class _YTDLPLogger:
|
||||
def __init__(self, target: logging.Logger) -> None:
|
||||
self.target = target
|
||||
|
||||
def __call__(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None:
|
||||
kwargs.setdefault("stacklevel", 4)
|
||||
if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "):
|
||||
target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
|
||||
self.target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
|
||||
return
|
||||
|
||||
if level <= logging.DEBUG:
|
||||
target.info(msg, *args, **kwargs)
|
||||
self.target.info(msg, *args, **kwargs)
|
||||
return
|
||||
|
||||
target.log(level, msg, *args, **kwargs)
|
||||
self.target.log(level, msg, *args, **kwargs)
|
||||
|
||||
return _log
|
||||
|
||||
class _LogCapture:
|
||||
def __init__(self, logs: list[str]) -> None:
|
||||
self.logs = logs
|
||||
|
||||
def __call__(self, _: int, msg: str, *args: Any, **__: Any) -> None:
|
||||
self.logs.append(msg % args if args else msg)
|
||||
|
||||
|
||||
def _get_process_pool_kwargs() -> dict[str, Any]:
|
||||
|
|
@ -102,7 +120,7 @@ class ExtractorPool(metaclass=Singleton):
|
|||
def attach(self, app: web.Application) -> None:
|
||||
"""Attach the extractor pool to the application (no-op for now)."""
|
||||
app.on_shutdown.append(self.shutdown)
|
||||
Services.get_instance().add(__class__.__name__, self)
|
||||
Services.get_instance().add(type(self).__name__, self)
|
||||
|
||||
def _ensure_initialized(self, config: ExtractorConfig) -> None:
|
||||
"""
|
||||
|
|
@ -163,7 +181,10 @@ class ExtractorPool(metaclass=Singleton):
|
|||
self._pool.shutdown(wait=False, cancel_futures=False)
|
||||
LOG.debug("Extractor process pool shutdown complete")
|
||||
except Exception as exc:
|
||||
LOG.error("Error shutting down extractor process pool: %s", exc)
|
||||
LOG.exception(
|
||||
"Failed to shut down the extractor process pool.",
|
||||
extra={"exception_type": type(exc).__name__},
|
||||
)
|
||||
else:
|
||||
self._pool = None
|
||||
|
||||
|
|
@ -233,6 +254,23 @@ def _sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
|
|||
return sanitized
|
||||
|
||||
|
||||
def needs_reextract(info: dict[str, Any]) -> bool:
|
||||
return bool(info.get("is_live") or info.get("live_status") in LIVE_REEXTRACT_STATUSES)
|
||||
|
||||
|
||||
def _process_safe_info(info: dict[str, Any]) -> dict[str, Any]:
|
||||
if needs_reextract(info):
|
||||
info = dict(info)
|
||||
info[REEXTRACT_INFO_KEY] = True
|
||||
for key in ("formats", "requested_formats", "requested_downloads", "fragments"):
|
||||
info.pop(key, None)
|
||||
|
||||
if _is_picklable(info):
|
||||
return info
|
||||
|
||||
return YTDLP.sanitize_info(info, remove_private_keys=False)
|
||||
|
||||
|
||||
def extract_info_sync(
|
||||
config: dict[str, Any],
|
||||
url: str,
|
||||
|
|
@ -241,8 +279,9 @@ def extract_info_sync(
|
|||
follow_redirect: bool = False,
|
||||
sanitize_info: bool = False,
|
||||
capture_logs: int | None = None,
|
||||
process_safe: bool = False,
|
||||
**kwargs,
|
||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||
) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
"""
|
||||
Extract video information from a URL.
|
||||
|
||||
|
|
@ -254,10 +293,11 @@ def extract_info_sync(
|
|||
follow_redirect: Follow URL redirects
|
||||
sanitize_info: Sanitize the extracted information
|
||||
capture_logs: If provided (e.g., logging.WARNING), capture logs at this level.
|
||||
process_safe: Strip non-pickleable data for safe inter-process communication.
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
tuple[dict | None, list[dict]]: Extracted information and captured logs.
|
||||
tuple[dict | None, list[str]]: Extracted information and captured logs.
|
||||
|
||||
"""
|
||||
params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True}
|
||||
|
|
@ -286,7 +326,7 @@ def extract_info_sync(
|
|||
captured_logs: list[str] = kwargs.get("captured_logs", [])
|
||||
if capture_logs is not None:
|
||||
log_wrapper.add_target(
|
||||
target=lambda _, msg: captured_logs.append(msg),
|
||||
target=_LogCapture(captured_logs),
|
||||
level=capture_logs,
|
||||
name="log-capture",
|
||||
)
|
||||
|
|
@ -316,6 +356,7 @@ def extract_info_sync(
|
|||
follow_redirect=follow_redirect,
|
||||
sanitize_info=sanitize_info,
|
||||
capture_logs=capture_logs,
|
||||
process_safe=process_safe,
|
||||
captured_logs=captured_logs,
|
||||
**kwargs,
|
||||
)
|
||||
|
|
@ -334,6 +375,9 @@ def extract_info_sync(
|
|||
|
||||
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
|
||||
|
||||
if process_safe and isinstance(result, dict):
|
||||
result = _process_safe_info(result)
|
||||
|
||||
return (result, captured_logs)
|
||||
finally:
|
||||
logging.Logger.manager.loggerDict.pop(logger_name, None)
|
||||
|
|
@ -350,7 +394,7 @@ async def fetch_info(
|
|||
extractor_config: ExtractorConfig | None = None,
|
||||
budget_sleep: bool = False,
|
||||
**kwargs,
|
||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||
) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
"""
|
||||
Extract video information from a URL.
|
||||
|
||||
|
|
@ -370,7 +414,7 @@ async def fetch_info(
|
|||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
tuple[dict | None, list[dict]]: Extracted information and captured logs.
|
||||
tuple[dict | None, list[str]]: Extracted information and captured logs.
|
||||
|
||||
"""
|
||||
if extractor_config is None:
|
||||
|
|
@ -390,6 +434,8 @@ async def fetch_info(
|
|||
loop = asyncio.get_running_loop()
|
||||
|
||||
safe_config = _sanitize_config(config)
|
||||
safe_kwargs = _sanitize_picklable(kwargs)
|
||||
safe_kwargs.pop("process_safe", None)
|
||||
timeout = _sleep_timeout(safe_config, extractor_config.timeout, budget_sleep)
|
||||
|
||||
try:
|
||||
|
|
@ -408,7 +454,8 @@ async def fetch_info(
|
|||
follow_redirect=follow_redirect,
|
||||
sanitize_info=sanitize_info,
|
||||
capture_logs=capture_logs,
|
||||
**kwargs,
|
||||
process_safe=True,
|
||||
**safe_kwargs,
|
||||
),
|
||||
),
|
||||
timeout=timeout,
|
||||
|
|
@ -418,8 +465,23 @@ async def fetch_info(
|
|||
raise
|
||||
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.warning("extract_info process pool failed, falling back to thread pool url=%s error=%s", url, exc)
|
||||
LOG.warning(
|
||||
"yt-dlp extraction for '%s' fell back to the thread pool after the process pool failed.",
|
||||
url,
|
||||
extra={
|
||||
"url": url,
|
||||
"timeout": timeout,
|
||||
"concurrency": extractor_config.concurrency,
|
||||
"pool": "process",
|
||||
"fallback": "thread",
|
||||
"follow_redirect": follow_redirect,
|
||||
"no_archive": no_archive,
|
||||
"sanitize_info": sanitize_info,
|
||||
"capture_logs_level": capture_logs,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
return await asyncio.wait_for(
|
||||
fut=loop.run_in_executor(
|
||||
None,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("ytdlp.utils")
|
||||
from app.library.log import get_logger
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def patch_metadataparser() -> None:
|
||||
|
|
@ -14,7 +15,12 @@ def patch_metadataparser() -> None:
|
|||
from yt_dlp.postprocessor.metadataparser import MetadataParserPP
|
||||
from yt_dlp.utils import Namespace
|
||||
except Exception as exc:
|
||||
LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
|
||||
LOG.warning(
|
||||
"Unable to import yt-dlp metadata parser for patching: %s",
|
||||
exc,
|
||||
extra={"patch": "metadata_parser", "exception_type": type(exc).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
|
||||
|
|
@ -61,7 +67,12 @@ def patch_windows_popen_wait() -> None:
|
|||
try:
|
||||
from yt_dlp.utils import Popen
|
||||
except Exception as exc:
|
||||
LOG.warning(f"Unable to import yt_dlp Popen for patching: {exc!s}")
|
||||
LOG.warning(
|
||||
"Unable to import yt-dlp Popen for patching: %s",
|
||||
exc,
|
||||
extra={"patch": "windows_popen_wait", "exception_type": type(exc).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
if getattr(Popen, "_ytptube_wait_patched", False):
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ from app.library.cache import Cache
|
|||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import route
|
||||
from app.library.Utils import validate_url
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _get_preset_archive(preset: str) -> str | None:
|
||||
|
|
@ -37,7 +38,12 @@ def _get_preset_archive(preset: str) -> str | None:
|
|||
try:
|
||||
opts: dict = YTDLPOpts.get_instance().preset(preset).get_all()
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to build yt-dlp opts for preset '{preset}'. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to build yt-dlp options for preset '%s': %s.",
|
||||
preset,
|
||||
e,
|
||||
extra={"preset": preset, "exception_type": type(e).__name__},
|
||||
)
|
||||
return None
|
||||
|
||||
if not (archive_file := opts.get("download_archive")):
|
||||
|
|
@ -125,7 +131,19 @@ async def archiver_get(request: Request) -> Response:
|
|||
data={"file": archive_file, "items": data, "count": len(data)}, status=web.HTTPOk.status_code
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to read archive file '%s' for preset '%s': %s.",
|
||||
archive_file,
|
||||
preset,
|
||||
e,
|
||||
extra={
|
||||
"route": "api/archiver/",
|
||||
"action": "read_archive",
|
||||
"preset": preset,
|
||||
"archive_file": archive_file,
|
||||
"ids": ids,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to read archive file for preset '{preset}'.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
@ -171,7 +189,21 @@ async def archiver_add(request: Request) -> Response:
|
|||
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to add %s item(s) to archive file '%s' for preset '%s': %s.",
|
||||
len(items),
|
||||
archive_file,
|
||||
preset,
|
||||
e,
|
||||
extra={
|
||||
"route": "api/archiver/",
|
||||
"action": "add_archive_items",
|
||||
"preset": preset,
|
||||
"archive_file": archive_file,
|
||||
"item_count": len(items),
|
||||
"skip_check": skip_check,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to add items to archive for preset '{preset}'.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
@ -215,7 +247,20 @@ async def archiver_delete(request: Request) -> Response:
|
|||
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to delete %s item(s) from archive file '%s' for preset '%s': %s.",
|
||||
len(items),
|
||||
archive_file,
|
||||
preset,
|
||||
e,
|
||||
extra={
|
||||
"route": "api/archiver/",
|
||||
"action": "delete_archive_items",
|
||||
"preset": preset,
|
||||
"archive_file": archive_file,
|
||||
"item_count": len(items),
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": f"Failed to delete items from archive for preset '{preset}'.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
@ -241,7 +286,7 @@ async def convert(request: Request) -> Response:
|
|||
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
response = {"opts": {}, "output_template": None, "download_path": None}
|
||||
response: dict[str, Any] = {"opts": {}, "output_template": None, "download_path": None}
|
||||
|
||||
data = arg_converter(args, dumps=True)
|
||||
|
||||
|
|
@ -339,6 +384,8 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
|||
|
||||
if cache.has(key) and not request.query.get("force", False):
|
||||
data: Any | None = cache.get(key)
|
||||
if data is None:
|
||||
data = {}
|
||||
data["_cached"] = {
|
||||
"status": "hit",
|
||||
"preset": preset,
|
||||
|
|
@ -348,7 +395,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
|||
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
|
||||
"expires": data.get("_cached", {}).get("expires", time.time() + 300),
|
||||
}
|
||||
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
return web.json_response(text=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
|
||||
ytdlp_opts: dict = opts.get_all()
|
||||
|
||||
|
|
@ -405,10 +452,21 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
|||
|
||||
cache.set(key=key, value=data, ttl=300)
|
||||
|
||||
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
return web.json_response(text=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error encountered while getting video info for '{url}'. '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to get video info for '%s': %s.",
|
||||
url,
|
||||
e,
|
||||
extra={
|
||||
"route": "api/yt-dlp/url/info/",
|
||||
"action": "get_video_info",
|
||||
"url": url,
|
||||
"preset": preset,
|
||||
"cache_key": key if "key" in locals() else None,
|
||||
"has_cli_args": bool(cli_args),
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={
|
||||
"error": "failed to get video info.",
|
||||
|
|
@ -430,7 +488,7 @@ async def get_options() -> Response:
|
|||
"""
|
||||
from app.features.ytdlp.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(text=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code)
|
||||
|
||||
|
||||
@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids")
|
||||
|
|
@ -452,7 +510,11 @@ async def get_archive_ids(request: Request, config: Config) -> Response:
|
|||
response = []
|
||||
|
||||
for i, url in enumerate(data):
|
||||
dct = {"index": i, "url": url}
|
||||
dct: dict[str, Any] = {"index": i, "url": url}
|
||||
if not isinstance(url, str):
|
||||
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": "URL must be a string."})
|
||||
response.append(dct)
|
||||
continue
|
||||
try:
|
||||
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||
dct.update(get_archive_id(url))
|
||||
|
|
@ -496,7 +558,19 @@ async def make_command(request: Request, config: Config, encoder: Encoder) -> Re
|
|||
try:
|
||||
command, info = YTDLPCli(item=it, config=config).build()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to build yt-dlp command for '%s': %s.",
|
||||
it.url,
|
||||
e,
|
||||
extra={
|
||||
"route": "api/yt-dlp/command/",
|
||||
"action": "build_command",
|
||||
"url": it.url,
|
||||
"preset": it.preset,
|
||||
"has_cookies": bool(it.cookies),
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to build CLI command"},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
import logging
|
||||
import pickle
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.features.ytdlp.extractor import (
|
||||
ExtractorConfig,
|
||||
ExtractorPool,
|
||||
REEXTRACT_INFO_KEY,
|
||||
_LogCapture,
|
||||
_get_process_pool_kwargs,
|
||||
_process_safe_info,
|
||||
_ytdlp_logger,
|
||||
extract_info_sync,
|
||||
)
|
||||
from app.features.ytdlp.utils import LogWrapper
|
||||
|
||||
|
||||
class TestProcessPoolConfiguration:
|
||||
|
|
@ -117,6 +122,7 @@ class TestExtractInfo:
|
|||
{}, "https://example.com/video", debug=True, capture_logs=logging.WARNING
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["id"] == "test123"
|
||||
assert logs == ["[generic_browser] Browser fallback warning"]
|
||||
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen
|
||||
|
|
@ -150,11 +156,50 @@ class TestExtractInfo:
|
|||
{}, "https://example.com/video", debug=False, capture_logs=logging.WARNING
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["id"] == "test123"
|
||||
assert logs == ["[generic_browser] Browser fallback warning"]
|
||||
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen
|
||||
assert (logging.WARNING, "[generic_browser] Browser fallback warning") in seen
|
||||
|
||||
def test_process_safe_live(self) -> None:
|
||||
data = {
|
||||
"id": "live-id",
|
||||
"is_live": True,
|
||||
"formats": [{"format_id": "dash", "fragments": ({"url": "https://example.test/sq/1"} for _ in range(1))}],
|
||||
"requested_formats": [{"format_id": "dash"}],
|
||||
}
|
||||
|
||||
result = _process_safe_info(data)
|
||||
|
||||
assert result[REEXTRACT_INFO_KEY] is True
|
||||
assert "formats" not in result
|
||||
assert "requested_formats" not in result
|
||||
pickle.dumps(result)
|
||||
|
||||
def test_process_safe_post_live(self) -> None:
|
||||
data = {
|
||||
"id": "post-live-id",
|
||||
"live_status": "post_live",
|
||||
"formats": [{"format_id": "dash", "fragments": ({"url": "https://example.test/sq/1"} for _ in range(1))}],
|
||||
}
|
||||
|
||||
result = _process_safe_info(data)
|
||||
|
||||
assert result[REEXTRACT_INFO_KEY] is True
|
||||
assert "formats" not in result
|
||||
pickle.dumps(result)
|
||||
|
||||
def test_process_safe_lazy(self) -> None:
|
||||
from yt_dlp.utils import LazyList
|
||||
|
||||
data = {"id": "video-id", "formats": LazyList({"format_id": str(i)} for i in range(2))}
|
||||
|
||||
result = _process_safe_info(data)
|
||||
|
||||
assert result["formats"] == [{"format_id": "0"}, {"format_id": "1"}]
|
||||
pickle.dumps(result)
|
||||
|
||||
|
||||
class TestYtdlpLogger:
|
||||
def test_debug_prefix_uses_debug(self) -> None:
|
||||
|
|
@ -162,11 +207,26 @@ class TestYtdlpLogger:
|
|||
|
||||
_ytdlp_logger(logger)(logging.DEBUG, "[debug] hello")
|
||||
|
||||
logger.debug.assert_called_once_with("hello")
|
||||
logger.debug.assert_called_once_with("hello", stacklevel=4)
|
||||
|
||||
def test_screen_style_debug_uses_info(self) -> None:
|
||||
logger = MagicMock()
|
||||
|
||||
_ytdlp_logger(logger)(logging.DEBUG, "screen line")
|
||||
|
||||
logger.info.assert_called_once_with("screen line")
|
||||
logger.info.assert_called_once_with("screen line", stacklevel=4)
|
||||
|
||||
def test_targets_are_picklable(self) -> None:
|
||||
logs: list[str] = []
|
||||
wrapper = LogWrapper()
|
||||
wrapper.add_target(_ytdlp_logger(logging.getLogger("yt-dlp.test")), level=logging.DEBUG, name="yt-dlp.test")
|
||||
wrapper.add_target(_LogCapture(logs), level=logging.WARNING, name="log-capture")
|
||||
|
||||
pickle.dumps(wrapper)
|
||||
|
||||
def test_capture_formats_args(self) -> None:
|
||||
logs: list[str] = []
|
||||
|
||||
_LogCapture(logs)(logging.WARNING, "hello %s", "world")
|
||||
|
||||
assert logs == ["hello world"]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -219,7 +220,8 @@ class TestYTDLP:
|
|||
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
|
||||
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
|
||||
ytdlp = YTDLP(params={})
|
||||
ytdlp.to_screen = Mock()
|
||||
mock_obj: Any = ytdlp
|
||||
mock_obj.to_screen = Mock()
|
||||
|
||||
# Set interrupted flag
|
||||
ytdlp._interrupted = True
|
||||
|
|
@ -229,7 +231,7 @@ class TestYTDLP:
|
|||
# Should not call super method
|
||||
mock_super_delete.assert_not_called()
|
||||
# Should show message
|
||||
ytdlp.to_screen.assert_called_once_with("[info] Cancelled — skipping temp cleanup.")
|
||||
mock_obj.to_screen.assert_called_once_with("[info] Cancelled — skipping temp cleanup.")
|
||||
# Should return None
|
||||
assert result is None
|
||||
|
||||
|
|
@ -349,6 +351,14 @@ class TestYTDLP:
|
|||
assert len(result) == 8
|
||||
assert result.isalnum()
|
||||
|
||||
def test_exec_init(self) -> None:
|
||||
YTDLP(
|
||||
params={
|
||||
"compat_opts": set(),
|
||||
"postprocessors": [{"key": "Exec", "exec_cmd": "echo %(title)q"}],
|
||||
}
|
||||
)
|
||||
|
||||
def test_outtmpl_reuses_value(self) -> None:
|
||||
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})
|
||||
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ class TestYTDLPOpts:
|
|||
opts = YTDLPOpts()
|
||||
opts.get_all(keep=True)
|
||||
|
||||
mock_log.debug.assert_called_once_with(f"Final yt-dlp options: '{test_data!s}'.")
|
||||
mock_log.debug.assert_called_once_with("Prepared final yt-dlp options.", extra={"ytdlp_options": test_data})
|
||||
|
||||
def test_cookie_loading_error_handling(self):
|
||||
"""Test error handling when cookie loading fails."""
|
||||
|
|
@ -475,11 +475,12 @@ class TestYTDLPOpts:
|
|||
opts = YTDLPOpts()
|
||||
opts.preset("cookie_preset")
|
||||
|
||||
# Should log error but not raise
|
||||
mock_log.error.assert_called_once()
|
||||
error_args = mock_log.error.call_args[0][0]
|
||||
assert "Failed to load" in error_args
|
||||
assert "Cookie Preset" in error_args
|
||||
# Should log exception but not raise
|
||||
mock_log.exception.assert_called_once()
|
||||
error_fields = mock_log.exception.call_args.kwargs["extra"]
|
||||
assert error_fields["preset"] == "Cookie Preset"
|
||||
assert error_fields["has_cookies"] is True
|
||||
assert error_fields["exception_type"] == "ValueError"
|
||||
|
||||
assert "cookiefile" not in opts._preset_opts, "cookiefile should not be set"
|
||||
|
||||
|
|
@ -775,7 +776,7 @@ class TestYTDLPCli:
|
|||
from app.features.ytdlp.ytdlp_opts import YTDLPCli
|
||||
|
||||
with pytest.raises(ValueError, match="Expected Item instance"):
|
||||
YTDLPCli(item="not an item") # type: ignore
|
||||
YTDLPCli(item="not an item")
|
||||
|
||||
@patch("app.features.presets.service.Presets")
|
||||
@patch("app.features.ytdlp.ytdlp_opts.Config")
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ class TestLogWrapper:
|
|||
def test_add_target_type_validation(self) -> None:
|
||||
lw = LogWrapper()
|
||||
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
|
||||
lw.add_target(123) # type: ignore[arg-type]
|
||||
bad: Any = 123
|
||||
lw.add_target(bad)
|
||||
|
||||
def test_add_target_names(self) -> None:
|
||||
lw = LogWrapper()
|
||||
|
|
@ -90,6 +91,7 @@ class TestLogWrapper:
|
|||
assert len(cap.records) == 1
|
||||
assert cap.records[0].levelno == logging.INFO
|
||||
assert cap.records[0].getMessage() == "hello X"
|
||||
assert cap.records[0].funcName == "test_level_filtering_and_dispatch"
|
||||
assert len(calls) == 0
|
||||
|
||||
# WARNING hits both
|
||||
|
|
@ -127,7 +129,7 @@ class TestExtractYtdlpLogs:
|
|||
def test_extract_ytdlp_logs_with_filters(self):
|
||||
"""Test log extraction with filters."""
|
||||
logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"]
|
||||
filters = [re.compile(r"ERROR")]
|
||||
filters: list[str | re.Pattern[str]] = [re.compile(r"ERROR")]
|
||||
result = extract_ytdlp_logs(logs, filters)
|
||||
assert result == ["ERROR: Failed"]
|
||||
|
||||
|
|
@ -311,9 +313,12 @@ class TestGetThumbnail:
|
|||
def test_non_list(self):
|
||||
"""Test that None is returned for non-list input."""
|
||||
|
||||
assert get_thumbnail(None) is None
|
||||
assert get_thumbnail("not a list") is None
|
||||
assert get_thumbnail({"not": "list"}) is None
|
||||
bad_none: Any = None
|
||||
bad_str: Any = "not a list"
|
||||
bad_dict: Any = {"not": "list"}
|
||||
assert get_thumbnail(bad_none) is None
|
||||
assert get_thumbnail(bad_str) is None
|
||||
assert get_thumbnail(bad_dict) is None
|
||||
|
||||
def test_thumbnail_preference(self):
|
||||
"""Test that the thumbnail with highest preference is returned."""
|
||||
|
|
@ -348,6 +353,7 @@ class TestGetThumbnail:
|
|||
]
|
||||
|
||||
result = get_thumbnail(thumbnails)
|
||||
assert result is not None
|
||||
assert result["url"] == "with_pref.jpg"
|
||||
|
||||
def test_all_equal(self):
|
||||
|
|
@ -364,12 +370,15 @@ class TestGetThumbnail:
|
|||
class TestGetExtras:
|
||||
def test_none(self):
|
||||
"""Test that empty dict is returned for None input."""
|
||||
assert get_extras(None) == {}
|
||||
bad: Any = None
|
||||
assert get_extras(bad) == {}
|
||||
|
||||
def test_non_dict(self):
|
||||
"""Test that empty dict is returned for non-dict input."""
|
||||
assert get_extras("not a dict") == {}
|
||||
assert get_extras([]) == {}
|
||||
bad_str: Any = "not a dict"
|
||||
bad_list: Any = []
|
||||
assert get_extras(bad_str) == {}
|
||||
assert get_extras(bad_list) == {}
|
||||
|
||||
def test_extracts_video_information(self):
|
||||
"""Test extracting information from a video entry."""
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ from typing import Any
|
|||
|
||||
from app.features.ytdlp.patches import apply_ytdlp_patches
|
||||
from app.features.ytdlp.ytdlp import YTDLP
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import merge_dict, timed_lru_cache
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("ytdlp.utils")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class _DATA:
|
||||
|
|
@ -100,12 +101,12 @@ class LogWrapper:
|
|||
name (str|None): The name of the logging target. Defaults to None.
|
||||
|
||||
"""
|
||||
if not isinstance(target, logging.Logger | Callable):
|
||||
if not isinstance(target, logging.Logger) and not callable(target):
|
||||
msg = "Target must be a logging.Logger instance or a callable."
|
||||
raise TypeError(msg)
|
||||
|
||||
if name is None:
|
||||
name = target.name if isinstance(target, logging.Logger) else target.__name__
|
||||
name = target.name if isinstance(target, logging.Logger) else getattr(target, "__name__", "callable")
|
||||
|
||||
self.targets.append(
|
||||
LogTarget(
|
||||
|
|
@ -130,9 +131,11 @@ class LogWrapper:
|
|||
if level < target.level:
|
||||
continue
|
||||
|
||||
if target.logger:
|
||||
target.target.log(level, msg, *args, **kwargs)
|
||||
else:
|
||||
if isinstance(target.target, logging.Logger):
|
||||
log_kwargs: dict[str, Any] = {**kwargs}
|
||||
log_kwargs.setdefault("stacklevel", 3)
|
||||
target.target.log(level, msg, *args, **log_kwargs)
|
||||
elif callable(target.target):
|
||||
target.target(level, msg, *args, **kwargs)
|
||||
|
||||
def debug(self, msg, *args, **kwargs):
|
||||
|
|
@ -176,13 +179,17 @@ def arg_converter(
|
|||
|
||||
create_parser = yt_dlp.options.create_parser
|
||||
|
||||
def _default_opts(args: str):
|
||||
def _default_opts(args: str | list[str]):
|
||||
patched_parser = create_parser()
|
||||
|
||||
def patched_create_parser():
|
||||
return patched_parser
|
||||
|
||||
try:
|
||||
yt_dlp.options.create_parser = lambda: patched_parser
|
||||
yt_dlp.options.__dict__["create_parser"] = patched_create_parser
|
||||
return yt_dlp.parse_options(args)
|
||||
finally:
|
||||
yt_dlp.options.create_parser = create_parser
|
||||
yt_dlp.options.__dict__["create_parser"] = create_parser
|
||||
|
||||
apply_ytdlp_patches()
|
||||
|
||||
|
|
@ -235,7 +242,7 @@ def arg_converter(
|
|||
return diff
|
||||
|
||||
|
||||
def extract_ytdlp_logs(logs: list[str], filters: list[str | re.Pattern] = None) -> list[str]:
|
||||
def extract_ytdlp_logs(logs: list[str], filters: list[str | re.Pattern[str]] | None = None) -> list[str]:
|
||||
"""
|
||||
Extract yt-dlp log lines matching built-in filters plus any extras.
|
||||
|
||||
|
|
@ -329,7 +336,7 @@ def get_ytdlp(params: dict | None = None) -> YTDLP:
|
|||
return _DATA.YTDLP_INFO_CLS
|
||||
|
||||
|
||||
def get_thumbnail(thumbnails: list) -> str | None:
|
||||
def get_thumbnail(thumbnails: list) -> dict | None:
|
||||
"""
|
||||
Extract thumbnail URL from a yt-dlp entry.
|
||||
|
||||
|
|
@ -337,7 +344,7 @@ def get_thumbnail(thumbnails: list) -> str | None:
|
|||
thumbnails (list): The list of thumbnail dictionaries from yt-dlp entry.
|
||||
|
||||
Returns:
|
||||
str | None: The thumbnail URL if available, otherwise None.
|
||||
dict | None: The best thumbnail dict if available, otherwise None.
|
||||
|
||||
"""
|
||||
if not thumbnails or not isinstance(thumbnails, list):
|
||||
|
|
@ -378,7 +385,7 @@ def get_extras(entry: dict, kind: str = "video") -> dict:
|
|||
extras[f"playlist_{property}"] = val
|
||||
|
||||
if thumbnail := get_thumbnail(entry.get("thumbnails", [])):
|
||||
extras["thumbnail"] = thumbnail.get("url")
|
||||
extras["thumbnail"] = thumbnail.get("url") if isinstance(thumbnail, dict) else thumbnail
|
||||
elif thumbnail := entry.get("thumbnail"):
|
||||
extras["thumbnail"] = thumbnail
|
||||
|
||||
|
|
@ -436,7 +443,7 @@ def get_archive_id(url: str) -> dict[str, str | None]:
|
|||
}
|
||||
|
||||
"""
|
||||
idDict: dict[str, None] = {
|
||||
idDict: dict[str, str | None] = {
|
||||
"id": None,
|
||||
"ie_key": None,
|
||||
"archive_id": None,
|
||||
|
|
@ -460,8 +467,12 @@ def get_archive_id(url: str) -> dict[str, str | None]:
|
|||
idDict["archive_id"] = make_archive_id(_ie, temp_id)
|
||||
break
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error getting archive ID: {e}")
|
||||
LOG.exception(
|
||||
"Failed to get archive ID for '%s' with extractor '%s'.",
|
||||
url,
|
||||
key,
|
||||
extra={"url": url, "extractor": key, "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
return idDict
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
# flake8: noqa: F401, RUF100, W291, I001
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -10,6 +9,7 @@ from yt_dlp.utils import make_archive_id
|
|||
from app.features.ytdlp.outtmpl import rewrite_outtmpl
|
||||
from app.features.ytdlp.patches import apply_ytdlp_patches
|
||||
from app.library.cf_solver_handler import set_cf_handler
|
||||
from app.library.log import get_logger
|
||||
|
||||
|
||||
class _ArchiveProxy:
|
||||
|
|
@ -67,7 +67,7 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
postprocessors.value.update({"NFOMakerPP": NFOMakerPP})
|
||||
YTDLP._registered = True
|
||||
except Exception:
|
||||
logging.getLogger("ytdlp.wrapper").exception("Failed to register yt-dlp plugins")
|
||||
get_logger().exception("Failed to register yt-dlp plugins")
|
||||
|
||||
# Avoid yt-dlp preloading the archive file by stripping the param first
|
||||
orig_file = None
|
||||
|
|
@ -81,6 +81,9 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
except Exception:
|
||||
patched_params = params
|
||||
|
||||
self._ytptube_outtmpl_info: dict[str, Any] | None = None
|
||||
self._ytptube_outtmpl_cache: dict[str, Any] = {}
|
||||
|
||||
super().__init__(params=patched_params, auto_init=auto_init)
|
||||
|
||||
# Restore param and replace upstream archive set with our proxy
|
||||
|
|
@ -90,8 +93,6 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
self._ytptube_outtmpl_info: dict[str, Any] | None = None
|
||||
self._ytptube_outtmpl_cache: dict[str, Any] = {}
|
||||
self.archive = _ArchiveProxy(orig_file)
|
||||
|
||||
def _delete_downloaded_files(self, *args, **kwargs) -> None:
|
||||
|
|
@ -105,14 +106,14 @@ class YTDLP(yt_dlp.YoutubeDL):
|
|||
self._ytptube_outtmpl_info = None
|
||||
self._ytptube_outtmpl_cache = {}
|
||||
|
||||
def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False):
|
||||
def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False, *, _exec=False):
|
||||
if self._ytptube_outtmpl_info is not info_dict:
|
||||
self._ytptube_outtmpl_info = info_dict
|
||||
self._ytptube_outtmpl_cache = {}
|
||||
|
||||
outtmpl, enriched = rewrite_outtmpl(outtmpl, info_dict, cache=self._ytptube_outtmpl_cache)
|
||||
|
||||
return super().prepare_outtmpl(outtmpl, enriched, sanitize=sanitize)
|
||||
return super().prepare_outtmpl(outtmpl, enriched, sanitize=sanitize, _exec=_exec)
|
||||
|
||||
def process_info(self, info_dict):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -6,9 +5,10 @@ from typing import Any
|
|||
from app.features.presets.schemas import Preset
|
||||
from app.features.ytdlp.utils import arg_converter
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import calc_download_path, create_cookies_file, merge_dict
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("ytdlp.ytdlp_opts")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class ARGSMerger:
|
||||
|
|
@ -50,12 +50,12 @@ class ARGSMerger:
|
|||
"""
|
||||
return str(self)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
def as_dict(self) -> list[str]:
|
||||
"""
|
||||
Get all the options as a dict.
|
||||
Get all the options as a shell argument list.
|
||||
|
||||
Returns:
|
||||
dict: The options as a dict
|
||||
list[str]: The options as shell arguments.
|
||||
|
||||
"""
|
||||
return shlex.split(shlex.join(self.args))
|
||||
|
|
@ -335,7 +335,12 @@ class YTDLPOpts:
|
|||
if file and file.exists():
|
||||
self._preset_opts["cookiefile"] = str(file)
|
||||
except ValueError as e:
|
||||
LOG.error(f"Failed to load '{preset.name}' cookies. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to load cookies for preset '%s': %s.",
|
||||
preset.name,
|
||||
e,
|
||||
extra={"preset": preset.name, "has_cookies": True, "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
if preset.template:
|
||||
self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter}
|
||||
|
|
@ -392,6 +397,30 @@ class YTDLPOpts:
|
|||
|
||||
data: dict = merge_dict(user_cli, merge_dict(self._item_opts, merge_dict(self._preset_opts, default_opts)))
|
||||
|
||||
if self._config.disable_exec:
|
||||
stripped: list[str] = []
|
||||
if data.pop("netrc_cmd", None):
|
||||
stripped.append("netrc_cmd")
|
||||
|
||||
if "postprocessors" in data:
|
||||
exec_pps = [
|
||||
pp for pp in data["postprocessors"] if isinstance(pp, dict) and pp.get("key", "").startswith("Exec")
|
||||
]
|
||||
if exec_pps:
|
||||
stripped.extend(pp.get("key") for pp in exec_pps)
|
||||
data["postprocessors"] = [
|
||||
pp
|
||||
for pp in data["postprocessors"]
|
||||
if not (isinstance(pp, dict) and pp.get("key", "").startswith("Exec"))
|
||||
]
|
||||
|
||||
if stripped:
|
||||
LOG.warning(
|
||||
"Stripped %d dangerous options from yt-dlp options.",
|
||||
len(stripped),
|
||||
extra={"stripped": stripped, "reason": "YTP_DISABLE_EXEC is enabled"},
|
||||
)
|
||||
|
||||
if not keep:
|
||||
self.reset()
|
||||
|
||||
|
|
@ -403,7 +432,7 @@ class YTDLPOpts:
|
|||
data["format"] = data["format"][1:]
|
||||
|
||||
if self._config.debug:
|
||||
LOG.debug(f"Final yt-dlp options: '{data!s}'.")
|
||||
LOG.debug("Prepared final yt-dlp options.", extra={"ytdlp_options": data})
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import threading
|
||||
from queue import Empty, Queue
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .Services import Services
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("BackgroundWorker")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class CloseThread:
|
||||
|
|
@ -28,7 +29,7 @@ class BackgroundWorker(metaclass=Singleton):
|
|||
"The queue to hold the tasks."
|
||||
self.running = True
|
||||
"Whether the background worker is running or not."
|
||||
self.thread: threading.Thread = None
|
||||
self.thread: threading.Thread | None = None
|
||||
"The thread that runs the background worker."
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -39,19 +40,23 @@ class BackgroundWorker(metaclass=Singleton):
|
|||
Services.get_instance().add("background_worker", self)
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
|
||||
LOG.debug("Starting background worker...")
|
||||
LOG.debug("Started background worker thread.")
|
||||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
self.running = False
|
||||
try:
|
||||
LOG.debug("Shutting down background worker...")
|
||||
LOG.debug("Stopping background worker thread.")
|
||||
self.queue.put((CloseThread, (), {}))
|
||||
self.thread.join(timeout=5)
|
||||
LOG.debug("Background worker has been shut down.")
|
||||
if self.thread:
|
||||
self.thread.join(timeout=5)
|
||||
LOG.debug("Background worker thread has been shut down.")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to shut down background worker: {e}")
|
||||
LOG.exception(
|
||||
"Failed to shut down background worker thread.",
|
||||
extra={"exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
def _run(self):
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
|
@ -79,8 +84,16 @@ class BackgroundWorker(metaclass=Singleton):
|
|||
if inspect.iscoroutine(result):
|
||||
loop.call_soon_threadsafe(loop.create_task, result)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to run '{fn.__name__}'. {e!s}")
|
||||
function = getattr(fn, "__name__", fn.__class__.__name__)
|
||||
LOG.exception(
|
||||
"Failed to run background worker function '%s'.",
|
||||
function,
|
||||
extra={
|
||||
"function": function,
|
||||
"thread_name": threading.current_thread().name,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
|
|
@ -88,9 +101,12 @@ class BackgroundWorker(metaclass=Singleton):
|
|||
loop.call_soon_threadsafe(loop.stop)
|
||||
loop_thread.join(timeout=5)
|
||||
loop.close()
|
||||
LOG.debug("Event loop has been stopped and closed.")
|
||||
LOG.debug("Stopped background worker event loop.")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to stop the event loop: {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to stop background worker event loop.",
|
||||
extra={"thread_name": loop_thread.name, "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
def submit(self, fn, *args, **kwargs):
|
||||
self.queue.put((fn, args, kwargs))
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import copy
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable
|
||||
from enum import Enum
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .downloads import Download
|
||||
from .ItemDTO import ItemDTO
|
||||
from .operations import matches_condition
|
||||
from .sqlite_store import SqliteStore
|
||||
|
||||
LOG = logging.getLogger("datastore")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class StoreType(str, Enum):
|
||||
|
|
@ -165,6 +166,12 @@ class DataStore:
|
|||
def items(self):
|
||||
return self._dict.items()
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self._dict
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._dict)
|
||||
|
||||
async def put(self, value: Download, no_notify: bool = False) -> Download:
|
||||
_ = no_notify
|
||||
self._dict[value.info._id] = value
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.features.core.utils import gen_random
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .BackgroundWorker import BackgroundWorker
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("events")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Events:
|
||||
|
|
@ -34,6 +34,7 @@ class Events:
|
|||
|
||||
ITEM_ADDED: str = "item_added"
|
||||
ITEM_UPDATED: str = "item_updated"
|
||||
ITEM_PROGRESS: str = "item_progress"
|
||||
ITEM_COMPLETED: str = "item_completed"
|
||||
ITEM_CANCELLED: str = "item_cancelled"
|
||||
ITEM_DELETED: str = "item_deleted"
|
||||
|
|
@ -86,6 +87,7 @@ class Events:
|
|||
Events.LOG_SUCCESS,
|
||||
Events.ITEM_ADDED,
|
||||
Events.ITEM_UPDATED,
|
||||
Events.ITEM_PROGRESS,
|
||||
Events.ITEM_CANCELLED,
|
||||
Events.ITEM_DELETED,
|
||||
Events.ITEM_BULK_DELETED,
|
||||
|
|
@ -103,7 +105,7 @@ class Events:
|
|||
list: The list of debug events.
|
||||
|
||||
"""
|
||||
return [Events.ITEM_UPDATED]
|
||||
return [Events.ITEM_UPDATED, Events.ITEM_PROGRESS]
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
|
@ -242,7 +244,12 @@ class EventBus(metaclass=Singleton):
|
|||
event = Events.frontend()
|
||||
else:
|
||||
if event not in all_events:
|
||||
LOG.error(f"'{name}' attempted to listen on '{event}' which does not exist.")
|
||||
LOG.error(
|
||||
"Listener '%s' tried to subscribe to unknown event '%s'.",
|
||||
name,
|
||||
event,
|
||||
extra={"listener": name, "event": event},
|
||||
)
|
||||
return self
|
||||
|
||||
event = [event]
|
||||
|
|
@ -252,7 +259,12 @@ class EventBus(metaclass=Singleton):
|
|||
|
||||
for e in event:
|
||||
if e not in all_events:
|
||||
LOG.error(f"'{name}' attempted to listen on '{e}' which does not exist.")
|
||||
LOG.error(
|
||||
"Listener '%s' tried to subscribe to unknown event '%s'.",
|
||||
name,
|
||||
e,
|
||||
extra={"listener": name, "event": e},
|
||||
)
|
||||
continue
|
||||
|
||||
if e not in self._listeners:
|
||||
|
|
@ -261,7 +273,7 @@ class EventBus(metaclass=Singleton):
|
|||
self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
|
||||
self._listeners[e].append((name, EventListener(name, callback)))
|
||||
|
||||
LOG.debug(f"'{name}' subscribed to '{event}'.")
|
||||
LOG.debug("Listener '%s' has subscribed.", name, extra={"listener": name, "events": event})
|
||||
|
||||
return self
|
||||
|
||||
|
|
@ -289,7 +301,7 @@ class EventBus(metaclass=Singleton):
|
|||
events.append(e)
|
||||
|
||||
if len(events) > 0:
|
||||
LOG.debug(f"'{name}' unsubscribed from '{events}'.")
|
||||
LOG.debug("Listener '%s' unsubscribed from '%s'.", name, events, extra={"listener": name, "events": events})
|
||||
|
||||
return self
|
||||
|
||||
|
|
@ -316,7 +328,17 @@ class EventBus(metaclass=Singleton):
|
|||
ev = Event(event=event, title=title, message=message, data=data)
|
||||
|
||||
if self.debug or event not in Events.only_debug():
|
||||
LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data})
|
||||
LOG.debug(
|
||||
"Delivering '%s' event to %s listener(s).",
|
||||
ev.event,
|
||||
len(self._listeners[event]),
|
||||
extra={
|
||||
"event_id": ev.id,
|
||||
"event": ev.event,
|
||||
"handler_count": len(self._listeners[event]),
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
|
@ -329,16 +351,35 @@ class EventBus(metaclass=Singleton):
|
|||
if asyncio.iscoroutine(coro):
|
||||
await coro
|
||||
else:
|
||||
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
|
||||
LOG.warning(
|
||||
"Async handler '%s' returned '%s' instead of a coroutine.",
|
||||
handler.name,
|
||||
type(coro).__name__,
|
||||
extra={"handler": handler.name, "returned_type": type(coro).__name__},
|
||||
)
|
||||
else:
|
||||
await self._call(handler, ev, kwargs)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to emit '%s' event to listener '%s'.",
|
||||
ev.event,
|
||||
handler.name,
|
||||
extra={
|
||||
"event_id": ev.id,
|
||||
"event": ev.event,
|
||||
"handler": handler.name,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
loop.create_task(execute_handlers())
|
||||
except RuntimeError:
|
||||
LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers")
|
||||
LOG.debug(
|
||||
"No event loop detected; offloading '%s' event to %s background listener(s).",
|
||||
ev.event,
|
||||
len(self._listeners[event]),
|
||||
extra={"event_id": ev.id, "event": ev.event, "handler_count": len(self._listeners[event])},
|
||||
)
|
||||
for _, handler in self._listeners[event]:
|
||||
try:
|
||||
if not self._offload:
|
||||
|
|
@ -346,8 +387,17 @@ class EventBus(metaclass=Singleton):
|
|||
|
||||
self._offload.submit(handler.handle, ev, **kwargs)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to offload '%s' event to listener '%s'.",
|
||||
ev.event,
|
||||
handler.name,
|
||||
extra={
|
||||
"event_id": ev.id,
|
||||
"event": ev.event,
|
||||
"handler": handler.name,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import base64
|
||||
import hmac
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Awaitable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, RequestHandler, Response
|
||||
from aiohttp.typedefs import Handler, Middleware
|
||||
from aiohttp.web import Request, Response
|
||||
from aiohttp.web_log import AccessLogger
|
||||
from aiohttp.web_request import BaseRequest
|
||||
from aiohttp.web_response import StreamResponse
|
||||
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
|
||||
from .cache import Cache
|
||||
|
|
@ -19,7 +23,33 @@ from .Events import EventBus
|
|||
from .router import RouteType, get_routes
|
||||
from .Utils import decrypt_data, encrypt_data, get_file, load_modules
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("http_api")
|
||||
LOG = get_logger("http")
|
||||
|
||||
|
||||
class HttpAccessLogger(AccessLogger):
|
||||
def log(self, request: BaseRequest, response: StreamResponse, time: float) -> None:
|
||||
try:
|
||||
fmt_info = self._format_line(request, response, time)
|
||||
|
||||
values: list[object] = []
|
||||
extra: dict[str, Any] = {"elapsed_ms": round(time * 1000.0, 2)}
|
||||
for key, value in fmt_info:
|
||||
values.append(value)
|
||||
|
||||
if isinstance(key, str):
|
||||
extra[key] = value
|
||||
else:
|
||||
parent, child = key
|
||||
group: dict[str, Any] = extra.get(parent, {}) # type: ignore[assignment]
|
||||
if not isinstance(group, dict):
|
||||
group = {}
|
||||
group[child] = value
|
||||
extra[parent] = group
|
||||
|
||||
message = self._log_format % tuple(values)
|
||||
self.logger.info(message, extra=extra)
|
||||
except Exception:
|
||||
self.logger.exception("Error in logging")
|
||||
|
||||
|
||||
class HttpAPI:
|
||||
|
|
@ -89,7 +119,10 @@ class HttpAPI:
|
|||
try:
|
||||
app.on_response_prepare.append(on_prepare)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to register response preparation middleware.",
|
||||
extra={"operation": "register_on_response_prepare", "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
|
||||
|
|
@ -130,7 +163,13 @@ class HttpAPI:
|
|||
route.path = f"{base_path}/{route.path.lstrip('/')}"
|
||||
|
||||
if self.config.debug:
|
||||
LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.")
|
||||
LOG.debug(
|
||||
"Adding route '%s' %s: %s.",
|
||||
route.name,
|
||||
route.method,
|
||||
route.path,
|
||||
extra={"route_name": route.name, "method": route.method, "path": route.path},
|
||||
)
|
||||
|
||||
app.router.add_route(route.method, route.path, handler=_handle(route.handler), name=route.name)
|
||||
|
||||
|
|
@ -141,7 +180,7 @@ class HttpAPI:
|
|||
registered_options.append(route.path)
|
||||
|
||||
@staticmethod
|
||||
def basic_auth(username: str, password: str) -> Awaitable:
|
||||
def basic_auth(username: str, password: str) -> Middleware:
|
||||
"""
|
||||
Middleware to handle basic authentication.
|
||||
|
||||
|
|
@ -155,7 +194,7 @@ class HttpAPI:
|
|||
"""
|
||||
|
||||
@web.middleware
|
||||
async def auth_handler(request: Request, handler: RequestHandler) -> Response:
|
||||
async def auth_handler(request: Request, handler: Handler) -> StreamResponse:
|
||||
# if OPTIONS request, skip auth
|
||||
if "OPTIONS" == request.method:
|
||||
return await handler(request)
|
||||
|
|
@ -168,12 +207,19 @@ class HttpAPI:
|
|||
auth_cookie = request.cookies.get("auth")
|
||||
if auth_cookie is not None:
|
||||
try:
|
||||
data = decrypt_data(auth_cookie, key=Config.get_instance().secret_key)
|
||||
data = decrypt_data(auth_cookie, key=cast("bytes", Config.get_instance().secret_key))
|
||||
if data is not None:
|
||||
data = base64.b64encode(data.encode()).decode()
|
||||
auth_header = f"Basic {data}"
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to decrypt authentication cookie.",
|
||||
extra={
|
||||
"route": str(request.rel_url),
|
||||
"method": request.method,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
if auth_header is None:
|
||||
return web.json_response(
|
||||
|
|
@ -207,7 +253,7 @@ class HttpAPI:
|
|||
},
|
||||
)
|
||||
|
||||
response: Response = await handler(request)
|
||||
response: StreamResponse = await handler(request)
|
||||
|
||||
contentType: str | None = response.headers.get("content-type", None)
|
||||
if contentType and not contentType.startswith("text/html"):
|
||||
|
|
@ -218,7 +264,7 @@ class HttpAPI:
|
|||
"auth",
|
||||
encrypt_data(
|
||||
f"{user_name}:{user_password}",
|
||||
key=Config.get_instance().secret_key,
|
||||
key=cast("bytes", Config.get_instance().secret_key),
|
||||
),
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
expires=(datetime.now(UTC) + timedelta(days=7)).strftime("%a, %d %b %Y %H:%M:%S GMT"),
|
||||
|
|
@ -226,16 +272,20 @@ class HttpAPI:
|
|||
samesite="Strict",
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to set authentication cookie for '%s'.",
|
||||
request.rel_url,
|
||||
extra={"route": str(request.rel_url), "method": request.method, "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return auth_handler
|
||||
|
||||
@staticmethod
|
||||
def middle_wares(app: web.Application, base_path: str, download_path: str) -> Awaitable:
|
||||
def middle_wares(app: web.Application, base_path: str, download_path: str) -> Middleware:
|
||||
@web.middleware
|
||||
async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
|
||||
async def middleware_handler(request: Request, handler: Handler) -> StreamResponse:
|
||||
static_path = str(app.router["download_static"].url_for(filename=""))
|
||||
if request.path.startswith(static_path):
|
||||
realFile, status = get_file(
|
||||
|
|
@ -254,12 +304,13 @@ class HttpAPI:
|
|||
},
|
||||
)
|
||||
|
||||
response: Response = await handler(request)
|
||||
response: StreamResponse = await handler(request)
|
||||
|
||||
contentType: str = str(response.headers.get("content-type", ""))
|
||||
if contentType.startswith("text/html") and getattr(response, "_path", None):
|
||||
rewrite_path: str = base_path.rstrip("/")
|
||||
async with await anyio.open_file(response._path, "rb") as f:
|
||||
response_path = cast("Any", response)._path
|
||||
async with await anyio.open_file(response_path, "rb") as f:
|
||||
content = await f.read()
|
||||
content: str = (
|
||||
content.decode("utf-8")
|
||||
|
|
@ -277,13 +328,13 @@ class HttpAPI:
|
|||
new_response.set_cookie(
|
||||
morsel.key,
|
||||
morsel.value,
|
||||
expires=morsel["expires"],
|
||||
domain=morsel["domain"],
|
||||
max_age=morsel["max-age"],
|
||||
path=morsel["path"],
|
||||
secure=bool(morsel["secure"]),
|
||||
httponly=bool(morsel["httponly"]),
|
||||
samesite=morsel["samesite"] or None,
|
||||
expires=morsel.get("expires"),
|
||||
domain=morsel.get("domain"),
|
||||
max_age=morsel.get("max-age"),
|
||||
path=morsel.get("path") or "/",
|
||||
secure=bool(morsel.get("secure")),
|
||||
httponly=bool(morsel.get("httponly")),
|
||||
samesite=morsel.get("samesite") or None,
|
||||
)
|
||||
|
||||
return new_response
|
||||
|
|
@ -292,7 +343,7 @@ class HttpAPI:
|
|||
|
||||
return middleware_handler
|
||||
|
||||
async def _handle(self, handler: RequestHandler, request: Request) -> Response:
|
||||
async def _handle(self, handler: Handler, request: Request) -> StreamResponse:
|
||||
"""
|
||||
Call the handler with the request and return the response.
|
||||
|
||||
|
|
@ -314,7 +365,16 @@ class HttpAPI:
|
|||
else:
|
||||
response = await Services.get_instance().handle_async(handler, request=request)
|
||||
except TypeError as te:
|
||||
LOG.exception(te)
|
||||
LOG.exception(
|
||||
"Failed to inject route handler dependencies for '%s'.",
|
||||
getattr(handler, "__name__", handler.__class__.__name__),
|
||||
extra={
|
||||
"handler": getattr(handler, "__name__", handler.__class__.__name__),
|
||||
"route": str(request.rel_url),
|
||||
"method": request.method,
|
||||
"exception_type": type(te).__name__,
|
||||
},
|
||||
)
|
||||
if "missing 1 required positional argument" in str(te) and "request" in str(te):
|
||||
response = await handler(request)
|
||||
else:
|
||||
|
|
@ -322,7 +382,12 @@ class HttpAPI:
|
|||
except web.HTTPException as e:
|
||||
return web.json_response(data={"error": str(e)}, status=e.status_code)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to handle request '%s %s'.",
|
||||
request.method,
|
||||
request.rel_url,
|
||||
extra={"route": str(request.rel_url), "method": request.method, "exception_type": type(e).__name__},
|
||||
)
|
||||
response = web.json_response(
|
||||
data={"error": "Internal Server Error"},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.features.core.utils import gen_random
|
||||
from app.library.log import get_logger
|
||||
from app.library.router import Route, RouteType, get_routes
|
||||
from app.library.Services import Services
|
||||
from app.library.Utils import load_modules
|
||||
|
|
@ -17,7 +17,7 @@ from .encoder import Encoder
|
|||
from .Events import Event, EventBus, Events
|
||||
from .ItemDTO import Item
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("socket_api")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class WebSocketHub:
|
||||
|
|
@ -104,19 +104,20 @@ class HttpSocket:
|
|||
}
|
||||
)
|
||||
|
||||
self._notify.subscribe("frontend", event_handler, f"{__class__.__name__}.emit")
|
||||
self._notify.subscribe("frontend", event_handler, f"{HttpSocket.__name__}.emit")
|
||||
|
||||
@staticmethod
|
||||
def ws_event(func): # type: ignore
|
||||
def ws_event(func):
|
||||
"""
|
||||
Decorator to mark a method as a socket event.
|
||||
"""
|
||||
|
||||
@functools.wraps(func) # type: ignore
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
return await func(*args, **kwargs) # type: ignore
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
wrapper._ws_event = func.__name__ # type: ignore
|
||||
wrapper_with_event: Any = wrapper
|
||||
wrapper_with_event._ws_event = func.__name__
|
||||
return wrapper
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
|
|
@ -134,9 +135,11 @@ class HttpSocket:
|
|||
if not (data and data.data):
|
||||
return
|
||||
|
||||
await Services.get_instance().get("queue").add(item=Item.format(data.data))
|
||||
queue = Services.get_instance().get("queue")
|
||||
if queue is not None:
|
||||
await queue.add(item=Item.format(data.data))
|
||||
|
||||
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
|
||||
self._notify.subscribe(Events.ADD_URL, event_handler, f"{HttpSocket.__name__}.add")
|
||||
|
||||
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
|
||||
|
||||
|
|
@ -145,7 +148,10 @@ class HttpSocket:
|
|||
for route in socket_routes.values():
|
||||
if self.config.debug:
|
||||
LOG.debug(
|
||||
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
|
||||
"Registered socket route '%s' for %s %s.",
|
||||
route.name,
|
||||
route.method.value if isinstance(route.method, RouteType) else route.method,
|
||||
route.path,
|
||||
)
|
||||
|
||||
async def handle_message(sid: str, message: str) -> None:
|
||||
|
|
@ -161,7 +167,7 @@ class HttpSocket:
|
|||
return
|
||||
|
||||
if not (route := socket_routes.get(event)):
|
||||
LOG.debug(f"Unknown websocket event '{event}'.")
|
||||
LOG.debug("Unknown WebSocket event '%s'.", event, extra={"sid": sid, "event": event})
|
||||
return
|
||||
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
|
|
@ -183,7 +189,7 @@ class HttpSocket:
|
|||
|
||||
sid: str = gen_random(14)
|
||||
self.sio.add(sid, ws)
|
||||
LOG.debug(f"WebSocket client '{sid}' connected.")
|
||||
LOG.debug("WebSocket client '%s' connected.", sid, extra={"sid": sid})
|
||||
|
||||
await handle_connect(sid)
|
||||
|
||||
|
|
@ -192,8 +198,11 @@ class HttpSocket:
|
|||
try:
|
||||
await handle_message(sid, message)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error handling WebSocket message from client '{sid}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to handle WebSocket message from client '%s'.",
|
||||
sid,
|
||||
extra={"sid": sid, "message_size": len(message), "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
i: int = 0
|
||||
|
|
@ -202,15 +211,22 @@ class HttpSocket:
|
|||
i = i + 1
|
||||
asyncio.create_task(handle_message_safe(sid, msg.data), name=f"ws_msg_{sid}_{i}")
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
LOG.error(f"WebSocket connection closed with exception {ws.exception()}")
|
||||
LOG.error(
|
||||
"WebSocket connection closed with exception %s",
|
||||
ws.exception(),
|
||||
extra={
|
||||
"sid": sid,
|
||||
"exception": str(ws.exception()) if ws.exception() else None,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await handle_disconnect(sid, getattr(ws, "close_reason", None))
|
||||
self.sio.remove(sid)
|
||||
LOG.debug(f"WebSocket client '{sid}' disconnected.")
|
||||
LOG.debug("WebSocket client '%s' disconnected.", sid, extra={"sid": sid})
|
||||
|
||||
return ws
|
||||
|
||||
if self.config.debug:
|
||||
LOG.debug(f"Add (ws) GET: {ws_path}.")
|
||||
LOG.debug("Registered WebSocket endpoint at '%s'.", ws_path, extra={"method": "GET", "path": ws_path})
|
||||
|
||||
app.router.add_get(ws_path, ws_handler, name="ws")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -10,12 +9,13 @@ from typing import TYPE_CHECKING, Any
|
|||
from app.features.ytdlp.utils import archive_add, archive_delete, archive_read, get_archive_id
|
||||
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import clean_item, get_file, get_file_sidecar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.presets.schemas import Preset
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("ItemDTO")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
|
@ -93,7 +93,7 @@ class Item:
|
|||
bool: True if the item has extras, False otherwise.
|
||||
|
||||
"""
|
||||
return self.extras and len(self.extras) > 0
|
||||
return bool(self.extras and len(self.extras) > 0)
|
||||
|
||||
def add_extras(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
|
|
@ -117,7 +117,7 @@ class Item:
|
|||
bool: True if the item has command options for yt, False otherwise.
|
||||
|
||||
"""
|
||||
return self.cli and len(self.cli) > 2
|
||||
return bool(self.cli and len(self.cli) > 2)
|
||||
|
||||
@staticmethod
|
||||
def _default_preset() -> str:
|
||||
|
|
@ -161,7 +161,7 @@ class Item:
|
|||
Item: The formatted item.
|
||||
|
||||
"""
|
||||
url: str = item.get("url")
|
||||
url = item.get("url")
|
||||
|
||||
if not url or not isinstance(url, str):
|
||||
msg = "url param is required."
|
||||
|
|
@ -173,7 +173,7 @@ class Item:
|
|||
if len(url) >= 11 and re.fullmatch(r"[A-Za-z0-9_-]{11}", url):
|
||||
url = f"https://www.youtube.com/watch?v={url}"
|
||||
|
||||
data: dict[str, str] = {"url": url}
|
||||
data: dict[str, Any] = {"url": url}
|
||||
|
||||
preset: str | None = item.get("preset")
|
||||
if preset and isinstance(preset, str) and preset != Item._default_preset():
|
||||
|
|
@ -185,24 +185,28 @@ class Item:
|
|||
|
||||
data["preset"] = preset
|
||||
|
||||
if item.get("folder") and isinstance(item.get("folder"), str):
|
||||
data["folder"] = item.get("folder")
|
||||
folder = item.get("folder")
|
||||
if isinstance(folder, str) and folder:
|
||||
data["folder"] = folder
|
||||
|
||||
if item.get("cookies") and isinstance(item.get("cookies"), str):
|
||||
data["cookies"] = item.get("cookies")
|
||||
cookies = item.get("cookies")
|
||||
if isinstance(cookies, str) and cookies:
|
||||
data["cookies"] = cookies
|
||||
|
||||
if item.get("template") and isinstance(item.get("template"), str):
|
||||
data["template"] = item.get("template")
|
||||
template = item.get("template")
|
||||
if isinstance(template, str) and template:
|
||||
data["template"] = template
|
||||
|
||||
if "auto_start" in item and isinstance(item.get("auto_start"), bool):
|
||||
data["auto_start"] = bool(item.get("auto_start"))
|
||||
if isinstance(item.get("auto_start"), bool):
|
||||
data["auto_start"] = item["auto_start"]
|
||||
|
||||
extras: dict | None = item.get("extras")
|
||||
if extras and isinstance(extras, dict) and len(extras) > 0:
|
||||
if 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")
|
||||
requeued = item.get("requeued")
|
||||
if isinstance(requeued, bool):
|
||||
data["requeued"] = requeued
|
||||
|
||||
cli: str | None = item.get("cli")
|
||||
if cli and len(cli) > 2:
|
||||
|
|
@ -574,8 +578,10 @@ class ItemDTO:
|
|||
self.is_archivable = bool(self._archive_file)
|
||||
if not self.is_archivable:
|
||||
self.is_archived = False
|
||||
else:
|
||||
elif self.archive_id and self._archive_file:
|
||||
self.is_archived = len(archive_read(self._archive_file, [self.archive_id])) > 0
|
||||
else:
|
||||
self.is_archived = False
|
||||
|
||||
def get_archive_file(self) -> str | None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import importlib
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .httpx_client import sync_client
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("package_installer")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def parse_version(v: str) -> tuple[int, ...]:
|
||||
|
|
@ -16,7 +17,7 @@ def parse_version(v: str) -> tuple[int, ...]:
|
|||
|
||||
|
||||
class Packages:
|
||||
def __init__(self, env: str | None, file: str | None, upgrade: bool = False):
|
||||
def __init__(self, env: str | None, file: str | Path | None, upgrade: bool = False):
|
||||
from_env: list[str] = env.split() if env else []
|
||||
from_file = []
|
||||
|
||||
|
|
@ -64,19 +65,34 @@ class PackageInstaller:
|
|||
current_version: str | None = self._get_installed_version(pkg)
|
||||
|
||||
if current_version and version and self.compare_versions(current_version, version):
|
||||
LOG.info(f"'{pkg}' is already installed with the specified version ({version}). Skipping installation.")
|
||||
LOG.info(
|
||||
"Package '%s' is already installed at version '%s'; skipping installation.",
|
||||
pkg,
|
||||
version,
|
||||
extra={"package": pkg, "installed_version": current_version, "requested_version": version},
|
||||
)
|
||||
return
|
||||
|
||||
if upgrade and current_version and not version:
|
||||
latest_version: str | None = self._get_latest_version(pkg)
|
||||
if latest_version and parse_version(current_version) >= parse_version(latest_version):
|
||||
LOG.info(f"'{pkg}' is already the latest version ({current_version}). Skipping upgrade.")
|
||||
LOG.info(
|
||||
"Package '%s' is already at the latest version '%s'; skipping upgrade.",
|
||||
pkg,
|
||||
current_version,
|
||||
extra={"package": pkg, "installed_version": current_version, "latest_version": latest_version},
|
||||
)
|
||||
return
|
||||
|
||||
if current_version:
|
||||
LOG.info(f"'{pkg}' is already installed (version: {current_version}). Proceeding with upgrade...")
|
||||
LOG.info(
|
||||
"Package '%s' is installed at '%s'; proceeding with upgrade.",
|
||||
pkg,
|
||||
current_version,
|
||||
extra={"package": pkg, "installed_version": current_version},
|
||||
)
|
||||
else:
|
||||
LOG.info(f"'{pkg}' is not installed. Installing...")
|
||||
LOG.info("Package '%s' is not installed; installing it.", pkg, extra={"package": pkg})
|
||||
|
||||
self._install_pkg(pkg, version=version)
|
||||
|
||||
|
|
@ -93,9 +109,20 @@ class PackageInstaller:
|
|||
resp = client.get(url)
|
||||
if 200 == resp.status_code:
|
||||
return resp.json()["info"]["version"]
|
||||
LOG.warning(f"Failed to fetch '{pkg}' info: HTTP {resp.status_code}")
|
||||
LOG.warning(
|
||||
"Failed to fetch PyPI metadata for '%s': HTTP %s.",
|
||||
pkg,
|
||||
resp.status_code,
|
||||
extra={"package": pkg, "status_code": resp.status_code, "url": url},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.warning(f"Error while querying PyPI for '{pkg}': {e}")
|
||||
LOG.warning(
|
||||
"Failed to query PyPI for '%s': %s",
|
||||
pkg,
|
||||
e,
|
||||
extra={"package": pkg, "url": url, "exception_type": type(e).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _install_pkg(self, pkg: str, version: str | None = None) -> bool:
|
||||
|
|
@ -135,7 +162,12 @@ class PackageInstaller:
|
|||
|
||||
return 0 == proc.returncode
|
||||
except subprocess.CalledProcessError as e:
|
||||
LOG.error(f"Failed to install '{pkg}' (exit {e.returncode}). {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to install package '%s' (exit %s).",
|
||||
pkg,
|
||||
e.returncode,
|
||||
extra={"package": pkg, "returncode": e.returncode, "exception_type": type(e).__name__},
|
||||
)
|
||||
self.out(out=e.stdout, err=e.stderr)
|
||||
raise
|
||||
|
||||
|
|
@ -150,13 +182,20 @@ class PackageInstaller:
|
|||
if not pkgs.has_packages() or not self.user_site:
|
||||
return
|
||||
|
||||
LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}")
|
||||
LOG.info("Checking configured user pip packages.", extra={"packages": pkgs.packages})
|
||||
for package in pkgs.packages:
|
||||
try:
|
||||
self.action(package, upgrade=pkgs.allow_upgrade())
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to install or upgrade package '{package}'. Error message: {e!s}")
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
"Failed to install or upgrade package '%s'.",
|
||||
package,
|
||||
extra={
|
||||
"package": package,
|
||||
"allow_upgrade": pkgs.allow_upgrade(),
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
def compare_versions(self, current: str, target: str) -> bool:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from aiocron import Cron
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .Events import EventBus, Events
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG = logging.getLogger("scheduler")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Scheduler(metaclass=Singleton):
|
||||
|
|
@ -46,10 +49,13 @@ class Scheduler(metaclass=Singleton):
|
|||
for job in self._jobs:
|
||||
try:
|
||||
self._jobs[job].stop()
|
||||
LOG.debug(f"Stopped job '{job}'.")
|
||||
LOG.debug("Stopped job '%s'.", job, extra={"job_id": job})
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to stop scheduler job '%s'.",
|
||||
job,
|
||||
extra={"job_id": job, "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
self._jobs = {}
|
||||
|
||||
|
|
@ -62,7 +68,7 @@ class Scheduler(metaclass=Singleton):
|
|||
if data and data.data:
|
||||
self.add(**data.data)
|
||||
|
||||
EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add")
|
||||
EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{type(self).__name__}.add")
|
||||
|
||||
def get_all(self) -> dict[str, Cron]:
|
||||
"""Return the jobs."""
|
||||
|
|
@ -95,7 +101,7 @@ class Scheduler(metaclass=Singleton):
|
|||
return id in self._jobs
|
||||
|
||||
def add(
|
||||
self, timer: str, func: callable, args: tuple = (), kwargs: dict | None = None, id: str | None = None
|
||||
self, timer: str, func: Callable[..., Any], args: tuple = (), kwargs: dict | None = None, id: str | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Add a job to the schedule.
|
||||
|
|
@ -127,7 +133,7 @@ class Scheduler(metaclass=Singleton):
|
|||
|
||||
self._jobs[job_id] = job
|
||||
|
||||
LOG.debug(f"Added '{job_id}' to the scheduler to run on '{timer}'.")
|
||||
LOG.debug("Added job '%s' to run on '%s'.", job_id, timer, extra={"job_id": job_id, "timer": timer})
|
||||
|
||||
return job_id
|
||||
|
||||
|
|
@ -151,12 +157,15 @@ class Scheduler(metaclass=Singleton):
|
|||
try:
|
||||
self._jobs[id].stop()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to stop job '{id}'. Error message '{e!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to stop scheduler job '%s'.",
|
||||
id,
|
||||
extra={"job_id": id, "exception_type": type(e).__name__},
|
||||
)
|
||||
return False
|
||||
|
||||
del self._jobs[id]
|
||||
LOG.debug(f"Removed job '{id}' from the scheduler.")
|
||||
LOG.debug("Removed job '%s' from the scheduler.", id, extra={"job_id": id})
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, TypeVar, get_args, get_origin, get_type_hints
|
||||
|
||||
from app.library.log import get_logger
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
T = TypeVar("T")
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _unwrap_annotation(ann: Any) -> Any:
|
||||
|
|
@ -117,7 +118,7 @@ class Services(metaclass=Singleton):
|
|||
|
||||
return candidates[0]
|
||||
|
||||
def _build_call_args(self, handler: callable, overrides: dict[str, Any]) -> dict[str, Any]:
|
||||
def _build_call_args(self, handler: Callable[..., Any], overrides: dict[str, Any]) -> dict[str, Any]:
|
||||
sig: inspect.Signature = inspect.signature(handler)
|
||||
|
||||
try:
|
||||
|
|
@ -169,10 +170,10 @@ class Services(metaclass=Singleton):
|
|||
|
||||
return resolved
|
||||
|
||||
async def handle_async(self, handler: callable, **kwargs) -> Any:
|
||||
async def handle_async(self, handler: Callable[..., Any], **kwargs) -> Any:
|
||||
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
|
||||
return await handler(**resolved)
|
||||
|
||||
def handle_sync(self, handler: callable, **kwargs) -> Any:
|
||||
def handle_sync(self, handler: Callable[..., Any], **kwargs) -> Any:
|
||||
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
|
||||
return handler(**resolved)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
|
|
@ -16,6 +15,7 @@ from typing import TYPE_CHECKING, Any
|
|||
from aiohttp import web
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.log import get_logger
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
|
@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from aiohttp.web import Request
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("terminal_manager")
|
||||
LOG = get_logger()
|
||||
|
||||
ACTIVE_FILE_NAME = "active.json"
|
||||
METADATA_FILE_NAME = "metadata.json"
|
||||
|
|
@ -75,7 +75,7 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
self._cleanup_job_id = Scheduler.get_instance().add(
|
||||
timer=CLEANUP_SCHEDULE,
|
||||
func=self.cleanup,
|
||||
id=f"{__class__.__name__}.{__class__.cleanup.__name__}",
|
||||
id=f"{type(self).__name__}.{type(self).cleanup.__name__}",
|
||||
)
|
||||
|
||||
async def on_startup(self, _: web.Application) -> None:
|
||||
|
|
@ -296,7 +296,7 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
master_fd: int | None = None
|
||||
|
||||
try:
|
||||
LOG.info("Cli command from client. '%s'", command)
|
||||
LOG.info("Starting terminal session '%s' command.", session_id, extra={"session_id": session_id})
|
||||
args = ["yt-dlp", *shlex.split(command, posix=os.name != "nt")]
|
||||
env_vars = self._build_env()
|
||||
|
||||
|
|
@ -343,7 +343,10 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
try:
|
||||
os.close(slave_fd)
|
||||
except Exception as exc:
|
||||
LOG.error("Error closing PTY. '%s'.", str(exc))
|
||||
LOG.exception(
|
||||
"Failed to close the PTY slave file descriptor.",
|
||||
extra={"exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
read_task = asyncio.create_task(
|
||||
self._read_process_output(session_id=session_id, proc=proc, use_pty=use_pty, master_fd=master_fd),
|
||||
|
|
@ -356,8 +359,11 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
final_status = "interrupted"
|
||||
except Exception as exc:
|
||||
final_status = "failed"
|
||||
LOG.error("CLI execute exception was thrown.")
|
||||
LOG.exception(exc)
|
||||
LOG.exception(
|
||||
"Terminal session '%s' command failed.",
|
||||
session_id,
|
||||
extra={"session_id": session_id, "use_pty": use_pty, "exception_type": type(exc).__name__},
|
||||
)
|
||||
await self._append_event(session_id, "output", {"type": "stderr", "line": str(exc)})
|
||||
finally:
|
||||
final_status = await self._resolve_final_status(session_id=session_id, status=final_status)
|
||||
|
|
@ -371,7 +377,7 @@ class TerminalSessionManager(metaclass=Singleton):
|
|||
try:
|
||||
return_code = await asyncio.wait_for(proc.wait(), timeout=self._shutdown_timeout)
|
||||
except TimeoutError:
|
||||
LOG.warning("Terminal session '%s' process did not exit cleanly.", session_id)
|
||||
LOG.warning("Terminal session '%s' did not exit after repeated shutdown attempts.", session_id)
|
||||
|
||||
if proc is not None:
|
||||
proc_returncode = getattr(proc, "returncode", None)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .cache import Cache
|
||||
from .config import Config
|
||||
from .Events import EventBus, Events
|
||||
|
|
@ -13,7 +14,7 @@ from .Scheduler import Scheduler
|
|||
from .Singleton import Singleton
|
||||
from .version import APP_VERSION
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("update_checker")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class UpdateChecker(metaclass=Singleton):
|
||||
|
|
@ -79,7 +80,7 @@ class UpdateChecker(metaclass=Singleton):
|
|||
self._notify.subscribe(
|
||||
event=Events.STARTED,
|
||||
callback=event_handler,
|
||||
name=f"{__class__.__name__}.{__class__.attach.__name__}",
|
||||
name=f"{UpdateChecker.__name__}.{type(self).attach.__name__}",
|
||||
)
|
||||
|
||||
self._schedule_check()
|
||||
|
|
@ -107,7 +108,7 @@ class UpdateChecker(metaclass=Singleton):
|
|||
self._job_id = self._scheduler.add(
|
||||
timer=timer,
|
||||
func=lambda: asyncio.create_task(self.check_for_updates()),
|
||||
id=f"{__class__.__name__}.{self.check_for_updates.__name__}",
|
||||
id=f"{UpdateChecker.__name__}.{self.check_for_updates.__name__}",
|
||||
)
|
||||
|
||||
async def check_for_updates(self) -> tuple[tuple[str, str | None], tuple[str, str | None]]:
|
||||
|
|
@ -145,7 +146,11 @@ class UpdateChecker(metaclass=Singleton):
|
|||
strip_v_prefix: bool = False,
|
||||
) -> tuple[str, str | None]:
|
||||
try:
|
||||
LOG.info(f"Checking for {name} updates...")
|
||||
LOG.info(
|
||||
"Checking whether %s has an update available.",
|
||||
name,
|
||||
extra={"target_name": name, "api_url": api_url, "current_version": current_version},
|
||||
)
|
||||
|
||||
client = get_async_client(use_curl=False)
|
||||
response = await client.get(
|
||||
|
|
@ -155,32 +160,59 @@ class UpdateChecker(metaclass=Singleton):
|
|||
)
|
||||
|
||||
if 200 != response.status_code:
|
||||
LOG.warning(f"Failed to check for {name} updates: HTTP {response.status_code}")
|
||||
LOG.warning(
|
||||
"Failed to check for %s updates: HTTP %s",
|
||||
name,
|
||||
response.status_code,
|
||||
extra={"target_name": name, "api_url": api_url, "status_code": response.status_code},
|
||||
)
|
||||
return ("error", None)
|
||||
|
||||
data: dict[str, Any] = response.json()
|
||||
|
||||
latest_tag: str = data.get("tag_name", "")
|
||||
if not latest_tag:
|
||||
LOG.warning(f"No tag_name found in {name} GitHub release data.")
|
||||
LOG.warning(
|
||||
"No tag_name found in %s GitHub release data.",
|
||||
name,
|
||||
extra={"target_name": name, "api_url": api_url},
|
||||
)
|
||||
return ("error", None)
|
||||
|
||||
compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version
|
||||
compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag
|
||||
|
||||
if self._compare_versions(compare_current, compare_latest):
|
||||
LOG.warning(f"{name} update available: {current_version} -> {latest_tag}")
|
||||
LOG.warning(
|
||||
"%s has an update available: %s -> %s.",
|
||||
name,
|
||||
current_version,
|
||||
latest_tag,
|
||||
extra={"target_name": name, "current_version": current_version, "latest_version": latest_tag},
|
||||
)
|
||||
result = ("update_available", latest_tag)
|
||||
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
|
||||
return result
|
||||
|
||||
LOG.info(f"No {name} updates available.")
|
||||
LOG.info(
|
||||
"%s is already up to date.",
|
||||
name,
|
||||
extra={"target_name": name, "current_version": current_version},
|
||||
)
|
||||
result = ("up_to_date", None)
|
||||
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
|
||||
return result
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error checking for {name} updates: {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to check whether %s has an update available.",
|
||||
name,
|
||||
extra={
|
||||
"target_name": name,
|
||||
"api_url": api_url,
|
||||
"cache_key": cache_key,
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
return ("error", None)
|
||||
|
||||
async def _check_app_version(self) -> tuple[str, str | None]:
|
||||
|
|
@ -203,7 +235,7 @@ class UpdateChecker(metaclass=Singleton):
|
|||
async def _check_ytdlp_version(self) -> tuple[str, str | None]:
|
||||
current_version: str = self._config._ytdlp_version()
|
||||
if not current_version or "0.0.0" == current_version:
|
||||
LOG.warning("Could not determine yt-dlp version, skipping yt-dlp update check.")
|
||||
LOG.warning("Skipping the yt-dlp update check because the current version could not be determined.")
|
||||
return ("error", None)
|
||||
|
||||
status, new_version = await self._check_github_version(
|
||||
|
|
@ -238,5 +270,12 @@ class UpdateChecker(metaclass=Singleton):
|
|||
|
||||
return parse_version(latest) > parse_version(current)
|
||||
except Exception as e:
|
||||
LOG.warning(f"Error comparing versions '{current}' vs '{latest}': {e}")
|
||||
LOG.warning(
|
||||
"Failed to compare versions '%s' and '%s' because %s.",
|
||||
current,
|
||||
latest,
|
||||
e,
|
||||
extra={"current_version": current, "latest_version": latest, "exception_type": type(e).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import base64
|
|||
import copy
|
||||
import glob
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import lru_cache, wraps
|
||||
|
|
@ -16,7 +18,9 @@ from typing import Any
|
|||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("Utils")
|
||||
from app.library.log import get_logger
|
||||
|
||||
LOG = get_logger()
|
||||
|
||||
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
|
||||
"Allowed subtitle file extensions."
|
||||
|
|
@ -36,11 +40,179 @@ TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
|
|||
|
||||
|
||||
class FileLogFormatter(logging.Formatter):
|
||||
def formatTime(self, record, datefmt=None): # noqa: ARG002, N802
|
||||
def formatTime(self, record, datefmt=None): # noqa: N802
|
||||
_ = datefmt
|
||||
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
|
||||
LOG_RECORD_ATTRS: set[str] = set(logging.makeLogRecord({}).__dict__) | {"asctime", "message"}
|
||||
SKIP_LOG_FIELD = object()
|
||||
|
||||
|
||||
class JsonLogFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
source = {
|
||||
"path": record.pathname,
|
||||
"file": record.filename,
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
data: dict[str, Any] = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"datetime": self.formatTime(record),
|
||||
"level": record.levelname.lower(),
|
||||
"levelno": record.levelno,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"source": source,
|
||||
"process": {"id": record.process, "name": record.processName},
|
||||
"thread": {"id": record.thread, "name": record.threadName},
|
||||
"fields": self._extra(record),
|
||||
}
|
||||
|
||||
if record.exc_info:
|
||||
exception = self._exception(record.exc_info)
|
||||
if exception is not None:
|
||||
data["exception"] = exception
|
||||
data["source"].update(self._exception_source(exception))
|
||||
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
def formatTime(self, record, datefmt=None): # noqa: N802
|
||||
_ = datefmt
|
||||
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
|
||||
|
||||
@staticmethod
|
||||
def _extra(record: logging.LogRecord) -> dict[str, Any]:
|
||||
extra: dict[str, Any] = {}
|
||||
for key, value in record.__dict__.items():
|
||||
if key in LOG_RECORD_ATTRS or key.startswith("_"):
|
||||
continue
|
||||
|
||||
value = JsonLogFormatter._field(value)
|
||||
if value is not SKIP_LOG_FIELD:
|
||||
extra[key] = value
|
||||
|
||||
return extra
|
||||
|
||||
@staticmethod
|
||||
def _field(value: Any) -> Any:
|
||||
if isinstance(value, str | int | float | bool) or value is None:
|
||||
return value
|
||||
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
data: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str) or key.startswith("_"):
|
||||
continue
|
||||
|
||||
item = JsonLogFormatter._field(item)
|
||||
if item is not SKIP_LOG_FIELD:
|
||||
data[key] = item
|
||||
|
||||
return data
|
||||
|
||||
if isinstance(value, list):
|
||||
data: list[Any] = []
|
||||
for item in value:
|
||||
item = JsonLogFormatter._field(item)
|
||||
if item is not SKIP_LOG_FIELD:
|
||||
data.append(item)
|
||||
|
||||
return data
|
||||
|
||||
return SKIP_LOG_FIELD
|
||||
|
||||
@staticmethod
|
||||
def _exception(
|
||||
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
|
||||
) -> dict[str, Any] | None:
|
||||
exc = exc_info[1]
|
||||
if exc is None:
|
||||
return None
|
||||
|
||||
stack = JsonLogFormatter._exception_stack(exc_info)
|
||||
data: dict[str, Any] = {"type": JsonLogFormatter._exception_type(exc)}
|
||||
|
||||
message = str(exc).strip()
|
||||
if message:
|
||||
data["message"] = message
|
||||
|
||||
if stack:
|
||||
origin = stack[-1]
|
||||
data["file"] = origin["path"]
|
||||
data["line"] = origin["line"]
|
||||
data["stack"] = stack
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _exception_type(exc: BaseException) -> str:
|
||||
module = exc.__class__.__module__
|
||||
name = exc.__class__.__qualname__
|
||||
return name if module == "builtins" else f"{module}.{name}"
|
||||
|
||||
@staticmethod
|
||||
def _exception_stack(
|
||||
exc_info: tuple[type[BaseException], BaseException, Any] | tuple[None, None, None],
|
||||
) -> list[dict[str, Any]]:
|
||||
exc = exc_info[1]
|
||||
tb = exc_info[2] if len(exc_info) > 2 else None
|
||||
if tb is None and exc is not None:
|
||||
tb = exc.__traceback__
|
||||
|
||||
if tb is None:
|
||||
return []
|
||||
|
||||
return [JsonLogFormatter._frame(frame) for frame in traceback.extract_tb(tb)]
|
||||
|
||||
@staticmethod
|
||||
def _frame(frame: traceback.FrameSummary) -> dict[str, Any]:
|
||||
path = frame.filename
|
||||
file_path = Path(path)
|
||||
return {
|
||||
"path": path,
|
||||
"file": file_path.name,
|
||||
"module": file_path.stem,
|
||||
"function": frame.name,
|
||||
"line": frame.lineno,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _exception_source(exception: dict[str, Any]) -> dict[str, Any]:
|
||||
source: dict[str, Any] = {}
|
||||
|
||||
path = exception.get("file")
|
||||
if isinstance(path, str) and path:
|
||||
file_path = Path(path)
|
||||
source["path"] = path
|
||||
source["file"] = file_path.name
|
||||
source["module"] = file_path.stem
|
||||
|
||||
line = exception.get("line")
|
||||
if isinstance(line, int):
|
||||
source["line"] = line
|
||||
|
||||
stack = exception.get("stack")
|
||||
if isinstance(stack, list) and stack:
|
||||
frame = stack[-1]
|
||||
if isinstance(frame, dict):
|
||||
function = frame.get("function")
|
||||
if isinstance(function, str) and function:
|
||||
source["function"] = function
|
||||
|
||||
module = frame.get("module")
|
||||
if isinstance(module, str) and module:
|
||||
source["module"] = module
|
||||
|
||||
return source
|
||||
|
||||
|
||||
def timed_lru_cache(ttl_seconds: float, max_size: int = 128):
|
||||
"""
|
||||
Decorator that applies an LRU cache with a time-to-live (TTL) to a function.
|
||||
Supports both synchronous and asynchronous functions.
|
||||
|
|
@ -110,8 +282,9 @@ def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
|
|||
|
||||
return _CacheInfo(hits=0, misses=len(cache), maxsize=max_size, currsize=len(cache))
|
||||
|
||||
async_wrapper.cache_clear = cache_clear
|
||||
async_wrapper.cache_info = cache_info
|
||||
async_wrapper_cache: Any = async_wrapper
|
||||
async_wrapper_cache.cache_clear = cache_clear
|
||||
async_wrapper_cache.cache_info = cache_info
|
||||
return async_wrapper
|
||||
|
||||
# For sync functions, use the original implementation
|
||||
|
|
@ -139,8 +312,9 @@ def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
|
|||
return result
|
||||
|
||||
# expose cache_clear, cache_info
|
||||
sync_wrapper.cache_clear = cached.cache_clear
|
||||
sync_wrapper.cache_info = cached.cache_info
|
||||
sync_wrapper_cache: Any = sync_wrapper
|
||||
sync_wrapper_cache.cache_clear = cached.cache_clear
|
||||
sync_wrapper_cache.cache_info = cached.cache_info
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
|
@ -211,7 +385,12 @@ def _is_safe_key(key: Any) -> bool:
|
|||
|
||||
|
||||
def merge_dict(
|
||||
source: dict, destination: dict, max_depth: int = 50, max_list_size: int = 10000, _depth: int = 0, _seen: set = None
|
||||
source: dict,
|
||||
destination: dict,
|
||||
max_depth: int = 50,
|
||||
max_list_size: int = 10000,
|
||||
_depth: int = 0,
|
||||
_seen: set | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Merge data from source into destination.
|
||||
|
|
@ -302,7 +481,7 @@ def merge_dict(
|
|||
return destination_copy
|
||||
|
||||
|
||||
def check_id(file: Path) -> bool | str:
|
||||
def check_id(file: Path) -> bool | Path:
|
||||
"""
|
||||
Check if we are able to get an id from the file name.
|
||||
if so check if any video file with the same id exists.
|
||||
|
|
@ -321,6 +500,8 @@ def check_id(file: Path) -> bool | str:
|
|||
return False
|
||||
|
||||
id: str | None = match.groupdict().get("id")
|
||||
if id is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not file.parent.exists():
|
||||
|
|
@ -335,7 +516,11 @@ def check_id(file: Path) -> bool | str:
|
|||
|
||||
return f.absolute()
|
||||
except OSError as e:
|
||||
LOG.error(f"Error checking file '{file}': {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to check for a matching file for '%s'.",
|
||||
file,
|
||||
extra={"file_path": str(file), "operation": "check_id", "exception_type": type(e).__name__},
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
|
@ -371,9 +556,9 @@ def validate_url(url: str, allow_internal: bool = False) -> bool:
|
|||
from yarl import URL
|
||||
|
||||
parsed_url = URL(url)
|
||||
except ValueError:
|
||||
except ValueError as exc:
|
||||
msg = "Invalid URL."
|
||||
raise ValueError(msg) # noqa: B904
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
# Check allowed schemes
|
||||
if parsed_url.scheme not in ["http", "https"]:
|
||||
|
|
@ -391,7 +576,11 @@ def validate_url(url: str, allow_internal: bool = False) -> bool:
|
|||
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}")
|
||||
LOG.exception(
|
||||
"Failed to resolve hostname '%s'.",
|
||||
hostname,
|
||||
extra={"hostname": hostname, "exception_type": type(e).__name__},
|
||||
)
|
||||
msg = "Invalid hostname."
|
||||
raise ValueError(msg) from e
|
||||
|
||||
|
|
@ -418,7 +607,7 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
|
|||
|
||||
|
||||
@timed_lru_cache(ttl_seconds=60, max_size=256)
|
||||
def get_file_sidecar(file: Path | None = None) -> dict[dict]:
|
||||
def get_file_sidecar(file: Path | None = None) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
Get sidecar files for the given file.
|
||||
|
||||
|
|
@ -519,16 +708,46 @@ def rename_file(old_path: Path, new_name: str) -> tuple[Path, list[tuple[Path, P
|
|||
renamed_sidecar: Path = old_sidecar.rename(new_sidecar)
|
||||
renamed_sidecars.append((old_sidecar, renamed_sidecar))
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed to rename sidecar '{old_sidecar}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to rename sidecar '%s' to '%s'.",
|
||||
old_sidecar,
|
||||
new_sidecar,
|
||||
extra={
|
||||
"old_path": str(old_sidecar),
|
||||
"new_path": str(new_sidecar),
|
||||
"operation": "rename_sidecar",
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
try:
|
||||
renamed_main.rename(old_path)
|
||||
except OSError:
|
||||
LOG.error(f"Failed to rollback main file rename from '{renamed_main}' to '{old_path}'")
|
||||
except OSError as rollback_error:
|
||||
LOG.exception(
|
||||
"Failed to roll back main file rename from '%s' to '%s'.",
|
||||
renamed_main,
|
||||
old_path,
|
||||
extra={
|
||||
"old_path": str(renamed_main),
|
||||
"new_path": str(old_path),
|
||||
"operation": "rename_rollback",
|
||||
"exception_type": type(rollback_error).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
return renamed_main, renamed_sidecars
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed to rename '{old_path}' to '{new_path}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to rename '%s' to '%s'.",
|
||||
old_path,
|
||||
new_path,
|
||||
extra={
|
||||
"old_path": str(old_path),
|
||||
"new_path": str(new_path),
|
||||
"operation": "rename",
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -586,24 +805,62 @@ def move_file(old_path: Path, target_dir: Path) -> tuple[Path, list[tuple[Path,
|
|||
moved_sidecar: Path = old_sidecar.rename(new_sidecar)
|
||||
renamed_sidecars.append((old_sidecar, moved_sidecar))
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed to move sidecar '{old_sidecar}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to move sidecar '%s' to '%s'.",
|
||||
old_sidecar,
|
||||
new_sidecar,
|
||||
extra={
|
||||
"old_path": str(old_sidecar),
|
||||
"new_path": str(new_sidecar),
|
||||
"operation": "move_sidecar",
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
try:
|
||||
moved_main.rename(old_path)
|
||||
# Rollback any sidecars that were already moved
|
||||
for rolled_back_old, rolled_back_new in renamed_sidecars:
|
||||
try:
|
||||
rolled_back_new.rename(rolled_back_old)
|
||||
except OSError:
|
||||
LOG.error(
|
||||
f"Failed to rollback sidecar move from '{rolled_back_new}' to '{rolled_back_old}'"
|
||||
except OSError as rollback_error:
|
||||
LOG.exception(
|
||||
"Failed to roll back sidecar move from '%s' to '%s'.",
|
||||
rolled_back_new,
|
||||
rolled_back_old,
|
||||
extra={
|
||||
"old_path": str(rolled_back_new),
|
||||
"new_path": str(rolled_back_old),
|
||||
"operation": "move_sidecar_rollback",
|
||||
"exception_type": type(rollback_error).__name__,
|
||||
},
|
||||
)
|
||||
except OSError:
|
||||
LOG.error(f"Failed to rollback main file move from '{moved_main}' to '{old_path}'")
|
||||
except OSError as rollback_error:
|
||||
LOG.exception(
|
||||
"Failed to roll back main file move from '%s' to '%s'.",
|
||||
moved_main,
|
||||
old_path,
|
||||
extra={
|
||||
"old_path": str(moved_main),
|
||||
"new_path": str(old_path),
|
||||
"operation": "move_rollback",
|
||||
"exception_type": type(rollback_error).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
return moved_main, renamed_sidecars
|
||||
except OSError as e:
|
||||
LOG.error(f"Failed to move '{old_path}' to '{new_path}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to move '%s' to '%s'.",
|
||||
old_path,
|
||||
new_path,
|
||||
extra={
|
||||
"old_path": str(old_path),
|
||||
"new_path": str(new_path),
|
||||
"operation": "move",
|
||||
"exception_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -679,18 +936,22 @@ def get_file(download_path: str | Path, file: str | Path) -> tuple[Path, int]:
|
|||
download_path = Path(download_path)
|
||||
|
||||
try:
|
||||
realFile: str = Path(calc_download_path(base_path=download_path, folder=str(file), create_path=False))
|
||||
realFile: Path = Path(calc_download_path(base_path=download_path, folder=str(file), create_path=False))
|
||||
if realFile.exists():
|
||||
return (realFile, 200)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error calculating download path. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to resolve download file path for '%s'.",
|
||||
file,
|
||||
extra={"file_path": str(file), "download_path": str(download_path), "exception_type": type(e).__name__},
|
||||
)
|
||||
return (Path(file), 404)
|
||||
|
||||
possibleFile: bool | str = check_id(file=realFile)
|
||||
if not possibleFile:
|
||||
possibleFile: bool | Path = check_id(file=realFile)
|
||||
if not isinstance(possibleFile, Path):
|
||||
return (realFile, 404)
|
||||
|
||||
return (Path(possibleFile), 302)
|
||||
return (possibleFile, 302)
|
||||
|
||||
|
||||
def encrypt_data(data: str, key: bytes) -> str:
|
||||
|
|
@ -712,7 +973,7 @@ def encrypt_data(data: str, key: bytes) -> str:
|
|||
return base64.urlsafe_b64encode(iv + ciphertext + tag).decode()
|
||||
|
||||
|
||||
def decrypt_data(data: str, key: bytes) -> str:
|
||||
def decrypt_data(data: str, key: bytes) -> str | None:
|
||||
"""
|
||||
Decrypts AES-GCM encrypted data
|
||||
|
||||
|
|
@ -725,8 +986,8 @@ def decrypt_data(data: str, key: bytes) -> str:
|
|||
|
||||
"""
|
||||
try:
|
||||
data = base64.urlsafe_b64decode(data)
|
||||
iv, ciphertext, tag = data[:12], data[12:-16], data[-16:]
|
||||
raw: bytes = base64.urlsafe_b64decode(data)
|
||||
iv, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:]
|
||||
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
|
||||
plaintext = cipher.decrypt_and_verify(ciphertext, tag)
|
||||
return plaintext.decode()
|
||||
|
|
@ -737,7 +998,7 @@ def decrypt_data(data: str, key: bytes) -> str:
|
|||
def get(
|
||||
data: dict | list,
|
||||
path: str | list | None = None,
|
||||
default: any = None,
|
||||
default: Any = None,
|
||||
separator=".",
|
||||
):
|
||||
"""
|
||||
|
|
@ -854,22 +1115,42 @@ def get_files(
|
|||
try:
|
||||
dir_path = dir_path.resolve()
|
||||
except OSError as e:
|
||||
LOG.warning(f"Failed to resolve '{dir}' - {e}")
|
||||
return []
|
||||
LOG.warning(
|
||||
"Failed to resolve '%s': %s",
|
||||
dir,
|
||||
e,
|
||||
extra={"requested_dir": dir, "base_path": str(base_path), "exception_type": type(e).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
return [], 0
|
||||
|
||||
try:
|
||||
dir_path.relative_to(base_path)
|
||||
except ValueError:
|
||||
LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.")
|
||||
return []
|
||||
LOG.warning(
|
||||
"Invalid path '%s': must be inside '%s'.",
|
||||
dir_path,
|
||||
base_path,
|
||||
extra={"path": str(dir_path), "base_path": str(base_path)},
|
||||
)
|
||||
return [], 0
|
||||
|
||||
if not str(dir_path).startswith(str(base_path)):
|
||||
LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.")
|
||||
return []
|
||||
LOG.warning(
|
||||
"Invalid path '%s': must be inside '%s'.",
|
||||
dir_path,
|
||||
base_path,
|
||||
extra={"path": str(dir_path), "base_path": str(base_path)},
|
||||
)
|
||||
return [], 0
|
||||
|
||||
if not dir_path.is_dir():
|
||||
LOG.warning(f"Invalid path: '{dir_path}' - must be a directory.")
|
||||
return []
|
||||
LOG.warning(
|
||||
"Invalid path '%s': must be a directory.",
|
||||
dir_path,
|
||||
extra={"path": str(dir_path), "base_path": str(base_path)},
|
||||
)
|
||||
return [], 0
|
||||
|
||||
contents: list = []
|
||||
for file in dir_path.iterdir():
|
||||
|
|
@ -882,13 +1163,28 @@ def get_files(
|
|||
test: Path = file.resolve()
|
||||
test.relative_to(base_path)
|
||||
if not str(test).startswith(str(base_path)):
|
||||
LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.")
|
||||
LOG.warning(
|
||||
"Invalid symlink '%s': must resolve inside '%s'.",
|
||||
file,
|
||||
base_path,
|
||||
extra={"path": str(file), "resolved_path": str(test), "base_path": str(base_path)},
|
||||
)
|
||||
continue
|
||||
except ValueError:
|
||||
LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.")
|
||||
LOG.warning(
|
||||
"Invalid symlink '%s': must resolve inside '%s'.",
|
||||
file,
|
||||
base_path,
|
||||
extra={"path": str(file), "base_path": str(base_path)},
|
||||
)
|
||||
continue
|
||||
except OSError:
|
||||
LOG.warning(f"Skipping broken symlink: {file}")
|
||||
except OSError as e:
|
||||
LOG.warning(
|
||||
"Skipping broken symlink '%s'.",
|
||||
file,
|
||||
extra={"path": str(file), "base_path": str(base_path), "exception_type": type(e).__name__},
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
content_type = None
|
||||
|
|
@ -1013,7 +1309,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
|
|||
try:
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
|
||||
cookies = MozillaCookieJar(str(file), None, None)
|
||||
cookies = MozillaCookieJar(str(file), delayload=False, policy=None)
|
||||
cookies.load()
|
||||
|
||||
return (True, cookies)
|
||||
|
|
@ -1078,7 +1374,11 @@ def delete_dir(dir: Path) -> bool:
|
|||
dir.rmdir()
|
||||
return True
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to delete directory '{dir}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to delete directory '%s'.",
|
||||
dir,
|
||||
extra={"dir": str(dir), "exception_type": type(e).__name__},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -1126,7 +1426,11 @@ def load_modules(root_path: Path, directory: Path):
|
|||
try:
|
||||
importlib.import_module(full_name)
|
||||
except ImportError as e:
|
||||
LOG.error(f"Failed to import module '{full_name}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to import module '%s'.",
|
||||
full_name,
|
||||
extra={"module_name": full_name, "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
|
||||
def parse_tags(text: str) -> tuple[str, dict[str, str | bool]]:
|
||||
|
|
@ -1184,35 +1488,6 @@ def str_to_dt(time_str: str, now=None) -> datetime:
|
|||
return dt
|
||||
|
||||
|
||||
def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]:
|
||||
"""
|
||||
List all folders relative to a base path, up to a specified depth limit.
|
||||
|
||||
Args:
|
||||
path (Path): The path to start listing folders from.
|
||||
base (Path): The base path to which the folders should be relative.
|
||||
depth_limit (int): The maximum depth to traverse from the base path.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of folder paths relative to the base path, up to the specified
|
||||
|
||||
"""
|
||||
if "/" == str(path):
|
||||
return []
|
||||
|
||||
rel_depth: int = len(path.relative_to(base).parts)
|
||||
if rel_depth > depth_limit:
|
||||
return []
|
||||
|
||||
folders: list[str] = []
|
||||
for entry in path.iterdir():
|
||||
if entry.is_dir():
|
||||
folders.append(str(entry.relative_to(base)))
|
||||
folders.extend(list_folders(entry, base, depth_limit))
|
||||
|
||||
return folders
|
||||
|
||||
|
||||
def get_channel_images(thumbnails: list[dict]) -> dict:
|
||||
"""
|
||||
Extract channel images from a list of thumbnail dictionaries.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _dict_value(data: dict[Any, Any], key: Any) -> tuple[bool, Any]:
|
||||
if key not in data or data[key] is None:
|
||||
return False, None
|
||||
return True, data[key]
|
||||
|
||||
|
||||
def _dict_delete(data: dict[Any, Any], key: Any) -> bool:
|
||||
if key not in data:
|
||||
return False
|
||||
del data[key]
|
||||
return True
|
||||
|
||||
|
||||
def get_value(value: Any) -> Any:
|
||||
"""
|
||||
Return the result of calling `value` if it is callable, else return `value`.
|
||||
|
|
@ -91,14 +105,19 @@ def ag(
|
|||
return val
|
||||
return get_value(default)
|
||||
key = path
|
||||
if isinstance(array, dict) and key in array and array[key] is not None:
|
||||
return array[key]
|
||||
if isinstance(array, dict):
|
||||
found, value = _dict_value(array, key)
|
||||
if found:
|
||||
return value
|
||||
if not isinstance(key, str) or separator not in key:
|
||||
return get_value(default)
|
||||
current = array
|
||||
for segment in key.split(separator):
|
||||
if isinstance(current, dict) and segment in current and current[segment] is not None:
|
||||
current = current[segment]
|
||||
if isinstance(current, dict):
|
||||
found, value = _dict_value(current, segment)
|
||||
if not found:
|
||||
return get_value(default)
|
||||
current = value
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
idx = int(segment)
|
||||
|
|
@ -150,7 +169,8 @@ def ag_exists(data: dict[Any, Any] | list[Any] | object, path: str | int, separa
|
|||
except TypeError:
|
||||
return False
|
||||
if isinstance(data, dict):
|
||||
if path in data and data[path] is not None:
|
||||
found, _ = _dict_value(data, path)
|
||||
if found:
|
||||
return True
|
||||
elif isinstance(data, list) and isinstance(path, int):
|
||||
return 0 <= path < len(data) and data[path] is not None
|
||||
|
|
@ -158,8 +178,11 @@ def ag_exists(data: dict[Any, Any] | list[Any] | object, path: str | int, separa
|
|||
segments = path_str.split(separator)
|
||||
current = data
|
||||
for seg in segments:
|
||||
if isinstance(current, dict) and seg in current and current[seg] is not None:
|
||||
current = current[seg]
|
||||
if isinstance(current, dict):
|
||||
found, value = _dict_value(current, seg)
|
||||
if not found:
|
||||
return False
|
||||
current = value
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
idx = int(seg)
|
||||
|
|
@ -189,17 +212,16 @@ def ag_delete(
|
|||
The modified structure.
|
||||
|
||||
"""
|
||||
if isinstance(path, list | tuple):
|
||||
for p in path:
|
||||
ag_delete(data, p, separator)
|
||||
return data
|
||||
if not isinstance(data, dict | list):
|
||||
try:
|
||||
data = vars(data)
|
||||
except TypeError:
|
||||
return data # type: ignore
|
||||
if isinstance(data, dict) and path in data:
|
||||
del data[path]
|
||||
if isinstance(path, list | tuple):
|
||||
for p in path:
|
||||
ag_delete(data, p, separator)
|
||||
return data
|
||||
if isinstance(data, dict) and _dict_delete(data, path):
|
||||
return data
|
||||
if isinstance(data, list) and isinstance(path, int):
|
||||
if 0 <= path < len(data):
|
||||
|
|
@ -210,8 +232,12 @@ def ag_delete(
|
|||
last = segments.pop()
|
||||
current = data
|
||||
for seg in segments:
|
||||
if isinstance(current, dict) and seg in current:
|
||||
current = current[seg]
|
||||
if isinstance(current, dict):
|
||||
found, value = _dict_value(current, seg)
|
||||
if not found:
|
||||
current = None
|
||||
break
|
||||
current = value
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
idx = int(seg)
|
||||
|
|
@ -228,8 +254,8 @@ def ag_delete(
|
|||
break
|
||||
if current is None:
|
||||
return data
|
||||
if isinstance(current, dict) and last in current:
|
||||
del current[last]
|
||||
if isinstance(current, dict):
|
||||
_dict_delete(current, last)
|
||||
elif isinstance(current, list):
|
||||
try:
|
||||
idx = int(last)
|
||||
|
|
@ -246,10 +272,12 @@ def run_tests():
|
|||
assert ag_set(d, "a.c.d", 42) == {"a": {"b": 1, "c": {"d": 42}}}
|
||||
|
||||
def test_ag_set_overwrite_error():
|
||||
error = ""
|
||||
try:
|
||||
ag_set({"a": 1}, "a.b", 2)
|
||||
except RuntimeError as e:
|
||||
assert "Cannot set value at path" in str(e) # noqa: PT017
|
||||
error = str(e)
|
||||
assert "Cannot set value at path" in error
|
||||
|
||||
def test_ag_get_value_callable_and_value():
|
||||
assert get_value(5) == 5
|
||||
|
|
@ -320,9 +348,9 @@ def run_tests():
|
|||
]:
|
||||
try:
|
||||
test()
|
||||
print(f"PASS: {test.__name__}") # noqa: T201
|
||||
sys.stdout.write(f"PASS: {test.__name__}\n")
|
||||
except AssertionError:
|
||||
print(f"FAIL {test.__name__}") # noqa: T201
|
||||
sys.stdout.write(f"FAIL {test.__name__}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.log import get_logger
|
||||
from app.library.Services import Services
|
||||
|
||||
from .Scheduler import Scheduler
|
||||
from .Singleton import ThreadSafe
|
||||
|
||||
LOG = logging.getLogger("cache")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class Cache(metaclass=ThreadSafe):
|
||||
|
|
@ -38,7 +38,7 @@ class Cache(metaclass=ThreadSafe):
|
|||
Scheduler.get_instance().add(
|
||||
timer="* * * * *",
|
||||
func=self.cleanup,
|
||||
id=f"{__class__.__name__}.{__class__.cleanup.__name__}",
|
||||
id=f"{type(self).__name__}.{type(self).cleanup.__name__}",
|
||||
)
|
||||
|
||||
def set(self, key: str, value: Any, ttl: float | None = None) -> None:
|
||||
|
|
@ -175,4 +175,8 @@ class Cache(metaclass=ThreadSafe):
|
|||
del self._cache[key]
|
||||
|
||||
if expired_keys:
|
||||
LOG.debug(f"Cleaned up {len(expired_keys)} expired cache entries.")
|
||||
LOG.debug(
|
||||
"Cleaned up %s expired cache entries.",
|
||||
len(expired_keys),
|
||||
extra={"expired_count": len(expired_keys)},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import http.cookiejar
|
||||
import logging
|
||||
from abc import ABC
|
||||
from collections.abc import Callable
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from yt_dlp.networking.common import (
|
||||
_REQUEST_HANDLERS,
|
||||
|
|
@ -20,8 +19,9 @@ from yt_dlp.networking.exceptions import HTTPError
|
|||
from yt_dlp.utils.networking import clean_headers
|
||||
|
||||
from app.library.cf_solver_shared import CACHE, is_cf_challenge, solver
|
||||
from app.library.log import get_logger
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
LOG = get_logger()
|
||||
|
||||
SolverFn = Callable[[Request, Response, RequestHandler], Request | None]
|
||||
|
||||
|
|
@ -215,7 +215,7 @@ class CFSolverRH(RequestHandler, ABC):
|
|||
def _send(self, request: Request) -> Response:
|
||||
host: str = self._get_host(request.url)
|
||||
if host and (cached := CACHE.get(host)):
|
||||
LOG.info(f"Injecting cached Cloudflare cookies for '{host}'.")
|
||||
LOG.info("Injecting cached Cloudflare cookies for '%s'.", host, extra={"host": host})
|
||||
self._apply_solution(cached, request.url, request.headers, self._get_cookiejar(request))
|
||||
|
||||
director: RequestDirector = self._build_fallback()
|
||||
|
|
@ -223,11 +223,13 @@ class CFSolverRH(RequestHandler, ABC):
|
|||
try:
|
||||
response: Response = director.send(request)
|
||||
except HTTPError as error:
|
||||
if error.response and is_cf_challenge(getattr(error.response, "status", None), error.response.headers):
|
||||
if error.response and is_cf_challenge(
|
||||
getattr(error.response, "status", None), cast("Any", error.response.headers)
|
||||
):
|
||||
return self._retry_with_clearance(request, error.response, director)
|
||||
raise
|
||||
|
||||
if is_cf_challenge(getattr(response, "status", None), response.headers):
|
||||
if is_cf_challenge(getattr(response, "status", None), cast("Any", response.headers)):
|
||||
return self._retry_with_clearance(request, response, director)
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
# flake8: noqa: S310
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .cache import Cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
CACHE: Cache = Cache()
|
||||
LOG: logging.Logger = logging.getLogger("cf_solver")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def solver(url: str, cookies: list[dict[str, Any]], user_agent: str | None) -> dict[str, Any] | None:
|
||||
|
|
@ -56,23 +59,36 @@ def solver(url: str, cookies: list[dict[str, Any]], user_agent: str | None) -> d
|
|||
if user_agent:
|
||||
payload.setdefault("headers", {})["User-Agent"] = user_agent
|
||||
|
||||
req = urllib.request.Request(
|
||||
req = urllib.request.Request( # noqa: S310
|
||||
endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
LOG.info(f"Solving Cloudflare challenge for '{host}' via FlareSolverr.")
|
||||
LOG.info(
|
||||
"Solving Cloudflare challenge for '%s' via FlareSolverr.", host, extra={"host": host, "endpoint": endpoint}
|
||||
)
|
||||
start_time = time.time()
|
||||
with urllib.request.urlopen(req, timeout=float(config.flaresolverr_client_timeout)) as resp:
|
||||
with urllib.request.urlopen(req, timeout=float(config.flaresolverr_client_timeout)) as resp: # noqa: S310
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if "ok" != result.get("status"):
|
||||
LOG.error(f"FlareSolverr failed to solve challenge for '{host}': {result.get('message')}")
|
||||
LOG.error(
|
||||
"FlareSolverr failed to solve challenge for '%s': %s",
|
||||
host,
|
||||
result.get("message"),
|
||||
extra={"host": host, "endpoint": endpoint, "solver_message": result.get("message")},
|
||||
)
|
||||
return None
|
||||
|
||||
LOG.info(f"FlareSolverr successfully solved challenge for '{host}'. (Took {time.time() - start_time:.2f} seconds)")
|
||||
elapsed_s: float = time.time() - start_time
|
||||
LOG.info(
|
||||
"FlareSolverr solved challenge for '%s' in %.2f seconds.",
|
||||
host,
|
||||
elapsed_s,
|
||||
extra={"host": host, "endpoint": endpoint, "elapsed_s": round(elapsed_s, 2)},
|
||||
)
|
||||
|
||||
solution = result.get("solution") or {}
|
||||
CACHE.set(
|
||||
|
|
@ -84,7 +100,7 @@ def solver(url: str, cookies: list[dict[str, Any]], user_agent: str | None) -> d
|
|||
return CACHE.get(host)
|
||||
|
||||
|
||||
def is_cf_challenge(status: int | None, headers: dict[str, Any] | None) -> bool:
|
||||
def is_cf_challenge(status: int | None, headers: Mapping[str, Any] | None) -> bool:
|
||||
"""
|
||||
Determine whether a response indicates a Cloudflare challenge.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import logging
|
|||
import multiprocessing
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
|
@ -12,14 +13,25 @@ from typing import TYPE_CHECKING, Any
|
|||
import coloredlogs
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from app.library.log import get_logger
|
||||
|
||||
from .Singleton import Singleton
|
||||
from .Utils import FileLogFormatter
|
||||
from .Utils import JsonLogFormatter
|
||||
from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION
|
||||
|
||||
APP_THIRD_PARTY_LOG_LEVELS: tuple[tuple[str, int], ...] = (
|
||||
("httpx", logging.WARNING),
|
||||
("urllib3.connectionpool", logging.WARNING),
|
||||
("apprise", logging.WARNING),
|
||||
("httpcore", logging.INFO),
|
||||
("aiosqlite", logging.INFO),
|
||||
("asyncio", logging.INFO),
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from subprocess import CompletedProcess
|
||||
|
||||
SUPPORTED_CODECS: tuple[str] = ("h264_qsv", "h264_nvenc", "h264_amf", "h264_videotoolbox", "h264_vaapi", "libx264")
|
||||
SUPPORTED_CODECS: tuple[str, ...] = ("h264_qsv", "h264_nvenc", "h264_amf", "h264_videotoolbox", "h264_vaapi", "libx264")
|
||||
"Supported encoder names in order of preference."
|
||||
|
||||
|
||||
|
|
@ -36,9 +48,6 @@ class Config(metaclass=Singleton):
|
|||
download_path: str = "."
|
||||
"""The path to the download directory."""
|
||||
|
||||
download_path_depth: int = 2
|
||||
"""How many subdirectories to show in auto complete."""
|
||||
|
||||
download_info_expires: int = 10800
|
||||
"""How long (in seconds) the download info is valid before it needs to be re-extracted."""
|
||||
|
||||
|
|
@ -75,9 +84,6 @@ class Config(metaclass=Singleton):
|
|||
log_level: str = "info"
|
||||
"""The log level to use for the application."""
|
||||
|
||||
log_level_file: str = "info"
|
||||
"""The log level to use for the file logging."""
|
||||
|
||||
base_path: str = "/"
|
||||
"""The base path to use for the application."""
|
||||
|
||||
|
|
@ -102,6 +108,9 @@ class Config(metaclass=Singleton):
|
|||
auth_password: str | None = None
|
||||
"""The password to use for basic authentication."""
|
||||
|
||||
disable_exec: bool = False
|
||||
"""Strip some dangerous yt-dlp options."""
|
||||
|
||||
remove_files: bool = False
|
||||
"""Remove downloaded files when removing the record."""
|
||||
|
||||
|
|
@ -171,7 +180,7 @@ class Config(metaclass=Singleton):
|
|||
file_logging: bool = True
|
||||
"Enable file logging."
|
||||
|
||||
secret_key: str
|
||||
secret_key: str | bytes
|
||||
"The secret key to use for the application."
|
||||
|
||||
tasks_handler_timer: str = "15 */1 * * *"
|
||||
|
|
@ -219,6 +228,9 @@ class Config(metaclass=Singleton):
|
|||
default_pagination: int = 50
|
||||
"""The default number of items per page for pagination."""
|
||||
|
||||
queue_display_limit: int = 100
|
||||
"""Maximum number of queued downloads returned to the UI. 0 means unlimited."""
|
||||
|
||||
task_handler_random_delay: float = 60.0
|
||||
"""The maximum random delay in seconds before starting a task handler."""
|
||||
|
||||
|
|
@ -271,10 +283,10 @@ class Config(metaclass=Singleton):
|
|||
"max_workers_per_extractor",
|
||||
"extract_info_timeout",
|
||||
"debugpy_port",
|
||||
"download_path_depth",
|
||||
"download_info_expires",
|
||||
"auto_clear_history_days",
|
||||
"default_pagination",
|
||||
"queue_display_limit",
|
||||
"extract_info_concurrency",
|
||||
"thumb_concurrency",
|
||||
"flaresolverr_max_timeout",
|
||||
|
|
@ -304,6 +316,7 @@ class Config(metaclass=Singleton):
|
|||
"check_for_updates",
|
||||
"thumb_generate",
|
||||
"thumb_sidecar",
|
||||
"disable_exec",
|
||||
)
|
||||
"The variables that are booleans."
|
||||
|
||||
|
|
@ -313,6 +326,7 @@ class Config(metaclass=Singleton):
|
|||
_frontend_vars: tuple = (
|
||||
"download_path",
|
||||
"keep_archive",
|
||||
"log_level",
|
||||
"output_template",
|
||||
"started",
|
||||
"remove_files",
|
||||
|
|
@ -333,6 +347,7 @@ class Config(metaclass=Singleton):
|
|||
"app_build_date",
|
||||
"app_branch",
|
||||
"default_pagination",
|
||||
"queue_display_limit",
|
||||
"check_for_updates",
|
||||
"new_version",
|
||||
"yt_new_version",
|
||||
|
|
@ -360,12 +375,13 @@ class Config(metaclass=Singleton):
|
|||
|
||||
def __init__(self, is_native: bool = False):
|
||||
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute())
|
||||
LOG = get_logger()
|
||||
|
||||
self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or str(Path(baseDefaultPath) / "var" / "config")
|
||||
envFile: str = Path(self.config_path) / ".env"
|
||||
envFile: Path = Path(self.config_path) / ".env"
|
||||
|
||||
if envFile.exists():
|
||||
logging.info(f"Loading environment variables from '{envFile}'.")
|
||||
LOG.info("Loading environment variables from '%s'.", envFile)
|
||||
load_dotenv(envFile)
|
||||
|
||||
self.is_native = is_native
|
||||
|
|
@ -373,7 +389,7 @@ class Config(metaclass=Singleton):
|
|||
self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or str(
|
||||
Path(baseDefaultPath) / "var" / "downloads"
|
||||
)
|
||||
self.app_path = Path(__file__).parent.parent.absolute()
|
||||
self.app_path = str(Path(__file__).parent.parent.absolute())
|
||||
|
||||
for k, v in self._get_attributes().items():
|
||||
if k.startswith("_") or k in self._manual_vars:
|
||||
|
|
@ -395,7 +411,7 @@ class Config(metaclass=Singleton):
|
|||
for key in re.findall(r"\{.*?\}", v):
|
||||
localKey: str = key[1:-1]
|
||||
if localKey not in self.__dict__:
|
||||
logging.error(f"Config variable '{k}' had non-existing config reference '{key}'.")
|
||||
LOG.error("Config variable '%s' had non-existing config reference '%s'.", k, key)
|
||||
sys.exit(1)
|
||||
|
||||
v: str = v.replace(key, str(getattr(self, localKey)))
|
||||
|
|
@ -433,18 +449,23 @@ class Config(metaclass=Singleton):
|
|||
encoding="utf-8",
|
||||
)
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("config")
|
||||
|
||||
if self.debug:
|
||||
try:
|
||||
import debugpy
|
||||
|
||||
debugpy.listen(("0.0.0.0", self.debugpy_port), in_process_debug_adapter=True)
|
||||
LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.")
|
||||
LOG.info(
|
||||
"Starting debugpy server on '0.0.0.0:%s'.",
|
||||
self.debugpy_port,
|
||||
extra={"host": "0.0.0.0", "port": self.debugpy_port},
|
||||
)
|
||||
except ImportError:
|
||||
LOG.error("debugpy package not found, install it with 'uv sync'.")
|
||||
LOG.error("debugpy package not found; install it with 'uv sync'.")
|
||||
except Exception as e:
|
||||
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
|
||||
LOG.exception(
|
||||
"Failed to start debugpy server.",
|
||||
extra={"host": "0.0.0.0", "port": self.debugpy_port, "exception_type": type(e).__name__},
|
||||
)
|
||||
|
||||
if (Path(self.config_path) / "ytdlp.cli").exists():
|
||||
LOG.error("Support for ./ytdlp.cli file is removed, migrate to presets and remove the file.")
|
||||
|
|
@ -453,12 +474,16 @@ class Config(metaclass=Singleton):
|
|||
LOG.warning("Keep temp files option is enabled.")
|
||||
|
||||
if self.auth_password and self.auth_username:
|
||||
LOG.info(f"Authentication enabled with username '{self.auth_username}'.")
|
||||
LOG.info(
|
||||
"Basic authentication is enabled for user '%s'.",
|
||||
self.auth_username,
|
||||
extra={"auth_username": self.auth_username},
|
||||
)
|
||||
|
||||
if self.file_logging:
|
||||
log_level_file: int | None = getattr(logging, self.log_level_file.upper(), None)
|
||||
if not isinstance(log_level_file, int):
|
||||
msg = f"Invalid file log level '{self.log_level_file}' specified."
|
||||
file_log_level: int | None = getattr(logging, self.log_level.upper(), None)
|
||||
if not isinstance(file_log_level, int):
|
||||
msg = f"Invalid log level '{self.log_level}' specified."
|
||||
raise TypeError(msg)
|
||||
|
||||
loggingPath: Path = Path(self.config_path) / "logs"
|
||||
|
|
@ -466,18 +491,18 @@ class Config(metaclass=Singleton):
|
|||
loggingPath.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = TimedRotatingFileHandler(
|
||||
filename=loggingPath / "app.log",
|
||||
filename=loggingPath / "app.jsonl",
|
||||
when="midnight",
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
handler.setLevel(log_level_file)
|
||||
formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s")
|
||||
handler.setLevel(file_log_level)
|
||||
formatter = JsonLogFormatter()
|
||||
handler.setFormatter(formatter)
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
key_file: str = Path(self.config_path) / "secret.key"
|
||||
key_file: Path = Path(self.config_path) / "secret.key"
|
||||
|
||||
if key_file.exists() and key_file.stat().st_size > 2:
|
||||
with open(key_file, "rb") as f:
|
||||
|
|
@ -487,16 +512,9 @@ class Config(metaclass=Singleton):
|
|||
with open(key_file, "wb") as f:
|
||||
f.write(self.secret_key)
|
||||
|
||||
self.started = time.time()
|
||||
self.started = int(time.time())
|
||||
|
||||
_log_levels = (
|
||||
("httpx", logging.WARNING),
|
||||
("urllib3.connectionpool", logging.WARNING),
|
||||
("apprise", logging.WARNING),
|
||||
("httpcore", logging.INFO),
|
||||
("aiosqlite", logging.INFO),
|
||||
)
|
||||
for _tool, _level in _log_levels:
|
||||
for _tool, _level in APP_THIRD_PARTY_LOG_LEVELS:
|
||||
logging.getLogger(_tool).setLevel(_level)
|
||||
|
||||
if self.app_env not in ("production", "development"):
|
||||
|
|
@ -528,7 +546,7 @@ class Config(metaclass=Singleton):
|
|||
|
||||
def _get_attributes(self) -> dict:
|
||||
attrs: dict = {}
|
||||
vClass: str = self.__class__
|
||||
vClass: type = self.__class__
|
||||
|
||||
for attribute in vClass.__dict__:
|
||||
if attribute.startswith("_"):
|
||||
|
|
@ -571,6 +589,9 @@ class Config(metaclass=Singleton):
|
|||
data: dict[str, Any] = {k: getattr(self, k) for k in self._frontend_vars}
|
||||
|
||||
data["ytdlp_version"] = Config._ytdlp_version()
|
||||
from app.library.log_control import get_runtime_log_level
|
||||
|
||||
data["runtime_log_level"] = get_runtime_log_level()
|
||||
return data
|
||||
|
||||
def get_replacers(self) -> dict[str, str]:
|
||||
|
|
@ -581,7 +602,7 @@ class Config(metaclass=Singleton):
|
|||
dict[str, str]: The replacer variables.
|
||||
|
||||
"""
|
||||
keys: tuple[str] = ("os_sep", "download_path", "temp_path", "config_path", "archive_file")
|
||||
keys: tuple[str, ...] = ("os_sep", "download_path", "temp_path", "config_path", "archive_file")
|
||||
return {k: getattr(self, k) for k in keys}
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -598,15 +619,21 @@ class Config(metaclass=Singleton):
|
|||
Updates the version of the application using git tags.
|
||||
This is used to set the version to the latest git tag.
|
||||
"""
|
||||
git_path: str = Path(__file__).parent / ".." / ".." / ".git"
|
||||
LOG = get_logger()
|
||||
git_path: Path = Path(__file__).parent / ".." / ".." / ".git"
|
||||
if not git_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
git = shutil.which("git")
|
||||
if not git:
|
||||
LOG.warning("Git executable was not found.")
|
||||
return
|
||||
|
||||
branch_result: CompletedProcess[str] = subprocess.run(
|
||||
["git", "-c", f"safe.directory={git_path.parent!s}", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
|
||||
[git, "-c", f"safe.directory={git_path.parent!s}", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=os.path.dirname(git_path),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
@ -615,16 +642,16 @@ class Config(metaclass=Singleton):
|
|||
)
|
||||
|
||||
if 0 != branch_result.returncode:
|
||||
logging.error(f"Git rev-parse failed: {branch_result.stderr.strip()}")
|
||||
LOG.error("Git rev-parse failed: %s", branch_result.stderr.strip())
|
||||
return
|
||||
|
||||
branch_name: str = branch_result.stdout.strip()
|
||||
if not branch_name:
|
||||
logging.warning("Git branch name is empty.")
|
||||
LOG.warning("Git branch name is empty.")
|
||||
return
|
||||
|
||||
commit_result: CompletedProcess[str] = subprocess.run(
|
||||
["git", "-c", f"safe.directory={git_path.parent!s}", "log", "-1", "--format=%ct_%H"], # noqa: S607
|
||||
[git, "-c", f"safe.directory={git_path.parent!s}", "log", "-1", "--format=%ct_%H"],
|
||||
cwd=os.path.dirname(git_path),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
@ -633,12 +660,12 @@ class Config(metaclass=Singleton):
|
|||
)
|
||||
|
||||
if 0 != commit_result.returncode:
|
||||
logging.error(f"Git log failed: {commit_result.stderr.strip()}")
|
||||
LOG.error("Git log failed: %s", commit_result.stderr.strip())
|
||||
return
|
||||
|
||||
commit_info: str = commit_result.stdout.strip()
|
||||
if not commit_info:
|
||||
logging.warning("Git commit info is empty.")
|
||||
LOG.warning("Git commit info is empty.")
|
||||
return
|
||||
|
||||
commit_date, commit_sha = commit_info.split("_", 1)
|
||||
|
|
@ -654,6 +681,6 @@ class Config(metaclass=Singleton):
|
|||
"commit": self.app_commit_sha,
|
||||
"build_date": self.app_build_date,
|
||||
}
|
||||
logging.info(f"Application version info set to '{version_data}'")
|
||||
LOG.info("Application version info set to '%s'", version_data)
|
||||
except Exception as e:
|
||||
logging.error(f"Error while getting git version: {e!s}")
|
||||
LOG.error("Error while getting git version: %s", e)
|
||||
|
|
|
|||
760
app/library/diagnostics.py
Normal file
760
app/library/diagnostics.py
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.metadata
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.httpx_client import resolve_curl_transport
|
||||
|
||||
CheckStatus = Literal["pass", "fail", "warn", "skip"]
|
||||
ReportStatus = Literal["ok", "degraded", "error"]
|
||||
|
||||
MIN_PYTHON: tuple[int, int] = (3, 13)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DiagnosticCheck:
|
||||
id: str
|
||||
label: str
|
||||
group: str
|
||||
required: bool
|
||||
status: CheckStatus
|
||||
description: str
|
||||
message: str
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"group": self.group,
|
||||
"required": self.required,
|
||||
"status": self.status,
|
||||
"description": self.description,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CheckMeta:
|
||||
group: str
|
||||
description: str
|
||||
|
||||
|
||||
BINARY_META: dict[str, CheckMeta] = {
|
||||
"ffmpeg": CheckMeta(
|
||||
group="core", description="Used for thumbnails and streaming and various media processing tasks."
|
||||
),
|
||||
"ffprobe": CheckMeta(group="core", description="Used for media inspection and metadata extraction."),
|
||||
"deno": CheckMeta(group="youtube", description="Used for yt-dlp YouTube support."),
|
||||
"yt_dlp_cli": CheckMeta(group="core", description="Used by the built-in terminal."),
|
||||
"aria2c": CheckMeta(group="advanced", description="External downloader for yt-dlp."),
|
||||
"mkvpropedit": CheckMeta(group="advanced", description="Edit Matroska container properties without remux."),
|
||||
"mkvextract": CheckMeta(group="advanced", description="Extract tracks from Matroska containers."),
|
||||
"mp4box": CheckMeta(group="advanced", description="MP4 container manipulation tool."),
|
||||
}
|
||||
|
||||
DIRECTORY_DESCRIPTIONS: dict[str, str] = {
|
||||
"config_path": "Directory for config files and app data.",
|
||||
"download_path": "Directory where downloads are saved.",
|
||||
"temp_path": "Directory used for temporary files.",
|
||||
}
|
||||
|
||||
|
||||
def _first_line(*parts: str) -> str:
|
||||
for part in parts:
|
||||
for line in part.splitlines():
|
||||
if line := line.strip():
|
||||
return line
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _bin_version(value: str) -> str:
|
||||
if not (line := value.strip()):
|
||||
return ""
|
||||
|
||||
if match := re.match(r"^deno\s+([0-9][^\s]*)", line, flags=re.IGNORECASE):
|
||||
return match.group(1).strip()
|
||||
|
||||
if match := re.match(r"^mkv\w+\s+v([0-9][^\s]*)", line, flags=re.IGNORECASE):
|
||||
return match.group(1).strip()
|
||||
|
||||
if match := re.search(r"\bversion\s+(.+?)(?:\s+Copyright\b|$)", line, flags=re.IGNORECASE):
|
||||
return match.group(1).strip()
|
||||
|
||||
return line
|
||||
|
||||
|
||||
def _parse_browser_url(value: str) -> tuple[str, str]:
|
||||
if value.startswith(("selenium+http://", "selenium+https://")):
|
||||
return "selenium", value.removeprefix("selenium+")
|
||||
|
||||
if value.startswith(("playwright+ws://", "playwright+wss://")):
|
||||
return "playwright", value.removeprefix("playwright+")
|
||||
|
||||
if value.startswith("playwright+cdp://"):
|
||||
return "playwright", f"http://{value.removeprefix('playwright+cdp://')}"
|
||||
|
||||
if value.startswith("playwright+cdp+"):
|
||||
return "playwright", value.removeprefix("playwright+cdp+")
|
||||
|
||||
return "unknown", value
|
||||
|
||||
|
||||
def _safe_url(url: str) -> str:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
except Exception:
|
||||
return "***"
|
||||
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return "***"
|
||||
|
||||
netloc = parsed.netloc
|
||||
if parsed.username or parsed.password:
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
netloc = f"***:***@{host}" if host else "***:***"
|
||||
|
||||
path = "/***" if parsed.path and parsed.path != "/" else parsed.path
|
||||
query = "***" if parsed.query else ""
|
||||
fragment = "***" if parsed.fragment else ""
|
||||
return urllib.parse.urlunsplit((parsed.scheme, netloc, path, query, fragment))
|
||||
|
||||
|
||||
def _package_version(name: str) -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version(name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _package_check(
|
||||
name: str,
|
||||
*,
|
||||
label: str,
|
||||
group: str,
|
||||
required: bool,
|
||||
description: str,
|
||||
present_message: str = "Installed.",
|
||||
missing_message: str = "Missing.",
|
||||
missing_status: CheckStatus = "skip",
|
||||
check_id: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> DiagnosticCheck:
|
||||
version = _package_version(name)
|
||||
installed = version is not None
|
||||
base_details: dict[str, Any] = {"package": name, "version": version}
|
||||
if details:
|
||||
base_details.update(details)
|
||||
|
||||
return DiagnosticCheck(
|
||||
id=check_id or name.replace("-", "_"),
|
||||
label=label,
|
||||
group=group,
|
||||
required=required,
|
||||
status="pass" if installed else ("fail" if required else missing_status),
|
||||
description=description,
|
||||
message=present_message if installed else missing_message,
|
||||
details=base_details,
|
||||
)
|
||||
|
||||
|
||||
def _check_ytdlp_package() -> DiagnosticCheck:
|
||||
version = Config._ytdlp_version()
|
||||
installed = version != "0.0.0"
|
||||
|
||||
return DiagnosticCheck(
|
||||
id="yt_dlp_package",
|
||||
label="yt-dlp package",
|
||||
group="core",
|
||||
required=True,
|
||||
status="pass" if installed else "fail",
|
||||
description="Main downloader library used by the app.",
|
||||
message="Installed." if installed else "Missing.",
|
||||
details={"version": version},
|
||||
)
|
||||
|
||||
|
||||
def _check_apprise_package(config: Config) -> DiagnosticCheck:
|
||||
configured = Path(config.apprise_config).exists()
|
||||
return _package_check(
|
||||
"apprise",
|
||||
label="Apprise",
|
||||
group="notifications",
|
||||
required=False,
|
||||
description="Sends notification targets.",
|
||||
present_message="Installed.",
|
||||
missing_message="Missing." if configured else "Not installed.",
|
||||
missing_status="warn" if configured else "skip",
|
||||
check_id="apprise",
|
||||
details={"config": str(config.apprise_config) if configured else None},
|
||||
)
|
||||
|
||||
|
||||
def _check_curl_transport() -> DiagnosticCheck:
|
||||
available = resolve_curl_transport(use_curl=True)
|
||||
version = _package_version("httpx-curl-cffi")
|
||||
|
||||
return DiagnosticCheck(
|
||||
id="curl_transport",
|
||||
label="curl-cffi transport",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="pass" if available else "skip",
|
||||
description="Transport for impersonation support.",
|
||||
message="Installed." if available else "Not installed.",
|
||||
details={"package": "httpx-curl-cffi", "version": version},
|
||||
)
|
||||
|
||||
|
||||
def _check_pot_provider_package() -> DiagnosticCheck:
|
||||
return _package_check(
|
||||
"bgutil-ytdlp-pot-provider",
|
||||
check_id="pot_provider_plugin",
|
||||
label="POT provider plugin",
|
||||
group="youtube",
|
||||
required=False,
|
||||
description="Optional plugin for external POT token providers for youtube.",
|
||||
present_message="Installed.",
|
||||
missing_message="Not installed.",
|
||||
)
|
||||
|
||||
|
||||
def _check_configured_pip_packages(config: Config) -> list[DiagnosticCheck]:
|
||||
checks: list[DiagnosticCheck] = []
|
||||
|
||||
for raw_pkg in config.pip_packages.split(" "):
|
||||
pkg = raw_pkg.strip()
|
||||
if not pkg:
|
||||
continue
|
||||
|
||||
name = pkg.split("==", 1)[0].split(">=", 1)[0].split("<=", 1)[0].strip()
|
||||
installed = False
|
||||
version = None
|
||||
|
||||
try:
|
||||
version = importlib.metadata.version(name)
|
||||
installed = True
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
installed = False
|
||||
|
||||
checks.append(
|
||||
DiagnosticCheck(
|
||||
id=f"pip_{name.replace('-', '_')}",
|
||||
label=name,
|
||||
group="custom",
|
||||
required=False,
|
||||
status="pass" if installed else "warn",
|
||||
description="Configured extra pip package.",
|
||||
message="Installed." if installed else "Missing.",
|
||||
details={"package": name, "requested": pkg, "version": version},
|
||||
)
|
||||
)
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
def _check_browser_endpoint() -> DiagnosticCheck:
|
||||
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
|
||||
if not raw_url:
|
||||
return DiagnosticCheck(
|
||||
id="browser_endpoint",
|
||||
label="Remote browser",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="skip",
|
||||
description="Endpoint used by the browser extractor.",
|
||||
message="Not configured.",
|
||||
details={},
|
||||
)
|
||||
|
||||
engine, parsed_url = _parse_browser_url(raw_url)
|
||||
if engine == "unknown":
|
||||
return DiagnosticCheck(
|
||||
id="browser_endpoint",
|
||||
label="Remote browser",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="warn",
|
||||
description="Endpoint used by the browser extractor.",
|
||||
message="Invalid URL.",
|
||||
details={"endpoint": _safe_url(raw_url)},
|
||||
)
|
||||
|
||||
return DiagnosticCheck(
|
||||
id="browser_endpoint",
|
||||
label="Remote browser",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="pass",
|
||||
description="Endpoint used by the browser extractor.",
|
||||
message="Configured.",
|
||||
details={
|
||||
"engine": engine,
|
||||
"endpoint": _safe_url(parsed_url),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _check_browser_client() -> DiagnosticCheck:
|
||||
raw_url = (os.environ.get("YTP_BROWSER_URL") or "").strip()
|
||||
if not raw_url:
|
||||
return DiagnosticCheck(
|
||||
id="browser_client",
|
||||
label="Browser client",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="skip",
|
||||
description="Client package required for the configured browser backend.",
|
||||
message="Not configured.",
|
||||
details={},
|
||||
)
|
||||
|
||||
engine, _ = _parse_browser_url(raw_url)
|
||||
if engine == "unknown":
|
||||
return DiagnosticCheck(
|
||||
id="browser_client",
|
||||
label="Browser client",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="skip",
|
||||
description="Client package required for the configured browser backend.",
|
||||
message="Invalid URL.",
|
||||
details={},
|
||||
)
|
||||
|
||||
package_name = "selenium" if engine == "selenium" else "playwright"
|
||||
version = _package_version(package_name)
|
||||
|
||||
return DiagnosticCheck(
|
||||
id="browser_client",
|
||||
label="Browser client",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="pass" if version else "warn",
|
||||
description="Client package required for the configured browser backend.",
|
||||
message="Installed." if version else "Missing.",
|
||||
details={"package": package_name, "version": version},
|
||||
)
|
||||
|
||||
|
||||
def _check_flaresolverr(config: Config) -> DiagnosticCheck:
|
||||
endpoint = (config.flaresolverr_url or "").strip()
|
||||
if not endpoint:
|
||||
return DiagnosticCheck(
|
||||
id="flaresolverr",
|
||||
label="FlareSolverr",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="skip",
|
||||
description="Optional Cloudflare challenge bypass service.",
|
||||
message="Not configured.",
|
||||
details={},
|
||||
)
|
||||
|
||||
parsed = urllib.parse.urlsplit(endpoint)
|
||||
valid = parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
return DiagnosticCheck(
|
||||
id="flaresolverr",
|
||||
label="FlareSolverr",
|
||||
group="advanced",
|
||||
required=False,
|
||||
status="pass" if valid else "warn",
|
||||
description="Optional Cloudflare challenge bypass service.",
|
||||
message="Configured." if valid else "Invalid URL.",
|
||||
details={"endpoint": _safe_url(endpoint)},
|
||||
)
|
||||
|
||||
|
||||
def _probe_directory(path: Path) -> tuple[CheckStatus, str]:
|
||||
if not path.exists():
|
||||
return "fail", "Directory does not exist."
|
||||
|
||||
if not path.is_dir():
|
||||
return "fail", "Path is not a directory."
|
||||
|
||||
if not os.access(path, os.R_OK | os.W_OK | os.X_OK):
|
||||
return "fail", "Not readable or writable."
|
||||
|
||||
return "pass", "Writable."
|
||||
|
||||
|
||||
async def _check_directory(path: Path, *, check_id: str, label: str) -> DiagnosticCheck:
|
||||
status, message = await asyncio.to_thread(_probe_directory, path)
|
||||
return DiagnosticCheck(
|
||||
id=check_id,
|
||||
label=label,
|
||||
group="core",
|
||||
required=True,
|
||||
status=status,
|
||||
description=DIRECTORY_DESCRIPTIONS.get(check_id, "Directory check."),
|
||||
message=message,
|
||||
details={"path": str(path)},
|
||||
)
|
||||
|
||||
|
||||
def _probe_db_file(path: Path) -> tuple[CheckStatus, str]:
|
||||
if not path.exists():
|
||||
return "fail", "Missing."
|
||||
|
||||
if not path.is_file():
|
||||
return "fail", "Invalid path."
|
||||
|
||||
if not os.access(path, os.R_OK):
|
||||
return "fail", "Not readable."
|
||||
|
||||
if not os.access(path, os.W_OK):
|
||||
return "fail", "Not writable."
|
||||
|
||||
try:
|
||||
uri = f"file:{urllib.parse.quote(str(path))}?mode=ro"
|
||||
with sqlite3.connect(uri, timeout=3, uri=True) as conn:
|
||||
conn.execute("SELECT name FROM sqlite_master LIMIT 1")
|
||||
except sqlite3.Error as exc:
|
||||
return "fail", f"SQLite error. {exc!s}"
|
||||
|
||||
return "pass", "Ready."
|
||||
|
||||
|
||||
async def _check_db_file(path: Path) -> DiagnosticCheck:
|
||||
status, message = await asyncio.to_thread(_probe_db_file, path)
|
||||
return DiagnosticCheck(
|
||||
id="database",
|
||||
label="SQLite database",
|
||||
group="core",
|
||||
required=True,
|
||||
status=status,
|
||||
description="Local database used for app state and history.",
|
||||
message=message,
|
||||
details={"path": str(path)},
|
||||
)
|
||||
|
||||
|
||||
async def _run_command(command: str, *args: str, wait_seconds: float = 5.0) -> tuple[int, str, str]:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=wait_seconds)
|
||||
except TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
raise
|
||||
|
||||
return proc.returncode or 0, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _binary_meta(check_id: str) -> CheckMeta:
|
||||
return BINARY_META.get(check_id, CheckMeta(group="core", description="Command line tool."))
|
||||
|
||||
|
||||
def _make_binary_check(
|
||||
command: str,
|
||||
*,
|
||||
check_id: str,
|
||||
label: str,
|
||||
required: bool,
|
||||
status: CheckStatus,
|
||||
message: str,
|
||||
path: str | None = None,
|
||||
version: str | None = None,
|
||||
) -> DiagnosticCheck:
|
||||
meta = _binary_meta(check_id)
|
||||
details: dict[str, Any] = {"command": command}
|
||||
|
||||
if path:
|
||||
details["path"] = path
|
||||
if version:
|
||||
details["version"] = version
|
||||
|
||||
return DiagnosticCheck(
|
||||
id=check_id,
|
||||
label=label,
|
||||
group=meta.group,
|
||||
required=required,
|
||||
status=status,
|
||||
description=meta.description,
|
||||
message=message,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_binary(*names: str) -> tuple[str, str | None]:
|
||||
for name in names:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return name, path
|
||||
return names[0], None
|
||||
|
||||
|
||||
async def _check_binary(
|
||||
command: str,
|
||||
*,
|
||||
check_id: str,
|
||||
label: str,
|
||||
required: bool,
|
||||
args: tuple[str, ...] = ("--version",),
|
||||
enabled: bool = True,
|
||||
disabled_message: str = "Check is not applicable.",
|
||||
missing_status: CheckStatus | None = None,
|
||||
aliases: tuple[str, ...] = (),
|
||||
) -> DiagnosticCheck:
|
||||
if not enabled:
|
||||
return _make_binary_check(
|
||||
command,
|
||||
check_id=check_id,
|
||||
label=label,
|
||||
required=required,
|
||||
status="skip",
|
||||
message=disabled_message,
|
||||
)
|
||||
|
||||
resolved_name, command_path = _resolve_binary(command, *aliases)
|
||||
not_found_status: CheckStatus = missing_status or ("fail" if required else "warn")
|
||||
|
||||
if not command_path:
|
||||
return _make_binary_check(
|
||||
resolved_name,
|
||||
check_id=check_id,
|
||||
label=label,
|
||||
required=required,
|
||||
status=not_found_status,
|
||||
message="Missing." if not_found_status == "fail" else "Not installed.",
|
||||
)
|
||||
|
||||
try:
|
||||
return_code, stdout, stderr = await _run_command(resolved_name, *args)
|
||||
except FileNotFoundError:
|
||||
return _make_binary_check(
|
||||
resolved_name,
|
||||
check_id=check_id,
|
||||
label=label,
|
||||
required=required,
|
||||
status=not_found_status,
|
||||
message="Missing." if not_found_status == "fail" else "Not installed.",
|
||||
path=command_path,
|
||||
)
|
||||
except TimeoutError:
|
||||
return _make_binary_check(
|
||||
resolved_name,
|
||||
check_id=check_id,
|
||||
label=label,
|
||||
required=required,
|
||||
status="fail" if required else "warn",
|
||||
message="Timed out.",
|
||||
path=command_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _make_binary_check(
|
||||
resolved_name,
|
||||
check_id=check_id,
|
||||
label=label,
|
||||
required=required,
|
||||
status="fail" if required else "warn",
|
||||
message=f"Error. {exc!s}",
|
||||
path=command_path,
|
||||
)
|
||||
|
||||
version: str = _bin_version(_first_line(stdout, stderr))
|
||||
status: CheckStatus = "pass" if return_code == 0 else ("fail" if required else "warn")
|
||||
message: str = "Available." if return_code == 0 else f"Exit code {return_code}."
|
||||
|
||||
return _make_binary_check(
|
||||
resolved_name,
|
||||
check_id=check_id,
|
||||
label=label,
|
||||
required=required,
|
||||
status=status,
|
||||
message=message,
|
||||
path=command_path,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
def _make_runtime(config: Config) -> dict[str, Any]:
|
||||
started = int(config.started) if config.started else 0
|
||||
uptime_seconds = max(int(time.time()) - started, 0) if started else 0
|
||||
|
||||
return {
|
||||
"app_version": config.app_version,
|
||||
"app_branch": config.app_branch,
|
||||
"app_commit_sha": config.app_commit_sha,
|
||||
"app_build_date": config.app_build_date,
|
||||
"started": started,
|
||||
"uptime_seconds": uptime_seconds,
|
||||
"platform": platform.system().lower(),
|
||||
"platform_release": platform.release(),
|
||||
"platform_machine": platform.machine(),
|
||||
"python_version": platform.python_version(),
|
||||
"python_minimum": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
|
||||
"is_native": config.is_native,
|
||||
"console_enabled": config.console_enabled,
|
||||
}
|
||||
|
||||
|
||||
def _requirements() -> dict[str, Any]:
|
||||
return {
|
||||
"python": {
|
||||
"current": platform.python_version(),
|
||||
"required": f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}",
|
||||
"supported": sys.version_info[:2] >= MIN_PYTHON,
|
||||
"note": "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _summarize(checks: list[DiagnosticCheck]) -> tuple[ReportStatus, dict[str, int]]:
|
||||
summary = {"total": len(checks), "pass": 0, "fail": 0, "warn": 0, "skip": 0, "required_failed": 0}
|
||||
|
||||
for check in checks:
|
||||
summary[check.status] += 1
|
||||
if check.required and check.status == "fail":
|
||||
summary["required_failed"] += 1
|
||||
|
||||
if summary["required_failed"] > 0:
|
||||
status: ReportStatus = "error"
|
||||
elif summary["warn"] > 0 or any(check.status == "fail" for check in checks):
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "ok"
|
||||
|
||||
return status, summary
|
||||
|
||||
|
||||
def _make_report(config: Config, checks: list[DiagnosticCheck]) -> dict[str, Any]:
|
||||
status, summary = _summarize(checks)
|
||||
return {
|
||||
"status": status,
|
||||
"generated_at": int(time.time()),
|
||||
"summary": summary,
|
||||
"runtime": _make_runtime(config),
|
||||
"requirements": _requirements(),
|
||||
"checks": [check.to_dict() for check in checks],
|
||||
}
|
||||
|
||||
|
||||
def diagnostics_error_report(config: Config) -> dict[str, Any]:
|
||||
return _make_report(
|
||||
config,
|
||||
[
|
||||
DiagnosticCheck(
|
||||
id="diagnostics",
|
||||
label="Diagnostics",
|
||||
group="core",
|
||||
required=True,
|
||||
status="fail",
|
||||
description="Diagnostics collector.",
|
||||
message="Diagnostics collection failed.",
|
||||
details={},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def collect_diagnostics(config: Config) -> dict[str, Any]:
|
||||
checks: list[DiagnosticCheck] = [
|
||||
_check_ytdlp_package(),
|
||||
_check_apprise_package(config),
|
||||
_check_curl_transport(),
|
||||
_check_pot_provider_package(),
|
||||
_check_browser_endpoint(),
|
||||
_check_browser_client(),
|
||||
_check_flaresolverr(config),
|
||||
]
|
||||
checks.extend(_check_configured_pip_packages(config))
|
||||
|
||||
storage_checks, binary_checks = await asyncio.gather(
|
||||
asyncio.gather(
|
||||
_check_directory(Path(config.config_path), check_id="config_path", label="Config directory"),
|
||||
_check_directory(Path(config.download_path), check_id="download_path", label="Download directory"),
|
||||
_check_directory(Path(config.temp_path), check_id="temp_path", label="Temp directory"),
|
||||
_check_db_file(Path(config.db_file)),
|
||||
),
|
||||
asyncio.gather(
|
||||
_check_binary(
|
||||
"ffmpeg",
|
||||
check_id="ffmpeg",
|
||||
label="ffmpeg",
|
||||
required=True,
|
||||
args=("-version",),
|
||||
),
|
||||
_check_binary(
|
||||
"ffprobe",
|
||||
check_id="ffprobe",
|
||||
label="ffprobe",
|
||||
required=True,
|
||||
args=("-version",),
|
||||
),
|
||||
_check_binary(
|
||||
"deno",
|
||||
check_id="deno",
|
||||
label="deno",
|
||||
required=True,
|
||||
),
|
||||
_check_binary(
|
||||
"yt-dlp",
|
||||
check_id="yt_dlp_cli",
|
||||
label="yt-dlp CLI",
|
||||
required=True,
|
||||
enabled=config.console_enabled,
|
||||
disabled_message="Disabled.",
|
||||
),
|
||||
_check_binary(
|
||||
"aria2c",
|
||||
check_id="aria2c",
|
||||
label="aria2c",
|
||||
required=False,
|
||||
missing_status="skip",
|
||||
),
|
||||
_check_binary(
|
||||
"mkvpropedit",
|
||||
check_id="mkvpropedit",
|
||||
label="mkvpropedit",
|
||||
required=False,
|
||||
missing_status="skip",
|
||||
),
|
||||
_check_binary(
|
||||
"mkvextract",
|
||||
check_id="mkvextract",
|
||||
label="mkvextract",
|
||||
required=False,
|
||||
missing_status="skip",
|
||||
),
|
||||
_check_binary(
|
||||
"MP4Box",
|
||||
check_id="mp4box",
|
||||
label="MP4Box",
|
||||
required=False,
|
||||
missing_status="skip",
|
||||
args=("-version",),
|
||||
aliases=("mp4box",),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
checks.extend(storage_checks)
|
||||
checks.extend(binary_checks)
|
||||
|
||||
return _make_report(config, checks)
|
||||
|
|
@ -19,9 +19,10 @@ from app.features.ytdlp.utils import extract_ytdlp_logs
|
|||
from app.features.ytdlp.ytdlp import YTDLP
|
||||
from app.library.config import Config
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import create_cookies_file
|
||||
|
||||
from ...features.ytdlp.extractor import extract_info_sync
|
||||
from ...features.ytdlp.extractor import REEXTRACT_INFO_KEY, extract_info_sync
|
||||
from .hooks import HookHandlers, NestedLogger
|
||||
from .process_manager import ProcessManager
|
||||
from .status_tracker import StatusTracker
|
||||
|
|
@ -38,6 +39,14 @@ if TYPE_CHECKING:
|
|||
class Download:
|
||||
update_task: asyncio.Task | None = None
|
||||
|
||||
@staticmethod
|
||||
def _raise_no_formats(msg: str) -> None:
|
||||
raise ValueError(msg)
|
||||
|
||||
@staticmethod
|
||||
def _raise_interrupted() -> None:
|
||||
raise SystemExit(130)
|
||||
|
||||
def __init__(self, info: ItemDTO, info_dict: dict | None = None, logs: list[str] | None = None):
|
||||
"""
|
||||
Initialize download task.
|
||||
|
|
@ -50,7 +59,7 @@ class Download:
|
|||
"""
|
||||
config: Config = Config.get_instance()
|
||||
|
||||
self.download_dir: str = info.download_dir
|
||||
self.download_dir: str = info.download_dir or ""
|
||||
self.temp_dir: str | None = info.temp_dir
|
||||
self.template: str | None = info.template
|
||||
self.template_chapter: str | None = info.template_chapter
|
||||
|
|
@ -64,7 +73,7 @@ class Download:
|
|||
self.max_workers = int(config.max_workers)
|
||||
self.is_live: bool = bool(info.is_live) or info.live_in is not None
|
||||
self.info_dict: dict | None = info_dict
|
||||
self.logger: logging.Logger = logging.getLogger(f"Download.{info.id or info._id}")
|
||||
self.logger: logging.Logger = get_logger()
|
||||
self.started_time = 0
|
||||
self.queue_time: datetime = datetime.now(tz=UTC)
|
||||
self.logs: list[str] = logs or []
|
||||
|
|
@ -97,6 +106,16 @@ class Download:
|
|||
params: dict[str, Any] = {}
|
||||
download_skipped = False
|
||||
|
||||
hook_handlers = self._hook_handlers
|
||||
if hook_handlers is None:
|
||||
msg = "_hook_handlers must be initialized before _download(). Call start() first."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
status_queue = self.status_queue
|
||||
if status_queue is None:
|
||||
msg = "status_queue must be initialized before _download(). Call start() first."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
try:
|
||||
params = (
|
||||
self.info.get_ytdlp_opts()
|
||||
|
|
@ -124,9 +143,9 @@ class Download:
|
|||
|
||||
params.update(
|
||||
{
|
||||
"progress_hooks": [self._hook_handlers.progress_hook],
|
||||
"postprocessor_hooks": [self._hook_handlers.postprocessor_hook],
|
||||
"post_hooks": [self._hook_handlers.post_hook],
|
||||
"progress_hooks": [hook_handlers.progress_hook],
|
||||
"postprocessor_hooks": [hook_handlers.postprocessor_hook],
|
||||
"post_hooks": [hook_handlers.post_hook],
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -136,30 +155,96 @@ class Download:
|
|||
|
||||
if self.info.cookies:
|
||||
try:
|
||||
cookie_file = Path(self._temp_manager.temp_path or self.temp_dir) / f"cookie_{self.info._id}.txt"
|
||||
cookie_file = (
|
||||
Path(self._temp_manager.temp_path or self.temp_dir or self.download_dir)
|
||||
/ f"cookie_{self.info._id}.txt"
|
||||
)
|
||||
self.logger.debug(
|
||||
f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'."
|
||||
f"Creating cookie file for '{self.info.title}' at '{cookie_file}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"path": str(cookie_file),
|
||||
"has_cookies": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
params["cookiefile"] = str(create_cookies_file(self.info.cookies, cookie_file))
|
||||
except Exception as e:
|
||||
err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'."
|
||||
self.logger.error(err_msg)
|
||||
self.logger.error(
|
||||
err_msg,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"has_cookies": True,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
raise ValueError(err_msg) from e
|
||||
|
||||
if self.info_dict and isinstance(self.info_dict, dict):
|
||||
if self.info_dict.get("extractor_key") in GENERIC_EXTRACTORS and self.info.get_preset().default:
|
||||
self.logger.debug(f"Removing 'download_archive' for generic extractor. url={self.info.url}")
|
||||
if self.info_dict.get(REEXTRACT_INFO_KEY):
|
||||
self.logger.info(
|
||||
f"Info dict for '{self.info.url}' marked for re-extraction, ignoring pre-extracted info."
|
||||
)
|
||||
self.info_dict = None
|
||||
elif self.info_dict.get("extractor_key") in GENERIC_EXTRACTORS and (
|
||||
(preset := self.info.get_preset()) is not None and preset.default
|
||||
):
|
||||
self.logger.debug(
|
||||
"Removing download archive option for generic extractor on '%s'.",
|
||||
self.info.url,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"extractor": self.info_dict.get("extractor_key"),
|
||||
}
|
||||
},
|
||||
)
|
||||
params.pop("download_archive", None)
|
||||
|
||||
if self.download_info_expires > 0:
|
||||
if self.info_dict and self.download_info_expires > 0:
|
||||
_ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None))
|
||||
_ts_dt = datetime.fromtimestamp(_ts, tz=UTC) if _ts else None
|
||||
if not _ts_dt or (datetime.now(tz=UTC) - _ts_dt).total_seconds() > self.download_info_expires:
|
||||
self.info_dict = None
|
||||
self.logger.warning(f"Info for '{self.info.url}' has expired, re-extracting info.")
|
||||
self.logger.warning(
|
||||
"Pre-extracted info for '%s' expired; extracting again.",
|
||||
self.info.url,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"expires_s": self.download_info_expires,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if not self.info_dict or not isinstance(self.info_dict, dict):
|
||||
self.logger.info(f"Extracting info for '{self.info.url}'.")
|
||||
self.logger.info(
|
||||
f"Extracting info for '{self.info.url}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"preset": self.info.preset,
|
||||
"has_cookies": bool(params.get("cookiefile")),
|
||||
}
|
||||
},
|
||||
)
|
||||
ie_params: dict = params.copy()
|
||||
|
||||
(info, logs) = extract_info_sync(
|
||||
|
|
@ -183,7 +268,20 @@ class Download:
|
|||
deletedOpts.append(opt)
|
||||
|
||||
if len(deletedOpts) > 0:
|
||||
self.logger.warning(f"Live stream detected for '{self.info.title}', deleted opts: {deletedOpts}")
|
||||
self.logger.warning(
|
||||
"Removed unsupported live stream options before downloading '%s'.",
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"removed_options": deletedOpts,
|
||||
"is_live": self.is_live,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if isinstance(self.info_dict, dict) and not (
|
||||
len(self.info_dict.get("formats", [])) > 0 or self.info_dict.get("url")
|
||||
|
|
@ -192,14 +290,39 @@ class Download:
|
|||
if filtered_logs := extract_ytdlp_logs(self.logs):
|
||||
msg += " " + ", ".join(filtered_logs)
|
||||
|
||||
raise ValueError(msg) # noqa: TRY301
|
||||
self._raise_no_formats(msg)
|
||||
|
||||
self.logger.info(
|
||||
f'Download {self.info.name()}, preset="{self.info.preset}", cookies="{bool(params.get("cookiefile"))}" started.'
|
||||
f"Downloading '{self.info.title}' from '{self.info.url}' to '{self.download_dir}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"path": self.download_dir,
|
||||
"preset": self.info.preset,
|
||||
"has_cookies": bool(params.get("cookiefile")),
|
||||
"download_skipped": download_skipped,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
self.logger.debug(f"Params before passing to yt-dlp. {params}")
|
||||
self.logger.debug(
|
||||
"Prepared yt-dlp parameters for '%s'.",
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"option_keys": sorted(str(key) for key in params),
|
||||
"has_cookies": bool(params.get("cookiefile")),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
params["logger"] = NestedLogger(self.logger)
|
||||
|
||||
|
|
@ -227,14 +350,26 @@ class Download:
|
|||
def mark_cancelled(*_) -> None:
|
||||
cls._interrupted = True
|
||||
cls.to_screen("[info] Interrupt received, exiting cleanly...")
|
||||
raise SystemExit(130) # noqa: TRY301
|
||||
self._raise_interrupted()
|
||||
|
||||
signal.signal(signal.SIGUSR1, mark_cancelled)
|
||||
|
||||
self.status_queue.put({"id": self.id, "status": "downloading", "download_skipped": download_skipped})
|
||||
status_queue.put({"id": self.id, "status": "downloading", "download_skipped": download_skipped})
|
||||
|
||||
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
|
||||
self.logger.debug(f"Downloading '{self.info.url}' using pre-info.")
|
||||
self.logger.debug(
|
||||
"Downloading '%s' using pre-extracted info.",
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"preset": self.info.preset,
|
||||
}
|
||||
},
|
||||
)
|
||||
_dct: dict = self.info_dict.copy()
|
||||
if isinstance(self.info.extras, dict) and len(self.info.extras) > 0:
|
||||
_dct.update(
|
||||
|
|
@ -259,10 +394,22 @@ class Download:
|
|||
cls.process_ie_result(ie_result=_dct, download=True)
|
||||
ret: int = cls._download_retcode
|
||||
else:
|
||||
self.logger.debug(f"Downloading using url: {self.info.url}")
|
||||
self.logger.debug(
|
||||
"Downloading '%s' directly from URL.",
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"preset": self.info.preset,
|
||||
}
|
||||
},
|
||||
)
|
||||
ret = cls.download(url_list=[self.info.url])
|
||||
|
||||
self.status_queue.put(
|
||||
status_queue.put(
|
||||
{
|
||||
"id": self.id,
|
||||
"status": "finished" if 0 == ret else "error",
|
||||
|
|
@ -270,8 +417,20 @@ class Download:
|
|||
}
|
||||
)
|
||||
except yt_dlp.utils.ExistingVideoReached as exc:
|
||||
self.logger.error(exc)
|
||||
self.status_queue.put(
|
||||
self.logger.error(
|
||||
f"Skipping already downloaded '{self.info.title}' from '{self.info.url}'. {exc!s}",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"status": "skip",
|
||||
"exception_type": type(exc).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
status_queue.put(
|
||||
{
|
||||
"id": self.id,
|
||||
"status": "skip",
|
||||
|
|
@ -280,9 +439,23 @@ class Download:
|
|||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.exception(exc)
|
||||
self.logger.error(exc)
|
||||
self.status_queue.put(
|
||||
self.logger.exception(
|
||||
f"Failed to download '{self.info.title}' from '{self.info.url}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"path": self.download_dir,
|
||||
"preset": self.info.preset,
|
||||
"has_cookies": bool(params.get("cookiefile")),
|
||||
"status": "error",
|
||||
"exception_type": type(exc).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
status_queue.put(
|
||||
{
|
||||
"id": self.id,
|
||||
"status": "error",
|
||||
|
|
@ -292,16 +465,50 @@ class Download:
|
|||
}
|
||||
)
|
||||
finally:
|
||||
self.status_queue.put(Terminator())
|
||||
status_queue.put(Terminator())
|
||||
if cookie_file and cookie_file.exists():
|
||||
try:
|
||||
cookie_file.unlink()
|
||||
self.logger.debug(f"Deleted cookie file: {cookie_file}")
|
||||
self.logger.debug(
|
||||
f"Deleted cookie file for '{self.info.title}' at '{cookie_file}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"path": str(cookie_file),
|
||||
"has_cookies": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to delete cookie file: {cookie_file}. {e}")
|
||||
self.logger.error(
|
||||
f"Failed to delete cookie file for '{self.info.title}' at '{cookie_file}'. {e!s}",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"path": str(cookie_file),
|
||||
"has_cookies": True,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
|
||||
f"Download task finished for '{self.info.title}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"path": self.download_dir,
|
||||
"preset": self.info.preset,
|
||||
"has_cookies": bool(params.get("cookiefile")),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def start(self) -> int | None:
|
||||
|
|
@ -315,24 +522,24 @@ class Download:
|
|||
Process exit code or None
|
||||
|
||||
"""
|
||||
self.status_queue = Config.get_manager().Queue()
|
||||
status_queue: Any = Config.get_manager().Queue()
|
||||
self.status_queue = status_queue
|
||||
|
||||
if temp_path := self._temp_manager.create_temp_path():
|
||||
self.info.temp_path = str(temp_path)
|
||||
temp_path = self._temp_manager.create_temp_path()
|
||||
|
||||
self._status_tracker = StatusTracker(
|
||||
info=self.info,
|
||||
download_id=self.id,
|
||||
download_dir=self.download_dir,
|
||||
temp_path=temp_path,
|
||||
status_queue=self.status_queue,
|
||||
status_queue=status_queue,
|
||||
logger=self.logger,
|
||||
debug=self.debug,
|
||||
)
|
||||
|
||||
self._hook_handlers = HookHandlers(
|
||||
download_id=self.id,
|
||||
status_queue=self.status_queue,
|
||||
status_queue=status_queue,
|
||||
logger=self.logger,
|
||||
debug=self.debug,
|
||||
)
|
||||
|
|
@ -345,7 +552,8 @@ class Download:
|
|||
EventBus.get_instance().emit(Events.ITEM_UPDATED, data=self.info)
|
||||
asyncio.create_task(self._status_tracker.progress_update(), name=f"update-{self.id}")
|
||||
|
||||
ret = await asyncio.get_running_loop().run_in_executor(None, self._process_manager.proc.join)
|
||||
proc = self._process_manager.proc
|
||||
ret = await asyncio.get_running_loop().run_in_executor(None, proc.join) if proc else None
|
||||
|
||||
if self._status_tracker.final_update:
|
||||
return ret
|
||||
|
|
@ -388,8 +596,19 @@ class Download:
|
|||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to close process. {e}")
|
||||
self.logger.exception(e)
|
||||
self.logger.exception(
|
||||
f"Failed to close download process for '{self.info.title}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"url": self.info.url,
|
||||
"status": self.info.status,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -417,12 +636,28 @@ class Download:
|
|||
|
||||
"""
|
||||
if self.started_time < 1:
|
||||
self.logger.debug(f"Download task '{self.info.name()}' not started yet.")
|
||||
self.logger.debug(
|
||||
"Download task for '%s' has not started yet.",
|
||||
self.info.title,
|
||||
extra={"download": {"download_id": self.id, "item_id": self.info.id, "title": self.info.title}},
|
||||
)
|
||||
return False
|
||||
|
||||
elapsed = int(time.time()) - self.started_time
|
||||
if elapsed < 300:
|
||||
self.logger.debug(f"Download task '{self.info.title}: {self.info.id}' started for '{elapsed}' seconds.")
|
||||
self.logger.debug(
|
||||
"Download task for '%s' has been running for %s seconds.",
|
||||
self.info.title,
|
||||
elapsed,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"elapsed_s": elapsed,
|
||||
}
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
status: str = self.info.status or "unknown"
|
||||
|
|
|
|||
|
|
@ -33,9 +33,19 @@ class HookHandlers:
|
|||
if self.debug:
|
||||
try:
|
||||
d_safe = create_debug_safe_dict(data)
|
||||
self.logger.debug(f"PG Hook: {d_safe}")
|
||||
self.logger.debug(
|
||||
"Received a yt-dlp progress update for download '%s'.",
|
||||
self.id,
|
||||
extra={"download": {"download_id": self.id, "hook": "progress", "status": d_safe}},
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"PG Hook: Error creating debug info: {e}")
|
||||
self.logger.exception(
|
||||
"Failed to create progress hook debug info for download '%s'.",
|
||||
self.id,
|
||||
extra={
|
||||
"download": {"download_id": self.id, "hook": "progress", "exception_type": type(e).__name__}
|
||||
},
|
||||
)
|
||||
|
||||
self.status_queue.put(
|
||||
{
|
||||
|
|
@ -62,9 +72,23 @@ class HookHandlers:
|
|||
try:
|
||||
d_safe = create_debug_safe_dict(data)
|
||||
d_safe["postprocessor"] = data.get("postprocessor")
|
||||
self.logger.debug(f"PP Hook: {d_safe}")
|
||||
self.logger.debug(
|
||||
"Received a yt-dlp post-processing update for download '%s'.",
|
||||
self.id,
|
||||
extra={"download": {"download_id": self.id, "hook": "postprocessor", "status": d_safe}},
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"PP Hook: Error creating debug info: {e}")
|
||||
self.logger.exception(
|
||||
"Failed to create postprocessor hook debug info for download '%s'.",
|
||||
self.id,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"hook": "postprocessor",
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.status_queue.put(status)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import time
|
|||
import uuid
|
||||
from numbers import Number
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yt_dlp.utils
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ from app.features.ytdlp.extractor import fetch_info
|
|||
from app.features.ytdlp.utils import archive_add, archive_read, arg_converter, get_extras, ytdlp_reject
|
||||
from app.library.Events import Events
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import create_cookies_file, merge_dict
|
||||
|
||||
from .core import Download
|
||||
|
|
@ -26,7 +27,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from .queue_manager import DownloadQueue
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("downloads.add")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
def _get_ignored_conditions(extras: dict | None) -> list[str]:
|
||||
|
|
@ -66,7 +67,7 @@ async def add_item(
|
|||
already=None,
|
||||
logs: list | None = None,
|
||||
yt_params: dict | None = None,
|
||||
) -> dict[str, str]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Route an entry to the appropriate processor based on type.
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ async def add_item(
|
|||
|
||||
async def add(
|
||||
queue: "DownloadQueue", item: "Item", already: set | None = None, entry: dict | None = None
|
||||
) -> dict[str, str]:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Add an item to the download queue.
|
||||
|
||||
|
|
@ -122,7 +123,11 @@ async def add(
|
|||
try:
|
||||
arg_converter(args=item.cli, level=True)
|
||||
except Exception as e:
|
||||
LOG.error(f"Invalid command options for yt-dlp '{item.cli}'. {e!s}")
|
||||
LOG.error(
|
||||
"Invalid yt-dlp command options for '%s'.",
|
||||
item.url,
|
||||
extra={"url": item.url, "preset": item.preset, "exception_type": type(e).__name__},
|
||||
)
|
||||
return {"status": "error", "msg": f"Invalid command options for yt-dlp '{item.cli}'. {e!s}"}
|
||||
|
||||
if _preset:
|
||||
|
|
@ -135,12 +140,27 @@ async def add(
|
|||
yt_conf = {}
|
||||
cookie_file: Path = Path(queue.config.temp_path) / f"c_{uuid.uuid4().hex}.txt"
|
||||
|
||||
LOG.info(f"Adding '{item!r}'.")
|
||||
LOG.info(
|
||||
f"Adding '{item.url}' to downloads.",
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"folder": item.folder,
|
||||
"has_cookies": bool(item.cookies),
|
||||
"auto_start": item.auto_start,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
already = set() if already is None else already
|
||||
|
||||
if item.url in already:
|
||||
LOG.warning(f"Recursion detected with url '{item.url}' skipping.")
|
||||
LOG.warning(
|
||||
"Skipping recursive download URL '%s'.",
|
||||
item.url,
|
||||
extra={"url": item.url, "preset": item.preset},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
already.add(item.url)
|
||||
|
|
@ -149,7 +169,16 @@ async def add(
|
|||
yt_conf: dict = 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}'.")
|
||||
LOG.warning(
|
||||
"Using external downloader '%s' for '%s'.",
|
||||
yt_conf.get("external_downloader"),
|
||||
item.url,
|
||||
extra={
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"external_downloader": yt_conf.get("external_downloader"),
|
||||
},
|
||||
)
|
||||
item.extras.update({"external_downloader": True})
|
||||
|
||||
archive_id: str | None = item.get_archive_id()
|
||||
|
|
@ -175,7 +204,7 @@ async def add(
|
|||
)
|
||||
|
||||
if archive_file := dlInfo.info.get_ytdlp_opts().get_all().get("download_archive"):
|
||||
dlInfo.info.msg += f" Found in archive '{archive_file}'."
|
||||
dlInfo.info.msg = (dlInfo.info.msg or "") + f" Found in archive '{archive_file}'."
|
||||
|
||||
await queue.done.put(dlInfo)
|
||||
|
||||
|
|
@ -201,14 +230,45 @@ async def add(
|
|||
yt_conf["cookiefile"] = str(create_cookies_file(item.cookies, cookie_file))
|
||||
except Exception as e:
|
||||
msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'."
|
||||
LOG.error(msg)
|
||||
LOG.exception(
|
||||
msg,
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"path": str(cookie_file),
|
||||
"has_cookies": True,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {"status": "error", "msg": msg}
|
||||
|
||||
if entry:
|
||||
LOG.info(f"[P] Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
||||
LOG.info(
|
||||
f"Processing pre-extracted info for '{item.url}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"has_cookies": bool(yt_conf.get("cookiefile")),
|
||||
"pre_extracted": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if not entry:
|
||||
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
||||
LOG.info(
|
||||
f"Extracting info for '{item.url}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"has_cookies": bool(yt_conf.get("cookiefile")),
|
||||
"pre_extracted": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
(entry, logs) = await fetch_info(
|
||||
config=yt_conf,
|
||||
url=item.url,
|
||||
|
|
@ -221,7 +281,17 @@ async def add(
|
|||
)
|
||||
|
||||
if not entry:
|
||||
LOG.error(f"Unable to extract info for '{item.url}'. Logs: {logs}")
|
||||
LOG.error(
|
||||
f"Unable to extract info for '{item.url}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"has_cookies": bool(yt_conf.get("cookiefile")),
|
||||
"yt_dlp_logs": logs,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {"status": "error", "msg": "Unable to extract info." + "\n".join(logs)}
|
||||
|
||||
# Sometimes playlists or extractor returns different ID than what we get from the make_archive_id()
|
||||
|
|
@ -236,9 +306,12 @@ async def add(
|
|||
new_archive_id: str | None = None
|
||||
|
||||
if entry.get("extractor_key") and entry.get("id"):
|
||||
new_archive_id: str = f"{entry.get('extractor_key').lower()} {entry.get('id')}"
|
||||
if new_archive_id != archive_id:
|
||||
extra_ids.append(new_archive_id)
|
||||
_extractor_key = entry.get("extractor_key")
|
||||
_entry_id = entry.get("id")
|
||||
if isinstance(_extractor_key, str) and _entry_id is not None:
|
||||
new_archive_id = f"{_extractor_key.lower()} {_entry_id!s}"
|
||||
if new_archive_id != archive_id:
|
||||
extra_ids.append(new_archive_id)
|
||||
|
||||
if len(extra_ids) > 0:
|
||||
archive_ids: list[str] = archive_read(_archive_file, extra_ids)
|
||||
|
|
@ -266,7 +339,7 @@ async def add(
|
|||
)
|
||||
)
|
||||
|
||||
dlInfo.info.msg += f" Found in archive '{_archive_file}'."
|
||||
dlInfo.info.msg = (dlInfo.info.msg or "") + f" Found in archive '{_archive_file}'."
|
||||
await queue.done.put(dlInfo)
|
||||
|
||||
queue._notify.emit(
|
||||
|
|
@ -293,7 +366,9 @@ async def add(
|
|||
):
|
||||
already.pop()
|
||||
|
||||
message = f"Condition '{condition.name}' matched for '{item!r}'."
|
||||
display = str(entry.get("title") or entry.get("webpage_url") or entry.get("url") or item.url)
|
||||
|
||||
message = f"Condition '{condition.name}' matched for '{display}'."
|
||||
|
||||
if condition.cli:
|
||||
message += f" Re-queuing with '{condition.cli}'."
|
||||
|
|
@ -302,18 +377,18 @@ async def add(
|
|||
|
||||
if condition.extras.get("ignore_download", False):
|
||||
extra_msg: str = ""
|
||||
if _archive_file and not condition.extras.get("no_archive", False):
|
||||
if _archive_file and not condition.extras.get("no_archive", False) and archive_id:
|
||||
archive_add(_archive_file, [archive_id])
|
||||
extra_msg = f" and added to archive file '{_archive_file}'"
|
||||
|
||||
_name = entry.get("title", entry.get("id"))
|
||||
log_message = f"Ignoring download of '{_name!r}' as per condition '{condition.name}'{extra_msg}."
|
||||
_name = str(entry.get("title") or entry.get("id") or item.url)
|
||||
log_message = f"Ignoring download of '{_name}' as per condition '{condition.name}'{extra_msg}."
|
||||
|
||||
store_type, dlInfo = await queue.get_item(archive_id=archive_id)
|
||||
if not store_type:
|
||||
dlInfo = Download(
|
||||
info=ItemDTO(
|
||||
id=entry.get("id"),
|
||||
id=str(entry.get("id") or item.url),
|
||||
title=_name,
|
||||
url=item.url,
|
||||
preset=item.preset,
|
||||
|
|
@ -329,6 +404,9 @@ async def add(
|
|||
|
||||
LOG.info(log_message)
|
||||
queue._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=log_message)
|
||||
if dlInfo is None:
|
||||
msg = "Download info is not available."
|
||||
raise RuntimeError(msg)
|
||||
queue._notify.emit(
|
||||
Events.ITEM_MOVED,
|
||||
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
|
||||
|
|
@ -339,7 +417,10 @@ async def add(
|
|||
|
||||
if condition.extras.get("set_preset") and (target_preset := condition.extras.get("set_preset")):
|
||||
if Presets.get_instance().has(target_preset):
|
||||
log_message: str = f"Switching preset from '{item.preset}' to '{target_preset}' for '{item!r}' as per condition '{condition.name}'."
|
||||
log_message: str = (
|
||||
f"Switching preset from '{item.preset}' to '{target_preset}' for '{display}' "
|
||||
f"as per condition '{condition.name}'."
|
||||
)
|
||||
LOG.info(log_message)
|
||||
queue._notify.emit(Events.LOG_INFO, data={}, title="Preset Switched", message=log_message)
|
||||
item = item.new_with(preset=target_preset)
|
||||
|
|
@ -356,15 +437,61 @@ async def add(
|
|||
return {"status": "error", "msg": _msg}
|
||||
|
||||
end_time = time.perf_counter() - started
|
||||
LOG.debug(f"extract_info: for 'URL: {item.url}' is done in '{end_time:.3f}'. Length: '{len(entry)}/keys'.")
|
||||
LOG.debug(
|
||||
f"Extracted info for '{item.url}' in '{end_time:.3f}' seconds.",
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"has_cookies": bool(yt_conf.get("cookiefile")),
|
||||
"elapsed_seconds": round(end_time, 3),
|
||||
"entry_keys": len(entry),
|
||||
}
|
||||
},
|
||||
)
|
||||
except yt_dlp.utils.ExistingVideoReached as exc:
|
||||
LOG.error(f"Video has been downloaded already and recorded in archive.log file. '{exc!s}'.")
|
||||
LOG.error(
|
||||
"Video '%s' is already recorded in the download archive.",
|
||||
item.url,
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"status": "skip",
|
||||
"exception_type": type(exc).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {"status": "error", "msg": "Video has been downloaded already and recorded in archive.log file."}
|
||||
except yt_dlp.utils.YoutubeDLError as exc:
|
||||
LOG.error(f"YoutubeDLError: Unable to extract info. '{exc!s}'.")
|
||||
LOG.exception(
|
||||
"Failed to extract media info for '%s'.",
|
||||
item.url,
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"has_cookies": bool(yt_conf.get("cookiefile")),
|
||||
"exception_type": type(exc).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {"status": "error", "msg": str(exc)}
|
||||
except asyncio.exceptions.TimeoutError as exc:
|
||||
LOG.error(f"TimeoutError: Unable to extract info. '{exc!s}'.")
|
||||
LOG.exception(
|
||||
"Timed out extracting media info for '%s' after %s second(s).",
|
||||
item.url,
|
||||
queue.config.extract_info_timeout,
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"has_cookies": bool(yt_conf.get("cookiefile")),
|
||||
"timeout_seconds": queue.config.extract_info_timeout,
|
||||
"exception_type": type(exc).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"msg": f"TimeoutError: {queue.config.extract_info_timeout}s reached Unable to extract info.",
|
||||
|
|
@ -375,6 +502,19 @@ async def add(
|
|||
cookie_file.unlink(missing_ok=True)
|
||||
yt_conf.pop("cookiefile", None)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to remove cookie file '{yt_conf['cookiefile']}'. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to remove cookie file for '%s' at '%s'.",
|
||||
item.url,
|
||||
yt_conf["cookiefile"],
|
||||
extra={
|
||||
"download": {
|
||||
"url": item.url,
|
||||
"preset": item.preset,
|
||||
"path": yt_conf["cookiefile"],
|
||||
"has_cookies": True,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return await add_item(queue=queue, entry=entry, item=item, already=already, logs=logs, yt_params=yt_conf)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"""Queue monitoring functions."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -8,12 +7,13 @@ from typing import TYPE_CHECKING
|
|||
from app.library.ag_utils import ag
|
||||
from app.library.Events import Events
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import dt_delta, str_to_dt
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .queue_manager import DownloadQueue
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("downloads.monitors")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
async def check_for_stale(queue: "DownloadQueue") -> None:
|
||||
|
|
@ -31,16 +31,37 @@ async def check_for_stale(queue: "DownloadQueue") -> None:
|
|||
return
|
||||
|
||||
for _id, item in list(queue.queue.items()):
|
||||
item_ref = f"{_id=} {item.info.id=} {item.info.title=}"
|
||||
if not item.is_stale():
|
||||
continue
|
||||
|
||||
try:
|
||||
LOG.warning(f"Cancelling staled item '{item_ref}' from download queue.")
|
||||
LOG.warning(
|
||||
f"Cancelling stale download '{item.info.title}' from queue.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": _id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
"status": item.info.status,
|
||||
}
|
||||
},
|
||||
)
|
||||
await queue.cancel([_id])
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to cancel staled item '{item_ref}'. {e!s}")
|
||||
LOG.exception(e)
|
||||
LOG.exception(
|
||||
f"Failed to cancel stale download '{item.info.title}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": _id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
"status": item.info.status,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def check_live(queue: "DownloadQueue") -> None:
|
||||
|
|
@ -68,9 +89,19 @@ async def check_live(queue: "DownloadQueue") -> None:
|
|||
if item.info.status not in status:
|
||||
continue
|
||||
|
||||
item_ref: str = f"{id=} {item.info.id=} {item.info.title=}"
|
||||
if not item.is_live:
|
||||
LOG.debug(f"Item '{item_ref}' is not a live stream.")
|
||||
LOG.debug(
|
||||
"Skipping history item '%s' because it is not a live stream.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"status": item.info.status,
|
||||
}
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
duration: int | None = item.info.extras.get("duration", None)
|
||||
|
|
@ -79,7 +110,17 @@ async def check_live(queue: "DownloadQueue") -> None:
|
|||
live_in: str | None = item.info.live_in or ag(item.info.extras, ["live_in", "release_in"], None)
|
||||
if not live_in:
|
||||
LOG.debug(
|
||||
f"Item '{item_ref}' marked as {'premiere video' if is_premiere else 'live stream'}, but no date is set."
|
||||
"Skipping %s '%s' because no start time is set.",
|
||||
"premiere video" if is_premiere else "live stream",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"is_premiere": is_premiere,
|
||||
}
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
|
|
@ -87,24 +128,76 @@ async def check_live(queue: "DownloadQueue") -> None:
|
|||
starts_in = starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
|
||||
|
||||
if time_now < (starts_in + timedelta(minutes=1)):
|
||||
LOG.debug(f"Item '{item_ref}' is not yet live. will start at '{dt_delta(starts_in - time_now)}'.")
|
||||
starts_in_text = dt_delta(starts_in - time_now)
|
||||
LOG.debug(
|
||||
"Live item '%s' is not ready yet; starts in %s.",
|
||||
item.info.title,
|
||||
starts_in_text,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"live_in": live_in,
|
||||
"starts_in": starts_in_text,
|
||||
}
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
if queue.config.prevent_live_premiere and is_premiere and duration:
|
||||
buffer_time = queue.config.live_premiere_buffer if queue.config.live_premiere_buffer >= 0 else 5
|
||||
premiere_ends: datetime = starts_in + timedelta(minutes=buffer_time, seconds=duration)
|
||||
if time_now < premiere_ends:
|
||||
start_after = premiere_ends.astimezone().isoformat()
|
||||
LOG.debug(
|
||||
f"Item '{item_ref}' is premiering, download will start in '{(starts_in + timedelta(minutes=buffer_time, seconds=duration)).astimezone().isoformat()}'"
|
||||
"Premiere '%s' is still running; download will start after '%s'.",
|
||||
item.info.title,
|
||||
start_after,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"is_premiere": is_premiere,
|
||||
"start_after": start_after,
|
||||
}
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
LOG.info(f"Retrying item '{item_ref} {item.info.extras=}' for download.")
|
||||
LOG.info(
|
||||
f"Retrying live download '{item.info.title}' from '{item.info.url}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
"preset": item.info.preset,
|
||||
"live_in": live_in,
|
||||
"is_premiere": is_premiere,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await queue.clear([item.info._id], remove_file=False)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to clear item '{item_ref}'. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to clear live download '%s' before retry.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
"preset": item.info.preset,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
|
|
@ -121,8 +214,21 @@ async def check_live(queue: "DownloadQueue") -> None:
|
|||
)
|
||||
except Exception as e:
|
||||
await queue.done.put(item)
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to retry item '{item_ref}'. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to retry live download '%s' from '%s'.",
|
||||
item.info.title,
|
||||
item.info.url,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
"preset": item.info.preset,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def delete_old_history(queue: "DownloadQueue") -> None:
|
||||
|
|
@ -156,7 +262,19 @@ async def delete_old_history(queue: "DownloadQueue") -> None:
|
|||
if item_datetime < cutoff_date:
|
||||
items_to_delete.append((key, info))
|
||||
except (OSError, ValueError, OverflowError) as e:
|
||||
LOG.error(f"Failed to parse timestamp '{info.timestamp}' for item '{info.title}': {e}")
|
||||
LOG.exception(
|
||||
"Failed to parse timestamp for history item '%s'.",
|
||||
info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": key,
|
||||
"item_id": info.id,
|
||||
"title": info.title,
|
||||
"timestamp": info.timestamp,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
titles: list[str] = []
|
||||
for key, info in items_to_delete:
|
||||
|
|
@ -171,7 +289,12 @@ async def delete_old_history(queue: "DownloadQueue") -> None:
|
|||
await queue.done.delete(key)
|
||||
|
||||
if titles:
|
||||
LOG.info(f"Automatically cleared '{', '.join(titles)}' from download history due to age.")
|
||||
LOG.info(
|
||||
"Automatically cleared %s old history item(s), including '%s'.",
|
||||
len(titles),
|
||||
titles[0],
|
||||
extra={"deleted_count": len(titles), "titles": titles},
|
||||
)
|
||||
|
||||
|
||||
async def cleanup_thumbnails(queue: "DownloadQueue") -> None:
|
||||
|
|
@ -201,7 +324,11 @@ async def cleanup_thumbnails(queue: "DownloadQueue") -> None:
|
|||
thumb.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
except OSError as exc:
|
||||
LOG.warning(f"Failed to remove orphaned thumbnail '{thumb}'. {exc!s}")
|
||||
LOG.exception(
|
||||
"Failed to remove orphaned thumbnail '%s'.",
|
||||
thumb,
|
||||
extra={"file_path": str(thumb), "exception_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
if removed > 0:
|
||||
LOG.info(f"Removed '{removed}' orphaned cached thumbnails.")
|
||||
LOG.info("Removed %s orphaned cached thumbnail(s).", removed, extra={"removed_count": removed})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"""Playlist processing."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.ytdlp.utils import ytdlp_reject
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import merge_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -11,7 +11,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from .queue_manager import DownloadQueue
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("downloads.playlist")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
async def process_playlist(
|
||||
|
|
@ -38,7 +38,17 @@ async def process_playlist(
|
|||
|
||||
playlist_name: str = f"{entry.get('id')}: {entry.get('title')}"
|
||||
|
||||
LOG.info(f"Processing '{playlist_name} ({len(entries)})' Playlist.")
|
||||
LOG.info(
|
||||
"Processing playlist '%s' with %s entrie(s).",
|
||||
playlist_name,
|
||||
len(entries),
|
||||
extra={
|
||||
"playlist_id": entry.get("id"),
|
||||
"playlist_title": entry.get("title"),
|
||||
"entry_count": len(entries),
|
||||
"preset": getattr(item, "preset", None),
|
||||
},
|
||||
)
|
||||
|
||||
playlistCount = entry.get("playlist_count")
|
||||
playlistCount: int = int(playlistCount) if playlistCount else len(entries)
|
||||
|
|
@ -62,7 +72,18 @@ async def process_playlist(
|
|||
item_name: str = (
|
||||
f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'"
|
||||
)
|
||||
LOG.info(f"Processing '{item_name}'.")
|
||||
LOG.info(
|
||||
"Processing playlist item '%s'.",
|
||||
item_name,
|
||||
extra={
|
||||
"playlist_id": entry.get("id"),
|
||||
"playlist_title": entry.get("title"),
|
||||
"playlist_index": i,
|
||||
"entry_count": playlist_keys["n_entries"],
|
||||
"item_id": etr.get("id"),
|
||||
"title": etr.get("title"),
|
||||
},
|
||||
)
|
||||
|
||||
_status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params)
|
||||
if not _status:
|
||||
|
|
@ -100,8 +121,8 @@ async def process_playlist(
|
|||
|
||||
max_downloads: int = -1
|
||||
ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all()
|
||||
if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int):
|
||||
max_downloads: int = ytdlp_opts.get("max_downloads")
|
||||
if isinstance(ytdlp_opts.get("max_downloads"), int):
|
||||
max_downloads = ytdlp_opts["max_downloads"]
|
||||
|
||||
results: list[dict[str, str]] = []
|
||||
for i, etr in enumerate(entries, start=1):
|
||||
|
|
@ -110,12 +131,23 @@ async def process_playlist(
|
|||
|
||||
results.append(await process_item(i, etr))
|
||||
|
||||
log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries."
|
||||
skipped = 0
|
||||
if max_downloads > 0 and len(entries) > max_downloads:
|
||||
skipped: int = len(entries) - max_downloads
|
||||
log_msg += f" Limited to '{max_downloads}' items, skipped '{skipped}' remaining items."
|
||||
skipped = len(entries) - max_downloads
|
||||
|
||||
LOG.info(log_msg)
|
||||
LOG.info(
|
||||
"Playlist '%s' processing completed with %s item(s).",
|
||||
playlist_name,
|
||||
len(results),
|
||||
extra={
|
||||
"playlist_id": entry.get("id"),
|
||||
"playlist_title": entry.get("title"),
|
||||
"processed_count": len(results),
|
||||
"entry_count": len(entries),
|
||||
"max_downloads": max_downloads,
|
||||
"skipped_count": skipped,
|
||||
},
|
||||
)
|
||||
|
||||
if any("error" == res["status"] for res in results):
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"""Download pool management - worker coordination and execution."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.log import get_logger
|
||||
from app.library.Utils import calc_download_path
|
||||
|
||||
from .core import Download
|
||||
|
|
@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from .queue_manager import DownloadQueue
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("downloads.pool")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class PoolManager:
|
||||
|
|
@ -78,7 +78,11 @@ class PoolManager:
|
|||
|
||||
async def shutdown(self) -> None:
|
||||
if self._active:
|
||||
LOG.info(f"Cancelling '{len(self._active)}' active downloads.")
|
||||
LOG.info(
|
||||
"Cancelling active downloads (%s item(s)).",
|
||||
len(self._active),
|
||||
extra={"active_count": len(self._active), "download_ids": list(self._active)},
|
||||
)
|
||||
await self.queue.cancel(list(self._active.keys()))
|
||||
|
||||
async def _download_pool(self) -> None:
|
||||
|
|
@ -87,7 +91,7 @@ class PoolManager:
|
|||
|
||||
while True:
|
||||
while not self.queue.queue.has_downloads():
|
||||
LOG.info("Waiting for item to download.")
|
||||
LOG.info("Waiting for queued downloads.")
|
||||
await self.event.wait()
|
||||
self.event.clear()
|
||||
adaptive_sleep = 0.2
|
||||
|
|
@ -140,7 +144,11 @@ class PoolManager:
|
|||
# No items could be processed, back off a bit to avoid busy-waiting.
|
||||
if 0 == items_processed:
|
||||
adaptive_sleep: float = min(adaptive_sleep * 1.5, max_sleep)
|
||||
LOG.debug(f"No download slots available. Backing off for {adaptive_sleep:.2f}s before next attempt.")
|
||||
LOG.debug(
|
||||
"No download slots available; backing off for %.2f seconds.",
|
||||
adaptive_sleep,
|
||||
extra={"backoff_s": round(adaptive_sleep, 2), "active_count": len(self._active)},
|
||||
)
|
||||
else:
|
||||
adaptive_sleep = 0.2
|
||||
|
||||
|
|
@ -156,7 +164,22 @@ class PoolManager:
|
|||
|
||||
"""
|
||||
filePath: str = calc_download_path(base_path=self.config.download_path, folder=entry.info.folder)
|
||||
LOG.info(f"Downloading 'id: {entry.id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' To '{filePath}'.")
|
||||
LOG.info(
|
||||
"Started download for '%s' to '%s'.",
|
||||
entry.info.title,
|
||||
filePath,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": entry.id,
|
||||
"item_id": entry.info.id,
|
||||
"title": entry.info.title,
|
||||
"url": entry.info.url,
|
||||
"download_dir": filePath,
|
||||
"preset": entry.info.preset,
|
||||
"is_live": entry.is_live,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
self._active[entry.info._id] = entry
|
||||
|
|
@ -176,7 +199,18 @@ class PoolManager:
|
|||
await entry.close()
|
||||
|
||||
if await self.queue.queue.exists(key=id):
|
||||
LOG.debug(f"Download Task '{id}' is completed. Removing from queue.")
|
||||
LOG.debug(
|
||||
"Removing completed download '%s' from queue.",
|
||||
entry.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": entry.info.id,
|
||||
"title": entry.info.title,
|
||||
"status": entry.info.status,
|
||||
}
|
||||
},
|
||||
)
|
||||
await self.queue.queue.delete(key=id)
|
||||
|
||||
nTitle: str | None = None
|
||||
|
|
@ -206,12 +240,16 @@ class PoolManager:
|
|||
await self.queue.done.put(entry)
|
||||
self._notify.emit(
|
||||
Events.ITEM_MOVED,
|
||||
data={"to": "history", "preset": entry.info.preset, "item": entry.info},
|
||||
data={"from": "queue", "to": "history", "preset": entry.info.preset, "item": entry.info},
|
||||
title=nTitle,
|
||||
message=nMessage,
|
||||
)
|
||||
else:
|
||||
LOG.warning(f"Download '{id}' not found in queue.")
|
||||
LOG.warning(
|
||||
"Completed download '%s' was not found in the queue.",
|
||||
entry.info.title or id,
|
||||
extra={"download_id": id, "title": entry.info.title},
|
||||
)
|
||||
|
||||
if self.event:
|
||||
self.event.set()
|
||||
|
|
|
|||
|
|
@ -99,54 +99,163 @@ class ProcessManager:
|
|||
if not self.running():
|
||||
return False
|
||||
|
||||
procId: int | None = self.proc.ident
|
||||
proc = self.proc
|
||||
if proc is None:
|
||||
return False
|
||||
|
||||
procId: int | None = proc.ident
|
||||
try:
|
||||
self.logger.info(f"Killing download process: PID={self.proc.pid}, ident={procId}.")
|
||||
self.logger.info(
|
||||
"Stopping download process PID=%s.",
|
||||
proc.pid,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"process_ident": procId,
|
||||
"is_live": self.is_live,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if self.is_live:
|
||||
self.logger.debug(f"Requesting graceful live cancellation for PID={self.proc.pid}.")
|
||||
self.logger.debug(
|
||||
"Requesting graceful live cancellation for PID=%s.",
|
||||
proc.pid,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"is_live": self.is_live,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.cancel_event.set()
|
||||
|
||||
if wait_for_process_with_timeout(self.proc, 10):
|
||||
self.logger.debug(f"Process PID={self.proc.pid} terminated gracefully.")
|
||||
if wait_for_process_with_timeout(proc, 10):
|
||||
self.logger.debug(
|
||||
"Download process PID=%s stopped gracefully.",
|
||||
proc.pid,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"is_live": self.is_live,
|
||||
}
|
||||
},
|
||||
)
|
||||
return True
|
||||
self.logger.warning(
|
||||
f"Process PID={self.proc.pid} did not respond to live cancellation, forcing termination."
|
||||
"Download process PID=%s did not respond to live cancellation; forcing termination.",
|
||||
proc.pid,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"is_live": self.is_live,
|
||||
"force": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
elif self.proc.pid and "posix" == os.name:
|
||||
elif proc.pid and "posix" == os.name:
|
||||
signal_name = "SIGUSR1"
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Sending {signal_name} signal to PID={self.proc.pid}.")
|
||||
os.kill(self.proc.pid, signal.SIGUSR1)
|
||||
self.logger.debug(
|
||||
"Sending %s signal to download process PID=%s.",
|
||||
signal_name,
|
||||
proc.pid,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"signal": signal_name,
|
||||
}
|
||||
},
|
||||
)
|
||||
os.kill(proc.pid, signal.SIGUSR1)
|
||||
|
||||
if wait_for_process_with_timeout(self.proc, 5):
|
||||
self.logger.debug(f"Process PID={self.proc.pid} terminated gracefully.")
|
||||
if wait_for_process_with_timeout(proc, 5):
|
||||
self.logger.debug(
|
||||
"Download process PID=%s stopped gracefully.",
|
||||
proc.pid,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"signal": signal_name,
|
||||
}
|
||||
},
|
||||
)
|
||||
return True
|
||||
self.logger.warning(
|
||||
f"Process PID={self.proc.pid} did not respond to {signal_name} (regular download), "
|
||||
f"forcing termination."
|
||||
"Download process PID=%s did not respond to %s; forcing termination.",
|
||||
proc.pid,
|
||||
signal_name,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"signal": signal_name,
|
||||
"force": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
except (OSError, AttributeError) as e:
|
||||
self.logger.debug(f"Failed to send {signal_name} signal: {e}")
|
||||
self.logger.debug(
|
||||
"Failed to send %s signal to download process PID=%s. %s",
|
||||
signal_name,
|
||||
proc.pid,
|
||||
e,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid,
|
||||
"signal": signal_name,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if self.proc.is_alive():
|
||||
self.logger.info(f"Force-terminating process PID={self.proc.pid}.")
|
||||
self.proc.terminate()
|
||||
if proc.is_alive():
|
||||
self.logger.info(
|
||||
"Terminating download process PID=%s.",
|
||||
proc.pid,
|
||||
extra={"download": {"download_id": self.download_id, "process_id": proc.pid, "force": True}},
|
||||
)
|
||||
proc.terminate()
|
||||
|
||||
if not wait_for_process_with_timeout(self.proc, 1 if self.is_live else 2):
|
||||
self.logger.warning(f"Process PID={self.proc.pid} did not respond, killing forcefully.")
|
||||
self.proc.kill()
|
||||
wait_for_process_with_timeout(self.proc, 1)
|
||||
if not wait_for_process_with_timeout(proc, 1 if self.is_live else 2):
|
||||
self.logger.warning(
|
||||
"Download process PID=%s did not terminate; killing forcefully.",
|
||||
proc.pid,
|
||||
extra={"download": {"download_id": self.download_id, "process_id": proc.pid, "force": True}},
|
||||
)
|
||||
proc.kill()
|
||||
wait_for_process_with_timeout(proc, 1)
|
||||
|
||||
self.logger.info(f"Process PID={self.proc.pid} killed.")
|
||||
self.logger.info(
|
||||
"Download process PID=%s stopped.",
|
||||
proc.pid,
|
||||
extra={"download": {"download_id": self.download_id, "process_id": proc.pid}},
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to kill process PID={self.proc.pid}, ident={procId}. {e}")
|
||||
self.logger.exception(e)
|
||||
self.logger.exception(
|
||||
f"Failed to stop download process PID={proc.pid if proc else None}, ident={procId}.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": proc.pid if proc else None,
|
||||
"process_ident": procId,
|
||||
"is_live": self.is_live,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -164,16 +273,21 @@ class ProcessManager:
|
|||
return False
|
||||
|
||||
self.cancel_in_progress = True
|
||||
procId: int | None = self.proc.ident
|
||||
proc = self.proc
|
||||
procId: int | None = proc.ident if proc else None
|
||||
|
||||
if not procId:
|
||||
if self.proc:
|
||||
self.proc.close()
|
||||
if proc:
|
||||
proc.close()
|
||||
self.proc = None
|
||||
self.logger.warning("Attempted to close download process, but it is not running.")
|
||||
return False
|
||||
|
||||
self.logger.info(f"Closing PID='{procId}' download process.")
|
||||
self.logger.info(
|
||||
"Closing download process PID='%s'.",
|
||||
procId,
|
||||
extra={"download": {"download_id": self.download_id, "process_ident": procId, "is_live": self.is_live}},
|
||||
)
|
||||
|
||||
try:
|
||||
self.kill()
|
||||
|
|
@ -182,20 +296,43 @@ class ProcessManager:
|
|||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
if self.proc.is_alive():
|
||||
self.logger.debug(f"Waiting for PID='{procId}' to close.")
|
||||
await loop.run_in_executor(None, self.proc.join)
|
||||
self.logger.debug(f"PID='{procId}' closed.")
|
||||
current = self.proc
|
||||
if current is not None and current.is_alive():
|
||||
self.logger.debug(
|
||||
"Waiting for download process PID='%s' to close.",
|
||||
procId,
|
||||
extra={"download": {"download_id": self.download_id, "process_ident": procId}},
|
||||
)
|
||||
await loop.run_in_executor(None, current.join)
|
||||
self.logger.debug(
|
||||
"Download process PID='%s' closed.",
|
||||
procId,
|
||||
extra={"download": {"download_id": self.download_id, "process_ident": procId}},
|
||||
)
|
||||
|
||||
if self.proc:
|
||||
self.proc.close()
|
||||
self.proc = None
|
||||
|
||||
self.logger.debug(f"Closed PID='{procId}' download process.")
|
||||
self.logger.debug(
|
||||
"Closed download process PID='%s'.",
|
||||
procId,
|
||||
extra={"download": {"download_id": self.download_id, "process_ident": procId}},
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to close process: '{procId}'. {e}")
|
||||
self.logger.exception(e)
|
||||
self.logger.exception(
|
||||
f"Failed to close download process PID='{procId}'.",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.download_id,
|
||||
"process_id": self.proc.pid if self.proc else None,
|
||||
"process_ident": procId,
|
||||
"is_live": self.is_live,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import functools
|
||||
import glob
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
|
@ -10,6 +9,7 @@ from aiohttp import web
|
|||
from app.library.config import Config
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.log import get_logger
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
|
@ -24,7 +24,7 @@ from .pool_manager import PoolManager
|
|||
if TYPE_CHECKING:
|
||||
from app.library.DataStore import StoreType
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("downloads.queue")
|
||||
LOG = get_logger()
|
||||
|
||||
|
||||
class DownloadQueue(metaclass=Singleton):
|
||||
|
|
@ -53,7 +53,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
async def event_handler(_, __):
|
||||
await self.initialize()
|
||||
|
||||
self._notify.subscribe(Events.STARTED, event_handler, f"{__class__.__name__}.{__class__.initialize.__name__}")
|
||||
self._notify.subscribe(Events.STARTED, event_handler, f"{DownloadQueue.__name__}.initialize")
|
||||
|
||||
Scheduler.get_instance().add(
|
||||
timer="* * * * *",
|
||||
|
|
@ -113,7 +113,9 @@ class DownloadQueue(metaclass=Singleton):
|
|||
except KeyError as e:
|
||||
status[item_id] = f"not found: {e!s}"
|
||||
status["status"] = "error"
|
||||
LOG.warning(f"Start requested for non-existent item {item_id=}.")
|
||||
LOG.warning(
|
||||
"Start requested for missing queued download '%s'.", item_id, extra={"download_id": item_id}
|
||||
)
|
||||
continue
|
||||
|
||||
if item.info.auto_start:
|
||||
|
|
@ -156,7 +158,9 @@ class DownloadQueue(metaclass=Singleton):
|
|||
except KeyError as e:
|
||||
status[item_id] = f"not found: {e!s}"
|
||||
status["status"] = "error"
|
||||
LOG.warning(f"Start requested for non-existent item {item_id=}.")
|
||||
LOG.warning(
|
||||
"Pause requested for missing queued download '%s'.", item_id, extra={"download_id": item_id}
|
||||
)
|
||||
continue
|
||||
|
||||
if item.started() or item.is_cancelled():
|
||||
|
|
@ -191,7 +195,8 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if not self.pool.is_paused():
|
||||
self.pool.pause()
|
||||
if not shutdown:
|
||||
LOG.warning(f"Download paused at. {datetime.now(tz=UTC).isoformat()}")
|
||||
paused_at = datetime.now(tz=UTC).isoformat()
|
||||
LOG.warning("Paused the download queue.", extra={"paused_at": paused_at})
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -206,7 +211,8 @@ class DownloadQueue(metaclass=Singleton):
|
|||
"""
|
||||
if self.pool.is_paused():
|
||||
self.pool.resume()
|
||||
LOG.warning(f"Downloading resumed at. {datetime.now(tz=UTC).isoformat()}")
|
||||
resumed_at = datetime.now(tz=UTC).isoformat()
|
||||
LOG.warning("Resumed the download queue.", extra={"resumed_at": resumed_at})
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -258,20 +264,51 @@ class DownloadQueue(metaclass=Singleton):
|
|||
except KeyError as e:
|
||||
status[id] = str(e)
|
||||
status["status"] = "error"
|
||||
LOG.warning(f"Requested cancel for non-existent download {id=}. {e!s}")
|
||||
LOG.warning("Cancel requested for missing queued download '%s'.", id, extra={"download_id": id})
|
||||
continue
|
||||
|
||||
item_ref = f"{id=} {item.info.id=} {item.info.title=}"
|
||||
|
||||
if item.running():
|
||||
LOG.debug(f"Canceling {item_ref}")
|
||||
LOG.debug(
|
||||
"Cancelling running download '%s'.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
}
|
||||
},
|
||||
)
|
||||
item.cancel()
|
||||
LOG.info(f"Cancelled {item_ref}")
|
||||
LOG.info(
|
||||
"Cancelled running download '%s'.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
}
|
||||
},
|
||||
)
|
||||
if not item.is_live:
|
||||
await item.close()
|
||||
else:
|
||||
await item.close()
|
||||
LOG.debug(f"Deleting from queue {item_ref}")
|
||||
LOG.debug(
|
||||
"Removing cancelled download '%s' from queue.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
}
|
||||
},
|
||||
)
|
||||
await self.queue.delete(id)
|
||||
self._notify.emit(
|
||||
Events.ITEM_CANCELLED,
|
||||
|
|
@ -283,11 +320,23 @@ class DownloadQueue(metaclass=Singleton):
|
|||
await self.done.put(item)
|
||||
self._notify.emit(
|
||||
Events.ITEM_MOVED,
|
||||
data={"to": "history", "preset": item.info.preset, "item": item.info},
|
||||
data={"from": "queue", "to": "history", "preset": item.info.preset, "item": item.info},
|
||||
title="Download Cancelled",
|
||||
message=f"Download '{item.info.title}' has been cancelled.",
|
||||
)
|
||||
LOG.info(f"Deleted from queue {item_ref}")
|
||||
LOG.info(
|
||||
"Moved cancelled download '%s' from queue to history.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
"status": item.info.status,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
status[id] = "ok"
|
||||
|
||||
|
|
@ -314,17 +363,28 @@ class DownloadQueue(metaclass=Singleton):
|
|||
except KeyError as e:
|
||||
status[id] = str(e)
|
||||
status["status"] = "error"
|
||||
LOG.warning(f"Requested delete for non-existent download {id=}. {e!s}")
|
||||
LOG.warning("Delete requested for missing history download '%s'.", id, extra={"download_id": id})
|
||||
continue
|
||||
|
||||
itemRef: str = f"{id=} {item.info.id=} {item.info.title=}"
|
||||
removed_files = 0
|
||||
filename: str = ""
|
||||
|
||||
if self.config.remove_files is not True:
|
||||
remove_file = False
|
||||
|
||||
LOG.debug(f"{remove_file=} {itemRef} - Removing local files: {item.info.status=}")
|
||||
LOG.debug(
|
||||
"Clearing history download '%s'.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"status": item.info.status,
|
||||
"remove_file": remove_file,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if remove_file and "finished" == item.info.status and item.info.filename:
|
||||
filename = str(item.info.filename)
|
||||
|
|
@ -344,16 +404,66 @@ class DownloadQueue(metaclass=Singleton):
|
|||
for f in rf.parent.glob(f"{glob.escape(rf.stem)}.*"):
|
||||
if f.is_file() and f.exists() and not f.name.startswith("."):
|
||||
removed_files += 1
|
||||
LOG.debug(f"Removing '{itemRef}' local file '{f.name}'.")
|
||||
LOG.debug(
|
||||
"Removing local file '%s' for '%s'.",
|
||||
f.name,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"filename": f.name,
|
||||
}
|
||||
},
|
||||
)
|
||||
f.unlink(missing_ok=True)
|
||||
else:
|
||||
LOG.debug(f"Removing '{itemRef}' local file '{rf.name}'.")
|
||||
LOG.debug(
|
||||
"Removing local file '%s' for '%s'.",
|
||||
rf.name,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"filename": rf.name,
|
||||
}
|
||||
},
|
||||
)
|
||||
rf.unlink(missing_ok=True)
|
||||
removed_files += 1
|
||||
else:
|
||||
LOG.warning(f"Failed to remove '{itemRef}' local file '{filename}'. File not found.")
|
||||
LOG.warning(
|
||||
"Could not remove local file '%s' for '%s' because it was not found.",
|
||||
filename,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"filename": filename,
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to remove local file '%s' for '%s'.",
|
||||
filename,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"filename": filename,
|
||||
"remove_file": remove_file,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await self.done.delete(id)
|
||||
deleted_ids.append(id)
|
||||
|
|
@ -366,11 +476,19 @@ class DownloadQueue(metaclass=Singleton):
|
|||
message=f"{_status} '{item.info.title}' from history.",
|
||||
)
|
||||
|
||||
msg = f"Deleted completed download '{itemRef}'."
|
||||
if removed_files > 0:
|
||||
msg += f" and removed '{removed_files}' local files."
|
||||
|
||||
LOG.info(msg=msg)
|
||||
LOG.info(
|
||||
"Deleted completed download '%s' from history%s.",
|
||||
item.info.title,
|
||||
f" and removed {removed_files} local file(s)" if removed_files > 0 else "",
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"removed_files": removed_files,
|
||||
}
|
||||
},
|
||||
)
|
||||
status[id] = "ok"
|
||||
|
||||
if deleted_ids:
|
||||
|
|
@ -382,22 +500,34 @@ class DownloadQueue(metaclass=Singleton):
|
|||
if not ids:
|
||||
return {"deleted": 0}
|
||||
|
||||
items = await self.done.get_many_by_ids(ids)
|
||||
items: list[tuple[str, Download]] = await self.done.get_many_by_ids(ids)
|
||||
if not items:
|
||||
return {"deleted": 0}
|
||||
|
||||
if self.config.remove_files is not True:
|
||||
remove_file = False
|
||||
remove_file = False if self.config.remove_files is not True else remove_file
|
||||
|
||||
removed_files = 0
|
||||
deleted_ids: list[str] = []
|
||||
deleted_titles: list[str] = []
|
||||
item_summaries: list[dict] = []
|
||||
|
||||
for item_id, item in items:
|
||||
item_ref: str = f"{item_id=} {item.info.id=} {item.info.title=}"
|
||||
filename: str = ""
|
||||
|
||||
LOG.debug(f"{remove_file=} {item_ref} - Removing local files: {item.info.status=}")
|
||||
LOG.debug(
|
||||
"Clearing history download '%s'.",
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": item_id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"status": item.info.status,
|
||||
"remove_file": remove_file,
|
||||
"preset": item.info.preset,
|
||||
"path": str(p) if (p := item.info.get_file()) else None,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if remove_file and "finished" == item.info.status and item.info.filename:
|
||||
filename = str(item.info.filename)
|
||||
|
|
@ -417,65 +547,149 @@ class DownloadQueue(metaclass=Singleton):
|
|||
for file_ref in rf.parent.glob(f"{glob.escape(rf.stem)}.*"):
|
||||
if file_ref.is_file() and file_ref.exists() and not file_ref.name.startswith("."):
|
||||
removed_files += 1
|
||||
LOG.debug(f"Removing '{item_ref}' local file '{file_ref.name}'.")
|
||||
LOG.debug(
|
||||
"Removing local file '%s' for '%s'.",
|
||||
file_ref.name,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": item_id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"preset": item.info.preset,
|
||||
"filename": file_ref.name,
|
||||
"path": str(file_ref),
|
||||
}
|
||||
},
|
||||
)
|
||||
file_ref.unlink(missing_ok=True)
|
||||
else:
|
||||
LOG.debug(f"Removing '{item_ref}' local file '{rf.name}'.")
|
||||
LOG.debug(
|
||||
"Removing local file '%s' for '%s'.",
|
||||
rf.name,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": item_id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"filename": rf.name,
|
||||
"preset": item.info.preset,
|
||||
"path": str(rf),
|
||||
}
|
||||
},
|
||||
)
|
||||
rf.unlink(missing_ok=True)
|
||||
removed_files += 1
|
||||
else:
|
||||
LOG.warning(f"Failed to remove '{item_ref}' local file '{filename}'. File not found.")
|
||||
LOG.warning(
|
||||
"Could not remove local file '%s' for '%s' because it was not found.",
|
||||
filename,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": item_id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"filename": filename,
|
||||
"preset": item.info.preset,
|
||||
"path": str(rf),
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Unable to remove '{item_ref}' local file '{filename}'. {e!s}")
|
||||
LOG.exception(
|
||||
"Failed to remove local file '%s' for '%s'.",
|
||||
filename,
|
||||
item.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": item_id,
|
||||
"item_id": item.info.id,
|
||||
"title": item.info.title,
|
||||
"filename": filename,
|
||||
"preset": item.info.preset,
|
||||
"remove_file": remove_file,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
deleted_ids.append(item_id)
|
||||
deleted_titles.append(item.info.title or item.info.id or item_id)
|
||||
item_summaries.append(
|
||||
{
|
||||
"id": item_id,
|
||||
"title": item.info.title,
|
||||
"url": item.info.url,
|
||||
"preset": item.info.preset,
|
||||
"path": str(p) if (p := item.info.get_file()) else None,
|
||||
}
|
||||
)
|
||||
|
||||
deleted_count = await self.done.bulk_delete(deleted_ids)
|
||||
deleted_count: int = await self.done.bulk_delete(deleted_ids)
|
||||
if deleted_count < 1:
|
||||
return {"deleted": 0}
|
||||
|
||||
title = "History Removed" if removed_files > 0 else "History Cleared"
|
||||
message = f"Removed {deleted_count} item{'s' if deleted_count != 1 else ''} from history."
|
||||
message: list[str] = [f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history."]
|
||||
if removed_files > 0:
|
||||
message += f" Also removed {removed_files} local file{'s' if removed_files != 1 else ''}."
|
||||
message.append(f"Also removed {removed_files} local file{'s' if removed_files != 1 else ''}.")
|
||||
|
||||
self._notify.emit(
|
||||
Events.ITEM_BULK_DELETED,
|
||||
data={"ids": deleted_ids, "count": deleted_count},
|
||||
title=title,
|
||||
message=message,
|
||||
data={"count": deleted_count, "removed_files": removed_files, "items": item_summaries},
|
||||
title="History Cleared",
|
||||
message=" ".join(message),
|
||||
)
|
||||
|
||||
summary = ", ".join(deleted_titles[:5])
|
||||
if deleted_count > 5:
|
||||
summary += ", ..."
|
||||
LOG.info(f"Bulk cleared {deleted_count} history item(s): {summary}")
|
||||
LOG.info(
|
||||
"Cleared %d history item(s).",
|
||||
deleted_count,
|
||||
extra={"deleted_count": deleted_count, "removed_files": removed_files, "items": item_summaries},
|
||||
)
|
||||
|
||||
return {"deleted": deleted_count}
|
||||
|
||||
async def clear_by_status(self, status_filter: str, remove_file: bool = False) -> dict[str, int | str]:
|
||||
if self.config.remove_files is not True:
|
||||
remove_file = False
|
||||
if not (items := await self.done.get_many_by_status(status_filter)):
|
||||
return {"deleted": 0}
|
||||
|
||||
if not remove_file:
|
||||
deleted_count = await self.done.bulk_delete_by_status(status_filter)
|
||||
if deleted_count < 1:
|
||||
return {"deleted": 0}
|
||||
remove_file = False if self.config.remove_files is not True else remove_file
|
||||
|
||||
self._notify.emit(
|
||||
Events.ITEM_BULK_DELETED,
|
||||
data={"count": deleted_count, "status": status_filter},
|
||||
title="History Cleared",
|
||||
message=f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history.",
|
||||
)
|
||||
LOG.info(f"Bulk cleared {deleted_count} history item(s) by status '{status_filter}'.")
|
||||
return {"deleted": deleted_count}
|
||||
if remove_file:
|
||||
return await self.clear_bulk([item_id for item_id, _ in items], remove_file=remove_file)
|
||||
|
||||
items = await self.done.get_many_by_status(status_filter)
|
||||
return await self.clear_bulk([item_id for item_id, _ in items], remove_file=remove_file)
|
||||
deleted_count: int = await self.done.bulk_delete_by_status(status_filter)
|
||||
item_summaries: list[dict[str, str | None]] = [
|
||||
{
|
||||
"id": item_id,
|
||||
"title": dto.info.title,
|
||||
"url": dto.info.url,
|
||||
"preset": dto.info.preset,
|
||||
"path": str(p) if (p := dto.info.get_file()) else None,
|
||||
}
|
||||
for item_id, dto in items
|
||||
]
|
||||
|
||||
async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]:
|
||||
self._notify.emit(
|
||||
Events.ITEM_BULK_DELETED,
|
||||
data={"count": deleted_count, "status": status_filter, "items": item_summaries},
|
||||
title="History Cleared",
|
||||
message=f"Cleared {deleted_count} item(s) from history.",
|
||||
)
|
||||
|
||||
LOG.info(
|
||||
"Cleared %d history item(s) with status '%s'.",
|
||||
deleted_count,
|
||||
status_filter,
|
||||
extra={
|
||||
"deleted_count": deleted_count,
|
||||
"status_filter": status_filter,
|
||||
"items": item_summaries,
|
||||
},
|
||||
)
|
||||
return {"deleted": deleted_count}
|
||||
|
||||
async def get(self, mode: str = "all") -> dict[str, dict[str, ItemDTO]]:
|
||||
"""
|
||||
Get the download queue and the download history.
|
||||
|
||||
|
|
@ -511,6 +725,32 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
return items
|
||||
|
||||
def live_queue(self, limit: int = 0) -> dict[str, int | dict[str, ItemDTO]]:
|
||||
"""Return a display-limited live queue snapshot with total metadata."""
|
||||
limit = max(0, int(limit or 0))
|
||||
items: dict[str, ItemDTO] = {}
|
||||
active = self.pool.get_active_downloads()
|
||||
|
||||
for key, download in active.items():
|
||||
if key in self.queue:
|
||||
items[key] = download.info
|
||||
|
||||
for key, download in self.queue.items():
|
||||
if limit > 0 and len(items) >= limit:
|
||||
break
|
||||
|
||||
if key in items:
|
||||
continue
|
||||
|
||||
items[key] = active[key].info if key in active else download.info
|
||||
|
||||
return {
|
||||
"queue": items,
|
||||
"queue_count": len(self.queue),
|
||||
"queue_loaded": len(items),
|
||||
"queue_limit": limit,
|
||||
}
|
||||
|
||||
async def get_item(self, **kwargs) -> tuple["StoreType", Download] | tuple[None, None]:
|
||||
"""
|
||||
Get a specific item from the download queue or history.
|
||||
|
|
@ -519,7 +759,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
**kwargs: The key-value pair to search for. Supported keys are 'id', 'url'.
|
||||
|
||||
Returns:
|
||||
(StoreType, Download) | None: The requested item if found, otherwise None.
|
||||
tuple[StoreType, Download] | tuple[None, None]: The requested item if found, otherwise None.
|
||||
|
||||
"""
|
||||
from app.library.DataStore import StoreType
|
||||
|
|
|
|||
|
|
@ -55,7 +55,37 @@ class StatusTracker:
|
|||
self.final_update = False
|
||||
self._terminator_sent: bool = False
|
||||
self._candidate_filepath: Path | None = None
|
||||
self.update_task: asyncio.Task | None = None
|
||||
self.update_task: asyncio.Future[Any] | None = None
|
||||
self._last_progress_time: float = 0.0
|
||||
self._pending_progress: bool = False
|
||||
self._progress_interval: float = 0.5
|
||||
|
||||
def _progress_payload(self) -> dict:
|
||||
return {
|
||||
"_id": self.info._id,
|
||||
"status": self.info.status,
|
||||
"percent": self.info.percent,
|
||||
"speed": self.info.speed,
|
||||
"eta": self.info.eta,
|
||||
"downloaded_bytes": self.info.downloaded_bytes,
|
||||
"total_bytes": self.info.total_bytes,
|
||||
"msg": self.info.msg,
|
||||
}
|
||||
|
||||
def _emit_progress(self) -> None:
|
||||
now = time.monotonic()
|
||||
if (now - self._last_progress_time) >= self._progress_interval:
|
||||
self._notify.emit(Events.ITEM_PROGRESS, data=self._progress_payload())
|
||||
self._last_progress_time = now
|
||||
self._pending_progress = False
|
||||
else:
|
||||
self._pending_progress = True
|
||||
|
||||
def _flush_progress(self) -> None:
|
||||
if self._pending_progress:
|
||||
self._notify.emit(Events.ITEM_PROGRESS, data=self._progress_payload())
|
||||
self._pending_progress = False
|
||||
self._last_progress_time = time.monotonic()
|
||||
|
||||
async def _finalize_file(self, filepath: Path) -> None:
|
||||
"""
|
||||
|
|
@ -68,12 +98,36 @@ class StatusTracker:
|
|||
self.info.datetime = str(formatdate(time.time()))
|
||||
self.info.filename = safe_relative_path(filepath, Path(self.download_dir))
|
||||
self.final_update = True
|
||||
self.logger.debug(f"Final file name: '{filepath}'.")
|
||||
self.logger.debug(
|
||||
"Final download file for '%s' is '%s'.",
|
||||
self.info.title,
|
||||
filepath,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"file_path": str(filepath),
|
||||
}
|
||||
},
|
||||
)
|
||||
try:
|
||||
filepath.relative_to(self.download_dir)
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
f"Final file '{filepath}' is outside of the intended download directory '{self.download_dir}'."
|
||||
"Final file '%s' for '%s' is outside download directory '%s'.",
|
||||
filepath,
|
||||
self.info.title,
|
||||
self.download_dir,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"file_path": str(filepath),
|
||||
"download_dir": self.download_dir,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.info.filename = None
|
||||
return
|
||||
|
|
@ -97,8 +151,19 @@ class StatusTracker:
|
|||
except Exception as e:
|
||||
self.info.extras["is_video"] = True
|
||||
self.info.extras["is_audio"] = True
|
||||
self.logger.exception(e)
|
||||
self.logger.error(f"Failed to run ffprobe. {e}")
|
||||
self.logger.exception(
|
||||
"Failed to inspect completed file '%s' with ffprobe.",
|
||||
filepath,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"file_path": str(filepath),
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async def process_status_update(self, status: StatusDict) -> None:
|
||||
"""
|
||||
|
|
@ -115,11 +180,33 @@ class StatusTracker:
|
|||
return
|
||||
|
||||
if status.get("id") != self.id or len(status) < 2:
|
||||
self.logger.warning(f"Received invalid status update. {status}")
|
||||
self.logger.warning(
|
||||
"Received invalid status update for '%s'.",
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"status": status,
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if self.debug:
|
||||
self.logger.debug(f"Status Update: _id={self.info._id} status={status}")
|
||||
self.logger.debug(
|
||||
"Received a download status update for '%s'.",
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"status": status,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if isinstance(status, str):
|
||||
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
|
||||
|
|
@ -127,6 +214,7 @@ class StatusTracker:
|
|||
|
||||
self.tmpfilename = status.get("tmpfilename")
|
||||
|
||||
old_status = self.info.status
|
||||
self.info.status = status.get("status", self.info.status)
|
||||
if "download_skipped" in status:
|
||||
self.info.download_skipped = bool(status.get("download_skipped"))
|
||||
|
|
@ -139,7 +227,19 @@ class StatusTracker:
|
|||
if self.info.status == "error" and "error" in status:
|
||||
self.info.error = status.get("error")
|
||||
if self._candidate_filepath and self._candidate_filepath.exists():
|
||||
self.logger.debug(f"Cleaning up partial file: {self._candidate_filepath}")
|
||||
self.logger.debug(
|
||||
"Cleaning up partial file '%s' for '%s'.",
|
||||
self._candidate_filepath,
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"file_path": str(self._candidate_filepath),
|
||||
}
|
||||
},
|
||||
)
|
||||
await self._finalize_file(self._candidate_filepath)
|
||||
|
||||
self._notify.emit(
|
||||
|
|
@ -156,7 +256,7 @@ class StatusTracker:
|
|||
self.info.percent = status["downloaded_bytes"] / total * 100
|
||||
except ZeroDivisionError:
|
||||
self.info.percent = 0
|
||||
self.info.total_bytes = total
|
||||
self.info.total_bytes = int(total)
|
||||
|
||||
self.info.speed = status.get("speed")
|
||||
self.info.eta = status.get("eta")
|
||||
|
|
@ -166,7 +266,11 @@ class StatusTracker:
|
|||
await self._finalize_file(Path(final_name))
|
||||
self.info.status = "finished"
|
||||
|
||||
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
|
||||
if self.info.status != old_status or self.final_update:
|
||||
self._flush_progress()
|
||||
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
|
||||
else:
|
||||
self._emit_progress()
|
||||
|
||||
async def progress_update(self) -> None:
|
||||
"""
|
||||
|
|
@ -176,12 +280,15 @@ class StatusTracker:
|
|||
"""
|
||||
while True:
|
||||
try:
|
||||
self.update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get)
|
||||
status = await self.update_task
|
||||
update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get)
|
||||
self.update_task = asyncio.ensure_future(update_task)
|
||||
status = await update_task
|
||||
if status is None or isinstance(status, Terminator):
|
||||
self._flush_progress()
|
||||
return
|
||||
await self.process_status_update(status)
|
||||
except (asyncio.CancelledError, OSError, FileNotFoundError, EOFError, BrokenPipeError, ConnectionError):
|
||||
self._flush_progress()
|
||||
return
|
||||
|
||||
async def drain_queue(self, max_iterations: int = 50) -> None:
|
||||
|
|
@ -203,7 +310,19 @@ class StatusTracker:
|
|||
|
||||
for i in range(drain_count):
|
||||
if self.final_update:
|
||||
self.logger.info(f"({max_iterations}/{i}) Draining stopped. Final update received.")
|
||||
self.logger.info(
|
||||
"Stopped draining status queue for '%s' after final update.",
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"max_iterations": max_iterations,
|
||||
"iteration": i,
|
||||
}
|
||||
},
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
|
|
@ -214,13 +333,27 @@ class StatusTracker:
|
|||
except (queue.Empty, BrokenPipeError, ConnectionRefusedError, EOFError, OSError):
|
||||
continue
|
||||
|
||||
self._flush_progress()
|
||||
|
||||
def cancel_update_task(self) -> None:
|
||||
"""Cancel the progress update task if it's running."""
|
||||
try:
|
||||
if self.update_task and not self.update_task.done():
|
||||
self.update_task.cancel()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to cancel update task. {e}")
|
||||
self.logger.exception(
|
||||
"Failed to cancel the progress update task for '%s' because %s.",
|
||||
self.info.title,
|
||||
e,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def put_terminator(self) -> None:
|
||||
"""Put a terminator sentinel in the status queue."""
|
||||
|
|
|
|||
|
|
@ -80,7 +80,20 @@ class TempManager:
|
|||
and self.info.downloaded_bytes
|
||||
and self.info.downloaded_bytes > 0
|
||||
):
|
||||
self.logger.warning(f"Keeping temp folder '{self.temp_path}'. status={self.info.status}")
|
||||
self.logger.warning(
|
||||
"Keeping temp folder '%s' for unfinished download '%s'.",
|
||||
self.temp_path,
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.info._id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"temp_path": str(self.temp_path),
|
||||
"status": self.info.status,
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
tmp_dir = Path(self.temp_path)
|
||||
|
|
@ -89,12 +102,50 @@ class TempManager:
|
|||
return
|
||||
|
||||
if not self.temp_dir or not is_safe_to_delete_dir(tmp_dir, self.temp_dir):
|
||||
self.logger.warning(f"Refusing to delete video temp folder '{self.temp_path}' as it's temp root.")
|
||||
self.logger.warning(
|
||||
"Refusing to delete temp folder '%s' for '%s' because it is not safe.",
|
||||
self.temp_path,
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.info._id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"temp_path": str(self.temp_path),
|
||||
"temp_dir": self.temp_dir,
|
||||
}
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
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.")
|
||||
self.logger.info(
|
||||
"Emptied temp folder '%s' for '%s'.",
|
||||
self.temp_path,
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.info._id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"temp_path": str(self.temp_path),
|
||||
}
|
||||
},
|
||||
)
|
||||
else:
|
||||
self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.")
|
||||
self.logger.info(
|
||||
"Deleted temp folder '%s' for '%s'." if status else "Failed to delete temp folder '%s' for '%s'.",
|
||||
self.temp_path,
|
||||
self.info.title,
|
||||
extra={
|
||||
"download": {
|
||||
"download_id": self.info._id,
|
||||
"item_id": self.info.id,
|
||||
"title": self.info.title,
|
||||
"temp_path": str(self.temp_path),
|
||||
"deleted": status,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -128,7 +128,12 @@ def parse_extractor_limit(
|
|||
limit: int = min(int(env_limit), max_workers)
|
||||
else:
|
||||
if env_limit and logger:
|
||||
logger.warning(f"Invalid extractor limit '{env_limit}' for '{extractor}', using default limit.")
|
||||
logger.warning(
|
||||
"Invalid extractor limit '%s' for '%s'; using default limit.",
|
||||
env_limit,
|
||||
extractor,
|
||||
extra={"extractor": extractor, "env_limit": env_limit, "default_limit": default_limit},
|
||||
)
|
||||
limit = default_limit
|
||||
|
||||
return min(limit, max_workers)
|
||||
|
|
@ -156,7 +161,12 @@ def get_extractor_limit(
|
|||
if extractor not in LIMITS:
|
||||
limit = parse_extractor_limit(extractor, max_workers_per_extractor, max_workers, logger)
|
||||
LIMITS[extractor] = asyncio.Semaphore(limit)
|
||||
logger.info(f"Created limits container for extractor '{extractor}': {limit}")
|
||||
logger.info(
|
||||
"Created download limit for extractor '%s' with %s worker(s).",
|
||||
extractor,
|
||||
limit,
|
||||
extra={"extractor": extractor, "limit": limit, "max_workers": max_workers},
|
||||
)
|
||||
|
||||
return LIMITS[extractor]
|
||||
|
||||
|
|
@ -247,7 +257,10 @@ def handle_task_exception(task: asyncio.Task, logger: logging.Logger) -> None:
|
|||
return
|
||||
|
||||
task_name: str = task.get_name() if task.get_name() else "unknown_task"
|
||||
import traceback
|
||||
|
||||
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
logger.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {tb}")
|
||||
logger.error(
|
||||
"Unhandled exception in background task '%s'. %s",
|
||||
task_name,
|
||||
exc,
|
||||
extra={"task_name": task_name, "exception_type": type(exc).__name__},
|
||||
exc_info=(type(exc), exc, exc.__traceback__),
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue