Compare commits

..

No commits in common. "master" and "v2.5.2" have entirely different histories.

207 changed files with 6085 additions and 14311 deletions

View file

@ -15,7 +15,7 @@ on:
env: env:
BUN_VERSION: latest BUN_VERSION: latest
NODE_VERSION: 22 NODE_VERSION: 20
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
jobs: jobs:

View file

@ -29,7 +29,7 @@ env:
DOCKERHUB_SLUG: arabcoders/ytptube DOCKERHUB_SLUG: arabcoders/ytptube
GHCR_SLUG: ghcr.io/arabcoders/ytptube GHCR_SLUG: ghcr.io/arabcoders/ytptube
BUN_VERSION: latest BUN_VERSION: latest
NODE_VERSION: 22 NODE_VERSION: 20
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
jobs: jobs:

View file

@ -39,7 +39,7 @@ jobs:
env: env:
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
BUN_VERSION: latest BUN_VERSION: latest
NODE_VERSION: 22 NODE_VERSION: 20
TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }} TAG_NAME: ${{ github.event.inputs.tag || github.ref_name }}
steps: steps:

247
API.md
View file

@ -59,6 +59,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt) - [GET /api/player/subtitle/{file:.\*}.vtt](#get-apiplayersubtitlefilevtt)
- [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile) - [GET /api/player/subtitles/manifest/{file:.\*}](#get-apiplayersubtitlesmanifestfile)
- [GET /api/player/subtitles/{source\_format}/{file:.\*}](#get-apiplayersubtitlessource_formatfile) - [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/ffprobe/{file:.\*}](#get-apifileffprobefile)
- [GET /api/file/info/{file:.\*}](#get-apifileinfofile) - [GET /api/file/info/{file:.\*}](#get-apifileinfofile)
- [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath) - [GET /api/file/browser/{path:.\*}](#get-apifilebrowserpath)
@ -88,8 +89,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [DELETE /api/conditions/{id}](#delete-apiconditionsid) - [DELETE /api/conditions/{id}](#delete-apiconditionsid)
- [GET /api/logs](#get-apilogs) - [GET /api/logs](#get-apilogs)
- [GET /api/logs/stream](#get-apilogsstream) - [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/](#get-apinotifications)
- [GET /api/notifications/events/](#get-apinotificationsevents) - [GET /api/notifications/events/](#get-apinotificationsevents)
- [POST /api/notifications/](#post-apinotifications) - [POST /api/notifications/](#post-apinotifications)
@ -101,8 +100,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/notifications/test](#post-apinotificationstest) - [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions) - [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration) - [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/folders](#get-apisystemfolders)
- [GET /api/system/diagnostics](#get-apisystemdiagnostics)
- [GET /api/system/limits](#get-apisystemlimits) - [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal) - [POST /api/system/terminal](#post-apisystemterminal)
- [GET /api/system/terminal](#get-apisystemterminal) - [GET /api/system/terminal](#get-apisystemterminal)
@ -700,16 +697,10 @@ GET /api/history?type=queue&status=pending&order=ASC
This endpoint returns the current state of active downloads from memory. 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**: **Response**:
```json ```json
{ {
"history_count": 0, // total number of completed items in history "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":{ "queue":{
"id": "abc123", "id": "abc123",
"url": "https://example.com/video", "url": "https://example.com/video",
@ -1664,6 +1655,17 @@ 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:.*} ### GET /api/file/ffprobe/{file:.*}
**Purpose**: Return the `ffprobe` data for a local file. **Purpose**: Return the `ffprobe` data for a local file.
@ -2323,46 +2325,12 @@ Binary image data with appropriate headers
{ {
"logs": [ "logs": [
{ {
"id": "<uuid>", "timestamp": "2023-01-01T12:00:00Z",
"datetime": "2026-05-18T12:00:00.000+00:00", "level": "INFO",
"level": "error", "message": "...",
"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, "offset": 0,
"limit": 100, "limit": 100,
@ -2384,45 +2352,9 @@ Binary image data with appropriate headers
**Event Payload**: **Event Payload**:
```json ```json
{ {
"id": "<uuid>", "id": "<sha256>",
"datetime": "2026-05-18T12:00:00.000+00:00", "line": "<log line>",
"level": "error", "datetime": "2024-01-01T12:00:00.000000+00:00"
"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
}
}
} }
``` ```
@ -2430,31 +2362,6 @@ 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/ ### GET /api/notifications/
**Purpose**: Retrieve notification targets with pagination. **Purpose**: Retrieve notification targets with pagination.
@ -2635,86 +2542,55 @@ or an error:
--- ---
### GET /api/system/configuration ### GET /api/system/configuration
**Purpose**: Retrieve system configuration. **Purpose**: Retrieve comprehensive system configuration including app settings, presets, download fields, queue status, and folder structure.
**Response**: **Response**:
```json ```json
{ {
"app": {...}, "app": {
"presets": [...], "version": "...",
"dl_fields": [...], "download_path": "/path/to/downloads",
"paused": false, "base_path": "/",
"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**:
- 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": { "presets": [
"app_version": "1.0.0", {
"app_branch": "main", "id": 1,
"app_commit_sha": "abcdef12", "name": "default",
"app_build_date": "20260526", "description": "...",
"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": [] "dl_fields": [
{
"id": 1,
"name": "Title",
"field": "title",
"kind": "text",
...
}
],
"paused": false,
"folders": [
{"name": "folder1", "path": "folder1"},
{"name": "folder2", "path": "folder2"}
],
"history_count": 150,
"queue": [
{
"id": "abc123",
"url": "https://example.com/video",
"status": "pending",
...
}
]
} }
``` ```
**Notes**: **Notes**:
- Unexpected collection errors are returned as an `error`. - 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
--- ---
@ -2726,6 +2602,7 @@ or an error:
{ {
"downloads": { "downloads": {
"paused": false, "paused": false,
"live_bypasses_limits": true,
"global": { "global": {
"limit": 20, "limit": 20,
"active": 3, "active": 3,
@ -3468,7 +3345,7 @@ Emitted when a download item is moved between queue and history.
"data": { "data": {
"id": "abc123", "id": "abc123",
"from": "queue", "from": "queue",
"to": "history" "to": "done"
} }
} }
``` ```

57
FAQ.md
View file

@ -3,9 +3,6 @@
Certain configuration values can be set via environment variables, using the `-e` parameter on the docker command line, 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. or the `environment:` section in `compose.yaml` file.
<details>
<summary>Click to expand</summary>
| Environment Variable | Description | Default | | Environment Variable | Description | Default |
| ------------------------------- | ------------------------------------------------------------------- | --------------------- | | ------------------------------- | ------------------------------------------------------------------- | --------------------- |
| TZ | The timezone to use for the application | `(not_set)` | | TZ | The timezone to use for the application | `(not_set)` |
@ -47,10 +44,10 @@ or the `environment:` section in `compose.yaml` file.
| YTP_FLARESOLVERR_CACHE_TTL | The cache TTL (in seconds) for FlareSolverr solutions | `600` | | 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_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_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_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_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` | | 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_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` | | YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` | | YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
@ -63,20 +60,19 @@ or the `environment:` section in `compose.yaml` file.
| YTP_THUMB_CONCURRENCY | The number of concurrent ffmpeg thumbnail generations allowed. | `2` | | 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_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_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] > [!NOTE]
> To raise the worker limit for a specific extractor, set an env variable using this format: `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>` > 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 uppercase. You can find the extractor name in the download logs. This value cannot be > The extractor name must be in uppercase, to know the extractor name, check the log for the specific extractor used for the download.
> higher than `YTP_MAX_WORKERS`; higher values are ignored. > The limit should not exceed the `YTP_MAX_WORKERS` value as it will be ignored.
>
> `YTP_SIMPLE_MODE=true` only applies when the browser has no saved layout choice yet. Users can still choose a layout in > [!IMPORTANT]
> WebUI Settings. `/?simple=1` forces and saves Simple for that browser. > 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.
>
> `YTP_AUTO_CLEAR_HISTORY_DAYS` `0` days means no automatic clearing of the download history. lowest value that will ## Notes about YTP_AUTO_CLEAR_HISTORY_DAYS
> trigger the clearing is `1` day. This setting will **NOT** delete the downloaded files, it will only clear the
> history from the database. - `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 # Browser extensions & bookmarklets
@ -123,35 +119,6 @@ As this is a simple basic authentication, if your browser doesn't show the promp
`http://username:password@your_ytptube_url:port` `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 # I cant download anything
If you are receiving errors like: If you are receiving errors like:

View file

@ -44,29 +44,21 @@ Please read the [FAQ](FAQ.md) for more information.
# Installation # 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 ## Run using docker command
```bash ```bash
mkdir -p ./{config,downloads/{files,tmp}} && docker run -itd --rm --user "${UID}:${UID}" --name ytptube \ mkdir -p ./{config,downloads/files,downloads/tmp} && docker run -itd --rm --user "${UID}:${UID}" --name ytptube \
-e YTP_TEMP_PATH=/downloads/tmp -e YTP_DOWNLOAD_PATH=/downloads/files \ -e YTP_TEMP_PATH=/downloads/tmp -e YTP_DOWNLOAD_PATH=/downloads/files \
-p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \ -p 8081:8081 -v ./config:/config:rw -v ./downloads:/downloads:rw \
ghcr.io/arabcoders/ytptube:latest ghcr.io/arabcoders/ytptube:latest
``` ```
## Run using podman
```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`. Then you can access the WebUI at `http://localhost:8081`.
> [!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.
## Using compose file ## Using compose file
The following is an example of a `compose.yaml` file that can be used to run YTPTube. The following is an example of a `compose.yaml` file that can be used to run YTPTube.
@ -75,8 +67,6 @@ The following is an example of a `compose.yaml` file that can be used to run YTP
services: services:
ytptube: ytptube:
user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id. 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 image: ghcr.io/arabcoders/ytptube:latest
container_name: ytptube container_name: ytptube
restart: unless-stopped restart: unless-stopped
@ -91,14 +81,18 @@ services:
``` ```
> [!IMPORTANT] > [!IMPORTANT]
> 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. > Make sure to change the `user` line to match your user id and group id.
```bash ```bash
mkdir -p ./{config,downloads/{files,tmp}} && docker compose -f compose.yaml up -d mkdir -p ./{config,downloads/files,downloads/tmp} && docker compose -f compose.yaml up -d
``` ```
Then you can access the WebUI at `http://localhost:8081`. 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 ## Unraid
For `Unraid` users You can install the `Community Applications` plugin, and search for **ytptube** it comes For `Unraid` users You can install the `Community Applications` plugin, and search for **ytptube** it comes
@ -112,14 +106,17 @@ 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. This project is not affiliated with yt-dlp or any other service.
This is a personal project designed to make downloading videos from the internet more convenient for me. It is not Its a personal project designed to make downloading videos from the internet more convenient. Its not intended for
intended for piracy or any unlawful use. This project was built primarily for my own use and preferences. piracy or any unlawful use.
AI-assisted tools are used in this project. If you are uncomfortable with this, you should not use this project. 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.
Contributions are welcome, but I may decline changes that do not interest me or do not align with my vision for this This project was built primarily for my own needs and preferences. The UI might not be the most polished or visually
project. Unsolicited pull requests will be closed. For suggestions or feature requests, please open a discussion or refined, but Im happy with it as it is. You can, however, create and load your own UI for complete customization.
join the Discord server.
Contributions are welcome, but I may decline changes that dont 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.
# Social contact # Social contact

View file

@ -16,10 +16,7 @@ def pytest_configure(config) -> None:
if getattr(config.option, "basetemp", None) is None: if getattr(config.option, "basetemp", None) is None:
config.option.basetemp = str(get_test_run_root() / "pytest") config.option.basetemp = str(get_test_run_root() / "pytest")
os.environ["YTP_FILE_LOGGING"] = "false"
def pytest_unconfigure(config) -> None: def pytest_unconfigure(config) -> None:
del config del config
os.environ.pop("YTP_FILE_LOGGING", None)
cleanup_test_run_root() cleanup_test_run_root()

View file

@ -1,18 +1,18 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from app.features.conditions.models import ConditionModel from app.features.conditions.models import ConditionModel
from app.features.core.migration import Migration as FeatureMigration from app.features.core.migration import Migration as FeatureMigration
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from app.features.conditions.repository import ConditionsRepository from app.features.conditions.repository import ConditionsRepository
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
class Migration(FeatureMigration): class Migration(FeatureMigration):
@ -36,11 +36,7 @@ class Migration(FeatureMigration):
try: try:
items: list[dict] | None = json.loads(self._source_file.read_text()) items: list[dict] | None = json.loads(self._source_file.read_text())
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
"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) await self._move_file(self._source_file)
return return
@ -58,11 +54,7 @@ class Migration(FeatureMigration):
await self._repo.create(normalized) await self._repo.create(normalized)
inserted += 1 inserted += 1
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to insert condition '%s': %s", normalized.name, exc)
"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) LOG.info("Migrated %s condition(s) from %s.", inserted, self._source_file)
await self._move_file(self._source_file) await self._move_file(self._source_file)

View file

@ -1,50 +1,31 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.features.conditions.migration import Migration from app.features.conditions.migration import Migration
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterable from collections.abc import AsyncGenerator, Iterable
from contextlib import AbstractAsyncContextManager
from sqlalchemy.engine.result import Result from sqlalchemy.engine.result import Result
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.selectable import Select from sqlalchemy.sql.selectable import Select
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
from sqlalchemy import delete, func, or_, select from sqlalchemy import delete, func, or_, select
from app.features.conditions.models import ConditionModel from app.features.conditions.models import ConditionModel
from app.features.core.deps import get_session from app.features.core.deps import get_session
from app.library.log import get_logger
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
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): class ConditionsRepository(metaclass=Singleton):
def __init__(self, session: SessionFactory | None = None) -> None: def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
self._migrated = False self._migrated = False
self.session: SessionFactory = session or get_session self.session: AsyncGenerator[AsyncSession] = session or get_session
async def run_migrations(self) -> None: async def run_migrations(self) -> None:
if self._migrated: if self._migrated:
@ -57,7 +38,7 @@ class ConditionsRepository(metaclass=Singleton):
def get_instance() -> ConditionsRepository: def get_instance() -> ConditionsRepository:
return ConditionsRepository() return ConditionsRepository()
async def all(self) -> list[ConditionModel]: async def list(self) -> list[ConditionModel]:
async with self.session() as session: async with self.session() as session:
result: Result[tuple[ConditionModel]] = await session.execute( result: Result[tuple[ConditionModel]] = await session.execute(
select(ConditionModel).order_by(ConditionModel.priority.desc(), ConditionModel.name.asc()) select(ConditionModel).order_by(ConditionModel.priority.desc(), ConditionModel.name.asc())
@ -112,11 +93,11 @@ class ConditionsRepository(metaclass=Singleton):
result: Result[tuple[ConditionModel]] = await session.execute(query.limit(1)) result: Result[tuple[ConditionModel]] = await session.execute(query.limit(1))
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def create(self, payload: ConditionModel | dict[str, Any]) -> ConditionModel: async def create(self, payload: ConditionModel | dict) -> ConditionModel:
async with self.session() as session: async with self.session() as session:
model = _coerce_model(payload) model: ConditionModel = ConditionModel(**payload) if isinstance(payload, dict) else payload
if model.id is not None: if model.id is not None:
model.id = None # ty: ignore model.id = None
if await self.get_by_name(name=model.name) is not None: if await self.get_by_name(name=model.name) is not None:
msg: str = f"Condition with name '{model.name}' already exists." msg: str = f"Condition with name '{model.name}' already exists."
@ -179,11 +160,13 @@ class ConditionsRepository(metaclass=Singleton):
await session.commit() await session.commit()
return model return model
async def replace_all(self, items: Iterable[dict[str, Any] | ConditionModel]) -> list[ConditionModel]: async def replace_all(self, items: Iterable[dict | ConditionModel]) -> list[ConditionModel]:
async with self.session() as session: async with self.session() as session:
try: try:
await session.execute(delete(ConditionModel)) await session.execute(delete(ConditionModel))
models: list[ConditionModel] = [_coerce_model(item) for item in items] models: list[ConditionModel] = [
ConditionModel(**item) if isinstance(item, dict) else item for item in items
]
session.add_all(models) session.add_all(models)
await session.commit() await session.commit()
except Exception: except Exception:

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
from collections import OrderedDict from collections import OrderedDict
from typing import Any from typing import Any
@ -14,11 +15,10 @@ from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.router import route from app.library.router import route
from app.library.Utils import validate_url from app.library.Utils import validate_url
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
def _model(model: Any) -> Condition: def _model(model: Any) -> Condition:
@ -115,44 +115,18 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
else: else:
data = cache.get(key) data = cache.get(key)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( return web.json_response(
data={"error": f"Failed to extract video info. '{e!s}'"}, data={"error": f"Failed to extract video info. '{e!s}'"},
status=web.HTTPInternalServerError.status_code, 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: try:
from app.features.ytdlp.mini_filter import match_str from app.features.ytdlp.mini_filter import match_str
status: bool = match_str(cond, data) status: bool = match_str(cond, data)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( return web.json_response(
data={"error": str(e)}, data={"error": str(e)},
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,

View file

@ -1,19 +1,19 @@
import logging
from collections.abc import Iterable from collections.abc import Iterable
from numbers import Number
from aiohttp import web from aiohttp import web
from app.features.conditions.models import ConditionModel from app.features.conditions.models import ConditionModel
from app.features.conditions.repository import ConditionsRepository 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.features.ytdlp.mini_filter import match_str
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
LOG = get_logger() LOG: logging.Logger = logging.getLogger("feature.conditions")
def _ignored_identifiers(ignore_conditions: Iterable[str | int | float] | None) -> tuple[set[str], bool]: def _ignored_identifiers(ignore_conditions: Iterable[str | Number] | None) -> tuple[set[str], bool]:
ignored: set[str] = set() ignored: set[str] = set()
ignore_all = False ignore_all = False
@ -21,7 +21,7 @@ def _ignored_identifiers(ignore_conditions: Iterable[str | int | float] | None)
return ignored, ignore_all return ignored, ignore_all
for value in ignore_conditions: for value in ignore_conditions:
if isinstance(value, bool) or not isinstance(value, (str, int, float)): if isinstance(value, bool) or not isinstance(value, (str, Number)):
continue continue
identifier = str(value).strip() identifier = str(value).strip()
@ -55,7 +55,7 @@ class Conditions(metaclass=Singleton):
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "ConditionsRepository.run_migrations") EventBus.get_instance().subscribe(Events.STARTED, handle_event, "ConditionsRepository.run_migrations")
async def get_all(self) -> list[ConditionModel]: async def get_all(self) -> list[ConditionModel]:
return await self._repo.all() return await self._repo.list()
async def save(self, item: ConditionModel | dict) -> ConditionModel: 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: if item.id is None or 0 == item.id:
model = await repo.create(item) model = await repo.create(item)
else: else:
model = await repo.update(item.id, Condition.model_validate(item).model_dump()) model = await repo.update(item.id, item.serialize())
except KeyError as exc: except KeyError as exc:
raise ValueError(str(exc)) from exc raise ValueError(str(exc)) from exc
@ -113,15 +113,13 @@ class Conditions(metaclass=Singleton):
repo = self._repo repo = self._repo
return await repo.get(identifier) return await repo.get(identifier)
async def match( async def match(self, info: dict, ignore_conditions: Iterable[str | Number] | None = None) -> ConditionModel | None:
self, info: dict, ignore_conditions: Iterable[str | int | float] | None = None
) -> ConditionModel | None:
""" """
Check if any condition matches the info dict. Check if any condition matches the info dict.
Args: Args:
info (dict): The info dict to check. info (dict): The info dict to check.
ignore_conditions (Iterable[str | int | float] | None): Condition ids or names to skip for this match. ignore_conditions (Iterable[str | Number] | None): Condition ids or names to skip for this match.
Returns: Returns:
Condition|None: The condition if found, None otherwise. Condition|None: The condition if found, None otherwise.
@ -135,7 +133,7 @@ class Conditions(metaclass=Singleton):
return None return None
repo = self._repo repo = self._repo
items: list[ConditionModel] = await repo.all() items: list[ConditionModel] = await repo.list()
if len(items) < 1: if len(items) < 1:
return None return None
@ -147,29 +145,17 @@ class Conditions(metaclass=Singleton):
continue continue
if not item.filter: if not item.filter:
LOG.error( LOG.error(f"Filter is empty for '{item.name}'.")
"Filter is empty for '%s'.", item.name, extra={"condition_id": item.id, "condition_name": item.name}
)
continue continue
try: try:
if not match_str(item.filter, info): if not match_str(item.filter, info):
continue continue
LOG.debug( LOG.debug(f"Matched '{item.id}: {item.name}' with filter '{item.filter}'.")
"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 return item
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to evaluate '{item.id}: {item.name}'. '{e!s}'.")
"Failed to evaluate condition '%s'.",
item.name,
extra={"condition_id": item.id, "condition_name": item.name, "exception_type": type(e).__name__},
)
continue continue
return None return None
@ -189,7 +175,7 @@ class Conditions(metaclass=Singleton):
if not info or not isinstance(info, dict) or len(info) < 1: if not info or not isinstance(info, dict) or len(info) < 1:
return None return None
if not (item := await self.get(str(identifier))) or not item.enabled or not item.filter: if not (item := await self.get(identifier)) or not item.enabled or not item.filter:
return None return None
return item if match_str(item.filter, info) else None return item if match_str(item.filter, info) else None

View file

@ -6,7 +6,6 @@ import pytest
import pytest_asyncio import pytest_asyncio
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
from typing import Any
from aiohttp import web from aiohttp import web
from aiohttp.test_utils import make_mocked_request from aiohttp.test_utils import make_mocked_request
@ -40,13 +39,13 @@ async def repo():
def _json_request(path: str, payload: object) -> web.Request: def _json_request(path: str, payload: object) -> web.Request:
mock_request: Any = make_mocked_request("POST", path) request = make_mocked_request("POST", path)
async def _json() -> object: async def _json() -> object:
return payload return payload
mock_request.json = _json request.json = _json # type: ignore[attr-defined]
return mock_request return request
class TestAllowInternalUrlsScope: class TestAllowInternalUrlsScope:
@ -209,7 +208,7 @@ class TestConditionsRepository:
await repo.create({"name": "A", "filter": "test", "priority": 1}) await repo.create({"name": "A", "filter": "test", "priority": 1})
await repo.create({"name": "C", "filter": "test", "priority": 2}) await repo.create({"name": "C", "filter": "test", "priority": 2})
items = await repo.all() items = await repo.list()
assert items[0].name == "C", "Highest priority should be first" assert items[0].name == "C", "Highest priority should be first"
assert items[1].name == "A", "Same priority sorted alphabetically" assert items[1].name == "A", "Same priority sorted alphabetically"
@ -241,6 +240,6 @@ class TestConditionsRepository:
assert len(result) == 2, "Should create 2 new conditions" assert len(result) == 2, "Should create 2 new conditions"
all_items = await repo.all() all_items = await repo.list()
assert len(all_items) == 2, "Should only have new conditions" 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" assert all_items[0].name in ["New 1", "New 2"], "Should only have new items"

View file

@ -1,16 +1,15 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
import logging
import time import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from app.library.config import Config from app.library.config import Config
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
class Migration(abc.ABC): class Migration(abc.ABC):
@ -27,11 +26,7 @@ class Migration(abc.ABC):
try: try:
await self.migrate() await self.migrate()
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Feature migration '%s' failed: %s", self.name, exc)
"Feature migration '%s' failed.",
self.name,
extra={"feature": self.name, "exception_type": type(exc).__name__},
)
return False return False
return True return True

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import DateTime as SQLADateTime from sqlalchemy import DateTime as SQLADateTime
from sqlalchemy import Dialect, TypeDecorator from sqlalchemy import TypeDecorator
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
@ -20,18 +20,16 @@ class UTCDateTime(TypeDecorator):
impl = SQLADateTime impl = SQLADateTime
cache_ok = True cache_ok = True
def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None: def process_bind_param(self, value: datetime | None, _dialect) -> datetime | None:
"""Convert datetime to UTC before storing.""" """Convert datetime to UTC before storing."""
_ = dialect
if value is not None: if value is not None:
if value.tzinfo is None: if value.tzinfo is None:
return value.replace(tzinfo=UTC) return value.replace(tzinfo=UTC)
return value.astimezone(UTC).replace(tzinfo=None) return value.astimezone(UTC).replace(tzinfo=None)
return value return value
def process_result_value(self, value: datetime | None, dialect: Dialect) -> datetime | None: def process_result_value(self, value: datetime | None, _dialect) -> datetime | None:
"""Ensure datetime is timezone-aware (UTC) when loading.""" """Ensure datetime is timezone-aware (UTC) when loading."""
_ = dialect
if value is not None and value.tzinfo is None: if value is not None and value.tzinfo is None:
return value.replace(tzinfo=UTC) return value.replace(tzinfo=UTC)
return value return value

View file

@ -1,18 +1,18 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from app.features.core.migration import Migration as FeatureMigration from app.features.core.migration import Migration as FeatureMigration
from app.features.dl_fields.schemas import DLField from app.features.dl_fields.schemas import DLField
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from app.features.dl_fields.repository import DLFieldsRepository from app.features.dl_fields.repository import DLFieldsRepository
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
class Migration(FeatureMigration): class Migration(FeatureMigration):
@ -36,11 +36,7 @@ class Migration(FeatureMigration):
try: try:
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text()) items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
"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) await self._move_file(self._source_file)
return return
@ -58,11 +54,7 @@ class Migration(FeatureMigration):
await self._repo.create(normalized) await self._repo.create(normalized)
inserted += 1 inserted += 1
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to insert dl field '%s': %s", normalized["name"], exc)
"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) LOG.info("Migrated %s dl field(s) from %s.", inserted, self._source_file)
await self._move_file(self._source_file) await self._move_file(self._source_file)

View file

@ -1,49 +1,30 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast import logging
from typing import TYPE_CHECKING, Any
from sqlalchemy import delete, func, or_, select from sqlalchemy import delete, func, or_, select
from app.features.core.deps import get_session from app.features.core.deps import get_session
from app.features.dl_fields.migration import Migration from app.features.dl_fields.migration import Migration
from app.features.dl_fields.models import DLFieldModel from app.features.dl_fields.models import DLFieldModel
from app.library.log import get_logger
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Iterable from collections.abc import AsyncGenerator, Iterable
from contextlib import AbstractAsyncContextManager
from sqlalchemy.engine.result import Result from sqlalchemy.engine.result import Result
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.selectable import Select from sqlalchemy.sql.selectable import Select
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] LOG: logging.Logger = logging.getLogger(__name__)
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): class DLFieldsRepository(metaclass=Singleton):
def __init__(self, session: SessionFactory | None = None) -> None: def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
self._migrated = False self._migrated = False
self.session: SessionFactory = session or get_session self.session: AsyncGenerator[AsyncSession] = session or get_session
async def run_migrations(self) -> None: async def run_migrations(self) -> None:
if self._migrated: if self._migrated:
@ -56,7 +37,7 @@ class DLFieldsRepository(metaclass=Singleton):
def get_instance() -> DLFieldsRepository: def get_instance() -> DLFieldsRepository:
return DLFieldsRepository() return DLFieldsRepository()
async def all(self) -> list[DLFieldModel]: async def list(self) -> list[DLFieldModel]:
async with self.session() as session: async with self.session() as session:
result: Result[tuple[DLFieldModel]] = await session.execute( result: Result[tuple[DLFieldModel]] = await session.execute(
select(DLFieldModel).order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc()) select(DLFieldModel).order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
@ -109,9 +90,9 @@ class DLFieldsRepository(metaclass=Singleton):
result: Result[tuple[DLFieldModel]] = await session.execute(query.limit(1)) result: Result[tuple[DLFieldModel]] = await session.execute(query.limit(1))
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def create(self, payload: DLFieldModel | dict[str, Any]) -> DLFieldModel: async def create(self, payload: DLFieldModel | dict) -> DLFieldModel:
async with self.session() as session: async with self.session() as session:
model = _coerce_model(payload) model: DLFieldModel = DLFieldModel(**payload) if isinstance(payload, dict) else payload
if await self.get_by_name(name=model.name) is not None: if await self.get_by_name(name=model.name) is not None:
msg: str = f"DL field with name '{model.name}' already exists." msg: str = f"DL field with name '{model.name}' already exists."
@ -172,16 +153,13 @@ class DLFieldsRepository(metaclass=Singleton):
await session.commit() await session.commit()
return model return model
async def replace_all(self, items: Iterable[dict[str, Any] | DLFieldModel]) -> list[DLFieldModel]: async def replace_all(self, items: Iterable[dict | DLFieldModel]) -> list[DLFieldModel]:
async with self.session() as session: async with self.session() as session:
try: try:
await session.execute(delete(DLFieldModel)) await session.execute(delete(DLFieldModel))
models: list[DLFieldModel] = [] models: list[DLFieldModel] = [
for item in items: DLFieldModel(**item) if isinstance(item, dict) else item for item in items
if isinstance(item, dict): ]
models.append(DLFieldModel(**cast("dict[str, Any]", item)))
else:
models.append(item)
session.add_all(models) session.add_all(models)
await session.commit() await session.commit()
except Exception: except Exception:

View file

@ -1,3 +1,4 @@
import logging
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
@ -10,10 +11,9 @@ from app.features.dl_fields.schemas import DLField, DLFieldList, DLFieldPatch
from app.features.dl_fields.service import DLFields from app.features.dl_fields.service import DLFields
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.router import route from app.library.router import route
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
def _model(model: Any) -> DLField: def _model(model: Any) -> DLField:

View file

@ -1,18 +1,18 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.features.dl_fields.models import DLFieldModel from app.features.dl_fields.models import DLFieldModel
from app.features.dl_fields.repository import DLFieldsRepository from app.features.dl_fields.repository import DLFieldsRepository
from app.features.dl_fields.schemas import DLField from app.features.dl_fields.schemas import DLField
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from aiohttp import web from aiohttp import web
LOG = get_logger() LOG: logging.Logger = logging.getLogger("feature.dl_fields")
class DLFields(metaclass=Singleton): class DLFields(metaclass=Singleton):
@ -33,10 +33,10 @@ class DLFields(metaclass=Singleton):
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "DLFieldsRepository.run_migrations") EventBus.get_instance().subscribe(Events.STARTED, handle_event, "DLFieldsRepository.run_migrations")
async def get_all(self) -> list[DLFieldModel]: async def get_all(self) -> list[DLFieldModel]:
return await self._repo.all() return await self._repo.list()
async def get_all_serialized(self) -> list[dict[str, Any]]: async def get_all_serialized(self) -> list[dict[str, Any]]:
items = await self._repo.all() items = await self._repo.list()
return [DLField.model_validate(item).model_dump() for item in items] return [DLField.model_validate(item).model_dump() for item in items]
async def save(self, item: DLField | dict) -> DLFieldModel: async def save(self, item: DLField | dict) -> DLFieldModel:

View file

@ -204,7 +204,7 @@ class TestDLFieldsRepository:
await repo.create({"name": "a", "description": "a", "field": "--a", "kind": "text", "order": 1}) 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}) await repo.create({"name": "c", "description": "c", "field": "--c", "kind": "text", "order": 0})
items = await repo.all() items = await repo.list()
assert items[0].name == "c", "Lowest order should be first" assert items[0].name == "c", "Lowest order should be first"
assert items[1].name == "a", "Same order sorted alphabetically" assert items[1].name == "a", "Same order sorted alphabetically"
@ -243,6 +243,6 @@ class TestDLFieldsRepository:
assert len(result) == 2, "Should create 2 new fields" assert len(result) == 2, "Should create 2 new fields"
all_items = await repo.all() all_items = await repo.list()
assert len(all_items) == 2, "Should only have new fields" 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" assert {item.name for item in all_items} == {"new_1", "new_2"}, "Should only have new items"

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@ -8,12 +9,11 @@ from app.features.core.migration import Migration as FeatureMigration
from app.features.notifications.schemas import NotificationEvents from app.features.notifications.schemas import NotificationEvents
from app.features.presets.service import Presets from app.features.presets.service import Presets
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from app.features.notifications.repository import NotificationsRepository from app.features.notifications.repository import NotificationsRepository
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
class Migration(FeatureMigration): class Migration(FeatureMigration):
@ -37,11 +37,7 @@ class Migration(FeatureMigration):
try: try:
items: list[dict] | None = json.loads(self._source_file.read_text()) items: list[dict] | None = json.loads(self._source_file.read_text())
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
"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) await self._move_file(self._source_file)
return return
@ -60,11 +56,7 @@ class Migration(FeatureMigration):
await self._repo.create(normalized) await self._repo.create(normalized)
inserted += 1 inserted += 1
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to insert notification '%s': %s", normalized.get("name"), exc)
"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) LOG.info("Migrated %s notification target(s) from %s.", inserted, self._source_file)
await self._move_file(self._source_file) await self._move_file(self._source_file)

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@ -7,43 +8,23 @@ from sqlalchemy import func, or_, select
from app.features.core.deps import get_session from app.features.core.deps import get_session
from app.features.notifications.migration import Migration from app.features.notifications.migration import Migration
from app.features.notifications.models import NotificationModel from app.features.notifications.models import NotificationModel
from app.library.log import get_logger
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import AsyncGenerator
from contextlib import AbstractAsyncContextManager
from sqlalchemy.engine.result import Result from sqlalchemy.engine.result import Result
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.selectable import Select from sqlalchemy.sql.selectable import Select
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] LOG: logging.Logger = logging.getLogger(__name__)
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): class NotificationsRepository(metaclass=Singleton):
def __init__(self, session: SessionFactory | None = None) -> None: def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
self._migrated = False self._migrated = False
self.session: SessionFactory = session or get_session self.session: AsyncGenerator[AsyncSession] = session or get_session
async def run_migrations(self) -> None: async def run_migrations(self) -> None:
if self._migrated: if self._migrated:
@ -56,7 +37,7 @@ class NotificationsRepository(metaclass=Singleton):
def get_instance() -> NotificationsRepository: def get_instance() -> NotificationsRepository:
return NotificationsRepository() return NotificationsRepository()
async def all(self) -> list[NotificationModel]: async def list(self) -> list[NotificationModel]:
async with self.session() as session: async with self.session() as session:
result: Result[tuple[NotificationModel]] = await session.execute( result: Result[tuple[NotificationModel]] = await session.execute(
select(NotificationModel).order_by(NotificationModel.name.asc()) select(NotificationModel).order_by(NotificationModel.name.asc())
@ -111,11 +92,11 @@ class NotificationsRepository(metaclass=Singleton):
result: Result[tuple[NotificationModel]] = await session.execute(query.limit(1)) result: Result[tuple[NotificationModel]] = await session.execute(query.limit(1))
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def create(self, payload: NotificationModel | dict[str, Any]) -> NotificationModel: async def create(self, payload: NotificationModel | dict) -> NotificationModel:
async with self.session() as session: async with self.session() as session:
model = _coerce_model(payload) model: NotificationModel = NotificationModel(**payload) if isinstance(payload, dict) else payload
if model.id is not None: if model.id is not None:
model.id = None # ty: ignore model.id = None
if await self.get_by_name(name=model.name) is not None: if await self.get_by_name(name=model.name) is not None:
msg: str = f"Notification target with name '{model.name}' already exists." msg: str = f"Notification target with name '{model.name}' already exists."

View file

@ -1,3 +1,4 @@
import logging
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
@ -10,10 +11,9 @@ from app.features.notifications.schemas import Notification, NotificationEvents,
from app.features.notifications.service import Notifications from app.features.notifications.service import Notifications
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.router import route from app.library.router import route
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
def _model(model: Any) -> Notification: def _model(model: Any) -> Notification:

View file

@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import traceback
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any
from app.features.notifications.models import NotificationModel from app.features.notifications.models import NotificationModel
from app.features.notifications.repository import NotificationsRepository from app.features.notifications.repository import NotificationsRepository
@ -21,7 +23,6 @@ from app.library.encoder import Encoder
from app.library.Events import Event, EventBus, Events from app.library.Events import Event, EventBus, Events
from app.library.httpx_client import async_client from app.library.httpx_client import async_client
from app.library.ItemDTO import Item, ItemDTO from app.library.ItemDTO import Item, ItemDTO
from app.library.log import get_logger
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
@ -30,7 +31,7 @@ if TYPE_CHECKING:
import httpx import httpx
from aiohttp import web from aiohttp import web
LOG = get_logger() LOG: logging.Logger = logging.getLogger("feature.notifications")
class Notifications(metaclass=Singleton): class Notifications(metaclass=Singleton):
@ -64,10 +65,10 @@ class Notifications(metaclass=Singleton):
await self._repo.run_migrations() await self._repo.run_migrations()
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "NotificationsRepository.run_migrations") EventBus.get_instance().subscribe(Events.STARTED, handle_event, "NotificationsRepository.run_migrations")
EventBus.get_instance().subscribe(NotificationEvents.events(), self.emit, f"{type(self).__name__}.emit") EventBus.get_instance().subscribe(NotificationEvents.events(), self.emit, f"{__class__.__name__}.emit")
async def all(self) -> list[NotificationModel]: async def list(self) -> list[NotificationModel]:
return await self._repo.all() return await self._repo.list()
async def list_paginated(self, page: int, per_page: int) -> tuple[list[NotificationModel], int, int, int]: 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) return await self._repo.list_paginated(page, per_page)
@ -92,8 +93,8 @@ class Notifications(metaclass=Singleton):
async def delete(self, identifier: int | str) -> NotificationModel: async def delete(self, identifier: int | str) -> NotificationModel:
return await self._repo.delete(identifier) return await self._repo.delete(identifier)
async def send(self, ev: Event, wait: bool = True) -> list[dict | Awaitable[dict]]: async def send(self, ev: Event, wait: bool = True) -> list[dict] | list[Awaitable[dict]]:
targets = await self._repo.all() targets = await self._repo.list()
if len(targets) < 1: if len(targets) < 1:
return [] return []
@ -126,7 +127,7 @@ class Notifications(metaclass=Singleton):
if wait: if wait:
return await asyncio.gather(*tasks) return await asyncio.gather(*tasks)
return cast("list[dict | Awaitable[dict]]", tasks) return tasks
def emit(self, e: Event, _, **__) -> None: def emit(self, e: Event, _, **__) -> None:
if not NotificationEvents.is_valid(e.event): if not NotificationEvents.is_valid(e.event):
@ -271,27 +272,14 @@ class Notifications(metaclass=Singleton):
if not status: if not status:
msg = "Apprise failed to send notification." msg = "Apprise failed to send notification."
self._raise_apprise_error(msg) raise RuntimeError(msg) # noqa: TRY301
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"Failed to send Apprise notification for event '%s'.", LOG.error("Error sending Apprise notification: %s", exc)
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 {"error": str(exc), "event": ev.event, "id": ev.id, "targets": [t.name for t in targets]}
return {} return {}
@staticmethod
def _raise_apprise_error(msg: str) -> None:
raise RuntimeError(msg)
async def _send(self, target: Notification, ev: Event) -> dict: async def _send(self, target: Notification, ev: Event) -> dict:
try: try:
LOG.info("Sending notification event '%s: %s' to '%s'.", ev.event, ev.id, target.name) LOG.info("Sending notification event '%s: %s' to '%s'.", ev.event, ev.id, target.name)
@ -347,17 +335,14 @@ class Notifications(metaclass=Singleton):
return resp_data return resp_data
except Exception as exc: except Exception as exc:
LOG.exception( err_msg = str(exc) or type(exc).__name__
"Failed to send notification event '%s: %s' to '%s'.", tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
LOG.error(
"Error sending notification event '%s: %s' to '%s'. '%s'. %s",
ev.event, ev.event,
ev.id, ev.id,
target.name, target.name,
extra={ err_msg,
"event_id": ev.id, tb,
"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)} return {"url": target.request.url, "status": 500, "text": str(ev)}

View file

@ -35,7 +35,7 @@ class TestNotificationsRepository:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_empty(self, repo): async def test_list_empty(self, repo):
"""List returns empty when no notifications exist.""" """List returns empty when no notifications exist."""
notifications = await repo.all() notifications = await repo.list()
assert notifications == [], "Should return empty list when no notifications exist" assert notifications == [], "Should return empty list when no notifications exist"
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -8,12 +9,11 @@ from app.features.core.migration import Migration as FeatureMigration
from app.features.presets.schemas import Preset from app.features.presets.schemas import Preset
from app.features.presets.utils import preset_name from app.features.presets.utils import preset_name
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from app.features.presets.repository import PresetsRepository from app.features.presets.repository import PresetsRepository
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
class Migration(FeatureMigration): class Migration(FeatureMigration):
@ -32,11 +32,7 @@ class Migration(FeatureMigration):
try: try:
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text()) items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
"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) await self._move_file(self._source_file)
return return
@ -46,7 +42,7 @@ class Migration(FeatureMigration):
return return
inserted = 0 inserted = 0
seen_names: dict[str, int] = {preset_name(preset.name): 1 for preset in await self._repo.all()} seen_names: dict[str, int] = {preset_name(preset.name): 1 for preset in await self._repo.list()}
for index, item in enumerate(items): for index, item in enumerate(items):
if not (normalized := self._normalize(item, index, seen_names)): if not (normalized := self._normalize(item, index, seen_names)):
@ -55,11 +51,7 @@ class Migration(FeatureMigration):
await self._repo.create(normalized) await self._repo.create(normalized)
inserted += 1 inserted += 1
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to insert preset '%s': %s", normalized["name"], exc)
"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) LOG.info("Migrated %s preset(s) from %s.", inserted, self._source_file)
await self._move_file(self._source_file) await self._move_file(self._source_file)

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@ -11,7 +12,6 @@ from app.features.presets.models import PresetModel
from app.features.presets.utils import preset_name, seed_defaults from app.features.presets.utils import preset_name, seed_defaults
from app.library.config import Config from app.library.config import Config
from app.library.Events import Event, EventBus, Events from app.library.Events import Event, EventBus, Events
from app.library.log import get_logger
from app.library.Services import Services from app.library.Services import Services
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
@ -27,31 +27,7 @@ if TYPE_CHECKING:
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
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): class PresetsRepository(metaclass=Singleton):
@ -97,15 +73,15 @@ class PresetsRepository(metaclass=Singleton):
LOG.debug("Refreshing presets cache due to configuration update.") LOG.debug("Refreshing presets cache due to configuration update.")
await self._update_cache() await self._update_cache()
Services.get_instance().add(PresetsRepository.__name__, self) Services.get_instance().add(__class__.__name__, self)
EventBus.get_instance().subscribe( EventBus.get_instance().subscribe(
Events.STARTED, handle_event, f"{PresetsRepository.__name__}.run_migrations" Events.STARTED, handle_event, f"{__class__.__name__}.run_migrations"
).subscribe(Events.CONFIG_UPDATE, handler, "Presets.refresh_cache") ).subscribe(Events.CONFIG_UPDATE, handler, "Presets.refresh_cache")
async def _update_cache(self) -> None: async def _update_cache(self) -> None:
from app.features.presets.service import Presets from app.features.presets.service import Presets
await Presets.get_instance().refresh_cache(await self.all()) await Presets.get_instance().refresh_cache(await self.list())
@staticmethod @staticmethod
def get_instance() -> PresetsRepository: def get_instance() -> PresetsRepository:
@ -121,7 +97,7 @@ class PresetsRepository(metaclass=Singleton):
LOG.error("Default preset '%s' not found, using 'default' preset.", default_name) LOG.error("Default preset '%s' not found, using 'default' preset.", default_name)
config.default_preset = "default" config.default_preset = "default"
async def all(self) -> list[PresetModel]: async def list(self) -> list[PresetModel]:
async with self.session() as session: async with self.session() as session:
result: Result[tuple[PresetModel]] = await session.execute( result: Result[tuple[PresetModel]] = await session.execute(
select(PresetModel).order_by(PresetModel.priority.desc(), PresetModel.name.asc()) select(PresetModel).order_by(PresetModel.priority.desc(), PresetModel.name.asc())
@ -262,9 +238,24 @@ class PresetsRepository(metaclass=Singleton):
result: Result[tuple[PresetModel]] = await session.execute(query.limit(1)) result: Result[tuple[PresetModel]] = await session.execute(query.limit(1))
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def create(self, payload: PresetModel | dict[str, Any]) -> PresetModel: async def create(self, payload: PresetModel | dict) -> PresetModel:
async with self.session() as session: async with self.session() as session:
data = _payload_data(payload) 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.pop("id", None) data.pop("id", None)
model = PresetModel(**data) model = PresetModel(**data)

View file

@ -12,7 +12,7 @@ class Presets(metaclass=Singleton):
def __init__(self, repo: PresetsRepository | None = None) -> None: def __init__(self, repo: PresetsRepository | None = None) -> None:
self._repo: PresetsRepository = repo or PresetsRepository.get_instance() self._repo: PresetsRepository = repo or PresetsRepository.get_instance()
self._cache: list[tuple[int, str, Preset]] = [] self._cache: list[tuple[int, str, Preset]] = []
Services.get_instance().add(type(self).__name__, self) Services.get_instance().add(__class__.__name__, self)
@staticmethod @staticmethod
def get_instance() -> Presets: def get_instance() -> Presets:

View file

@ -57,7 +57,7 @@ class TestPresetsRepository:
await repo.create({"name": "A", "priority": 1}) await repo.create({"name": "A", "priority": 1})
await repo.create({"name": "C", "priority": 2}) await repo.create({"name": "C", "priority": 2})
items = await repo.all() items = await repo.list()
assert items[0].name == "c", "Highest priority should be first" assert items[0].name == "c", "Highest priority should be first"
assert items[1].name == "a", "Same priority should sort by name" assert items[1].name == "a", "Same priority should sort by name"

View file

@ -1,11 +1,10 @@
import logging
import re import re
from datetime import UTC, datetime from datetime import UTC, datetime
from app.library.log import get_logger
NAME_WHITESPACE_PATTERN = re.compile(r"\s+") NAME_WHITESPACE_PATTERN = re.compile(r"\s+")
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
async def seed_defaults(repo) -> None: async def seed_defaults(repo) -> None:
@ -49,11 +48,7 @@ async def seed_defaults(repo) -> None:
await repo.update(existing.id, payload) await repo.update(existing.id, payload)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to seed default preset '%s': %s", preset.get("name"), exc)
"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: def preset_name(value: str) -> str:

View file

@ -5,16 +5,18 @@ Python wrapper for ffprobe command line tool. ffprobe must exist in the path.
import asyncio import asyncio
import functools import functools
import json import json
import logging
import operator import operator
import os import os
import subprocess # qa: ignore import subprocess # qa: ignore
from pathlib import Path from pathlib import Path
import anyio
from app.features.streaming.types import FFProbeError from app.features.streaming.types import FFProbeError
from app.library.log import get_logger
from app.library.Utils import timed_lru_cache from app.library.Utils import timed_lru_cache
LOG = get_logger() LOG: logging.Logger = logging.getLogger("streaming.ffprobe")
class FFStream: class FFStream:
@ -37,26 +39,21 @@ class FFStream:
def __repr__(self): def __repr__(self):
if "codec_long_name" not in self.__dict__: if "codec_long_name" not in self.__dict__:
self.__dict__["codec_long_name"] = self.__dict__.get("codec_name", "") self.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(): if self.is_video():
return f"<Stream: #{index} [{codec_type}] {codec_long_name}, {self.__dict__.get('framerate')}, ({self.__dict__.get('width')}x{self.__dict__.get('height')})>" return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, {self.framerate}, ({self.width}x{self.height})>"
if self.is_audio(): if self.is_audio():
return ( return (
f"<Stream: #{index} [{codec_type}] {codec_long_name}, " f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}, channels: {self.channels} ({self.channel_layout}), "
f"channels: {self.__dict__.get('channels')} ({self.__dict__.get('channel_layout')}), " "{sample_rate}Hz> "
f"{self.__dict__.get('sample_rate')}Hz> "
) )
if self.is_subtitle() or self.is_attachment(): if self.is_subtitle() or self.is_attachment():
return f"<Stream: #{index} [{codec_type}] {codec_long_name}>" return f"<Stream: #{self.index} [{self.codec_type}] {self.codec_long_name}>"
return f"<Stream: #{index} [{codec_type}]>" return f"<Stream: #{self.index} [{self.codec_type}]>"
def is_audio(self): def is_audio(self):
""" """
@ -255,14 +252,14 @@ async def ffprobe(file: Path | str) -> FFProbeResult:
raise OSError(msg) raise OSError(msg)
try: try:
proc = await asyncio.create_subprocess_exec( async with await anyio.open_file(os.devnull, "w") as tempf:
"ffprobe", await asyncio.create_subprocess_exec(
"-h", "ffprobe",
stdout=asyncio.subprocess.DEVNULL, "-h",
stderr=asyncio.subprocess.DEVNULL, stdout=tempf,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, stderr=tempf,
) creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
await proc.wait() )
except FileNotFoundError as e: except FileNotFoundError as e:
msg = "ffprobe not found." msg = "ffprobe not found."
raise OSError(msg) from e raise OSError(msg) from e

View file

@ -39,7 +39,7 @@ class M3u8:
m3u8.append("#EXT-X-MEDIA-SEQUENCE:0") m3u8.append("#EXT-X-MEDIA-SEQUENCE:0")
m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD") m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD")
segmentSize: str = f"{self.duration:.6f}" segmentSize: float = f"{self.duration:.6f}"
splits: int = math.ceil(duration / self.duration) splits: int = math.ceil(duration / self.duration)
segmentParams: dict = {} segmentParams: dict = {}

View file

@ -14,7 +14,7 @@ class Playlist:
"The path where files are downloaded." "The path where files are downloaded."
async def make(self, file: Path) -> str: async def make(self, file: Path) -> str:
ref: Path = Path(str(file.relative_to(self.download_path)).strip("/")) ref: str = Path(str(file.relative_to(self.download_path)).strip("/"))
try: try:
ff = await ffprobe(file) ff = await ffprobe(file)

View file

@ -1,7 +1,7 @@
# flake8: noqa: ARG002
from __future__ import annotations from __future__ import annotations
import os import os
import shutil
import subprocess import subprocess
import sys import sys
from functools import lru_cache from functools import lru_cache
@ -16,13 +16,9 @@ def detect_qsv_capabilities() -> dict[str, dict[str, bool]]:
Returns a dict where keys are codec names (e.g. "h264", "hevc", "vp9") and Returns a dict where keys are codec names (e.g. "h264", "hevc", "vp9") and
values are dicts with keys "full" and "lp" booleans. values are dicts with keys "full" and "lp" booleans.
""" """
vainfo = shutil.which("vainfo")
if not vainfo:
return {}
try: try:
result: subprocess.CompletedProcess[str] = subprocess.run( result: subprocess.CompletedProcess[str] = subprocess.run(
[vainfo], ["vainfo"], # noqa: S607
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
@ -91,13 +87,9 @@ def ffmpeg_encoders() -> set[str]:
""" """
from app.library.config import SUPPORTED_CODECS from app.library.config import SUPPORTED_CODECS
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
return set()
try: try:
result: subprocess.CompletedProcess[str] = subprocess.run( result: subprocess.CompletedProcess[str] = subprocess.run(
[ffmpeg, "-hide_banner", "-loglevel", "error", "-encoders"], ["ffmpeg", "-hide_banner", "-loglevel", "error", "-encoders"], # noqa: S607
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
@ -157,11 +149,9 @@ class _BaseBuilder:
codec_name: str codec_name: str
def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]: def input_args(self, ctx: dict[str, Any] | None = None) -> list[str]:
_ = ctx
return [] return []
def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]: def add_video_args(self, args: list[str], ctx: dict[str, Any] | None = None) -> list[str]:
_ = ctx
return [*args, "-codec:v", self.codec_name] return [*args, "-codec:v", self.codec_name]
@ -322,4 +312,4 @@ def encoder_fallback_chain(codec: str) -> tuple[str, ...]:
"libx264": [], "libx264": [],
} }
return tuple(chains.get(codec, chains["libx264"])) return chains.get(codec, chains["libx264"])

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
import logging
import os import os
import subprocess import subprocess # type: ignore
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@ -18,7 +19,6 @@ from app.features.streaming.library.segment_encoders import (
select_encoder, select_encoder,
) )
from app.library.config import SUPPORTED_CODECS, Config from app.library.config import SUPPORTED_CODECS, Config
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from asyncio.subprocess import Process from asyncio.subprocess import Process
@ -26,7 +26,7 @@ if TYPE_CHECKING:
from .ffprobe import FFProbeResult from .ffprobe import FFProbeResult
from .segment_encoders import EncoderBuilder from .segment_encoders import EncoderBuilder
LOG = get_logger() LOG: logging.Logger = logging.getLogger("player.segments")
class Segments: class Segments:
@ -170,15 +170,9 @@ class Segments:
stderr_task: asyncio.Task[None] = asyncio.create_task(_drain_stderr()) stderr_task: asyncio.Task[None] = asyncio.create_task(_drain_stderr())
LOG.debug( LOG.debug(f"Streaming '{file}' segment '{self.index}'. ffmpeg: {' '.join(args)}")
"Streaming segment %s for '%s'.",
self.index,
file,
extra={"file": str(file), "segment_index": self.index, "ffmpeg_args": args},
)
try: try:
assert proc.stdout is not None
while True: while True:
chunk: bytes = await proc.stdout.read(1024 * 64) chunk: bytes = await proc.stdout.read(1024 * 64)
if not chunk: if not chunk:
@ -197,10 +191,7 @@ class Segments:
try: try:
await asyncio.wait_for(proc.wait(), timeout=5) await asyncio.wait_for(proc.wait(), timeout=5)
except TimeoutError: except TimeoutError:
LOG.warning( LOG.error("ffmpeg process did not terminate in time. Killing it.")
"Segment stream for '%s' did not stop ffmpeg in time after the client disconnected; killing it.",
file,
)
proc.kill() proc.kill()
raise raise
except ConnectionResetError: except ConnectionResetError:
@ -213,10 +204,7 @@ class Segments:
try: try:
rc: int = await asyncio.wait_for(proc.wait(), timeout=5) rc: int = await asyncio.wait_for(proc.wait(), timeout=5)
except TimeoutError: except TimeoutError:
LOG.warning( LOG.error("ffmpeg process did not terminate in time after disconnect. Killing it.")
"Segment stream for '%s' did not stop ffmpeg in time after the client disconnected; killing it.",
file,
)
proc.kill() proc.kill()
try: try:
rc = await asyncio.wait_for(proc.wait(), timeout=5) rc = await asyncio.wait_for(proc.wait(), timeout=5)
@ -227,7 +215,7 @@ class Segments:
try: try:
rc = await asyncio.wait_for(proc.wait(), timeout=30) rc = await asyncio.wait_for(proc.wait(), timeout=30)
except TimeoutError: except TimeoutError:
LOG.warning("Segment stream for '%s' did not stop ffmpeg in time; killing the process.", file) LOG.error("ffmpeg process wait timed out. Killing it.")
proc.kill() proc.kill()
try: try:
rc = await asyncio.wait_for(proc.wait(), timeout=5) rc = await asyncio.wait_for(proc.wait(), timeout=5)
@ -241,13 +229,11 @@ class Segments:
return (wrote_any, rc, client_disconnected_local, stderr_buf.decode("utf-8", errors="ignore").strip()) 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: async def stream(self, file: Path, resp: web.StreamResponse) -> None:
codec: str = self.vcodec codec: str = Segments._cached_vcodec if Segments._cache_initialized else self.vcodec
if Segments._cache_initialized and Segments._cached_vcodec:
codec = Segments._cached_vcodec
if not codec or codec not in SUPPORTED_CODECS: if not codec or codec not in SUPPORTED_CODECS:
codec: str = select_encoder(self.vcodec or "") codec: str = select_encoder(self.vcodec or "")
LOG.debug("Selected video codec '%s' for segment streaming.", codec, extra={"codec": codec}) LOG.debug(f"Selected video codec '{codec}' for segment streaming.")
if Segments._cached_vcodec and Segments._cache_initialized: if Segments._cached_vcodec and Segments._cache_initialized:
codecs: list[str] = [Segments._cached_vcodec] codecs: list[str] = [Segments._cached_vcodec]
@ -273,14 +259,7 @@ class Segments:
if 0 != rc: if 0 != rc:
err: str = stderr_text[:500] if stderr_text else "no error output" err: str = stderr_text[:500] if stderr_text else "no error output"
LOG.warning( LOG.warning(f"transcoding has failed (cmd={ffmpeg_args}) (rc={rc}): {err}. Trying fallbacks.")
"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) self.attempted.add(s_codec)
finally: finally:
if stream_input.is_symlink(): if stream_input.is_symlink():

View file

@ -1,18 +1,17 @@
from __future__ import annotations from __future__ import annotations
import logging
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
import anyio import anyio
import pysubs2 import pysubs2
from pysubs2.formats.substation import SubstationFormat from pysubs2.formats.substation import SubstationFormat
from pysubs2.time import ms_to_times 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 from app.library.Utils import ALLOWED_SUBS_EXTENSIONS, get_file_sidecar
LOG = get_logger() LOG: logging.Logger = logging.getLogger("player.subtitle")
SOURCE_FORMATS: tuple[str, ...] = ("vtt", "srt", "ass") SOURCE_FORMATS: tuple[str, ...] = ("vtt", "srt", "ass")
DELIVERY_FORMATS: dict[str, str] = { DELIVERY_FORMATS: dict[str, str] = {
@ -49,8 +48,7 @@ def ms_to_timestamp(ms: int) -> str:
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}" return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
_substation_format: Any = SubstationFormat SubstationFormat.ms_to_timestamp = ms_to_timestamp
_substation_format.ms_to_timestamp = ms_to_timestamp
class Subtitle: class Subtitle:

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
import os import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@ -8,10 +9,9 @@ from pathlib import Path
from app.features.streaming.library.ffprobe import ffprobe from app.features.streaming.library.ffprobe import ffprobe
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
from app.library.Utils import FILES_TYPE, get_file_sidecar from app.library.Utils import FILES_TYPE, get_file_sidecar
LOG = get_logger() LOG: logging.Logger = logging.getLogger("player.thumbnail")
IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp") IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp")
FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart") FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart")
@ -24,8 +24,6 @@ THUMBNAIL_MISS_TTL = 3600.0
_LOCK = asyncio.Lock() _LOCK = asyncio.Lock()
_IN_PROCESS: dict[str, asyncio.Task[Path | None]] = {} _IN_PROCESS: dict[str, asyncio.Task[Path | None]] = {}
_SEM: asyncio.Semaphore | None = None _SEM: asyncio.Semaphore | None = None
_SEM_LIMIT: int | None = None _SEM_LIMIT: int | None = None
@ -37,7 +35,7 @@ def _get_semaphore() -> asyncio.Semaphore:
if _SEM is None or _SEM_LIMIT != limit: if _SEM is None or _SEM_LIMIT != limit:
_SEM = asyncio.Semaphore(limit) _SEM = asyncio.Semaphore(limit)
_SEM_LIMIT = limit _SEM_LIMIT = limit
LOG.info("Configured thumbnail generation to run %s job(s) at a time.", limit, extra={"limit": limit}) LOG.info(f"Configured thumbnail generation concurrency limit: {limit}")
return _SEM return _SEM
@ -131,11 +129,7 @@ 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: async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
ff_info = await ffprobe(media_file) ff_info = await ffprobe(media_file)
if not ff_info.has_video(): if not ff_info.has_video():
LOG.debug( LOG.debug(f"Skipping thumbnail generation for '{media_file}' because no video stream exists.")
"Skipping thumbnail generation for '%s' because no video stream exists.",
media_file,
extra={"media_file": str(media_file)},
)
return None return None
output_file.parent.mkdir(parents=True, exist_ok=True) output_file.parent.mkdir(parents=True, exist_ok=True)
@ -152,12 +146,7 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
sem = _get_semaphore() sem = _get_semaphore()
if sem.locked(): if sem.locked():
limit = _SEM_LIMIT or 1 limit = _SEM_LIMIT or 1
LOG.debug( LOG.debug(f"Waiting for thumbnail generation slot for '{media_file}'. limit={limit}")
"Waiting for a thumbnail generation slot for '%s' (limit=%s).",
media_file,
limit,
extra={"media_file": str(media_file), "limit": limit},
)
async with sem: async with sem:
last_error: str = "ffmpeg produced an empty thumbnail file" last_error: str = "ffmpeg produced an empty thumbnail file"
@ -165,17 +154,7 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
temp_file.unlink(missing_ok=True) temp_file.unlink(missing_ok=True)
args: list[str] = _build_ffmpeg_args(media_file, temp_file, seek_seconds=attempt_seek) args: list[str] = _build_ffmpeg_args(media_file, temp_file, seek_seconds=attempt_seek)
LOG.debug( LOG.debug(
"Generating thumbnail for '%s'. attempt=%s/%s seek=%s", f"Generating thumbnail for '{media_file}'. attempt={idx}/{len(attempts)} seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}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: try:
@ -192,12 +171,7 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
stdout, stderr = await proc.communicate() stdout, stderr = await proc.communicate()
if 0 == proc.returncode and temp_file.exists() and temp_file.stat().st_size > 0: if 0 == proc.returncode and temp_file.exists() and temp_file.stat().st_size > 0:
temp_file.replace(output_file) temp_file.replace(output_file)
LOG.info( LOG.info(f"Generated thumbnail '{output_file}' for '{media_file}'.")
"Generated thumbnail '%s' for '%s'.",
output_file,
media_file,
extra={"media_file": str(media_file), "output_file": str(output_file)},
)
return output_file return output_file
if 0 != proc.returncode: if 0 != proc.returncode:
@ -210,13 +184,7 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
last_error: str = "ffmpeg produced an empty thumbnail file" last_error: str = "ffmpeg produced an empty thumbnail file"
LOG.debug( LOG.debug(
"Thumbnail generation attempt failed for '%s'. seek=%s", f"Thumbnail generation attempt failed for '{media_file}'. seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}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) temp_file.unlink(missing_ok=True)
@ -256,16 +224,11 @@ async def ensure_thumb(media_file: Path, cache_root: Path, item_id: str | None =
task = _IN_PROCESS.get(thumb_id) task = _IN_PROCESS.get(thumb_id)
if task is not None and not task.done(): if task is not None and not task.done():
LOG.debug("Waiting for thumbnail generation for '%s'.", media_file, extra={"media_file": str(media_file)}) LOG.debug(f"Waiting for thumbnail generation for '{media_file}'.")
else: else:
task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{item_id or media_file.stem}") task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{item_id or media_file.stem}")
_IN_PROCESS[thumb_id] = task _IN_PROCESS[thumb_id] = task
LOG.debug( LOG.debug(f"Starting thumbnail generation task for '{media_file}' -> '{cache_file}'.")
"Starting thumbnail generation for '%s' -> '%s'.",
media_file,
cache_file,
extra={"media_file": str(media_file), "cache_file": str(cache_file)},
)
try: try:
result: Path | None = await task result: Path | None = await task

View file

@ -1,10 +1,10 @@
import logging
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response 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.m3u8 import M3u8
from app.features.streaming.library.playlist import Playlist from app.features.streaming.library.playlist import Playlist
@ -12,11 +12,10 @@ from app.features.streaming.library.segments import Segments
from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks from app.features.streaming.library.subtitle import Subtitle, get_subtitle_tracks
from app.features.streaming.types import StreamingError from app.features.streaming.types import StreamingError
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
from app.library.router import route from app.library.router import route
from app.library.Utils import get_file from app.library.Utils import get_file
LOG = get_logger() LOG: logging.Logger = logging.getLogger("streaming")
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create") @route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
@ -33,7 +32,7 @@ async def playlist_create(request: Request, config: Config, app: web.Application
Response: The response object. Response: The response object.
""" """
file: str | None = request.match_info.get("file") file: str = request.match_info.get("file")
if not file: if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
@ -84,8 +83,8 @@ async def m3u8_create(request: Request, config: Config, app: web.Application) ->
Response: The response object. Response: The response object.
""" """
file: str | None = request.match_info.get("file") file: str = request.match_info.get("file")
mode: str | None = request.match_info.get("mode") mode: str = request.match_info.get("mode")
if mode not in ["video", "subtitle"]: if mode not in ["video", "subtitle"]:
return web.json_response( return web.json_response(
@ -95,14 +94,13 @@ async def m3u8_create(request: Request, config: Config, app: web.Application) ->
if not file: if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
duration: float | None = None duration = request.query.get("duration", None)
duration_arg = request.query.get("duration", None)
if "subtitle" in mode: if "subtitle" in mode:
if not duration_arg: if not duration:
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
duration = float(duration_arg) duration = float(duration)
base_path: str = config.base_path.rstrip("/") base_path: str = config.base_path.rstrip("/")
@ -126,19 +124,11 @@ 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) return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
if "subtitle" in mode: 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) text = await cls.make_subtitle(file=realFile, duration=duration)
else: else:
text = await cls.make_stream(file=realFile) text = await cls.make_stream(file=realFile)
except StreamingError as e: except StreamingError as e:
LOG.exception( LOG.exception(e)
"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.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
return web.Response( return web.Response(
@ -153,7 +143,7 @@ async def m3u8_create(request: Request, config: Config, app: web.Application) ->
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream") @route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
async def segments_stream(request: Request, config: Config, app: web.Application) -> StreamResponse: async def segments_stream(request: Request, config: Config, app: web.Application) -> Response:
""" """
Get the segments. Get the segments.
@ -166,9 +156,9 @@ async def segments_stream(request: Request, config: Config, app: web.Application
Response: The response object. Response: The response object.
""" """
file: str | None = request.match_info.get("file") file: str = request.match_info.get("file")
segment: str | None = request.match_info.get("segment") segment: int = request.match_info.get("segment")
sd: str | None = request.query.get("sd") sd: int = request.query.get("sd")
vc: int = int(request.query.get("vc", 0)) vc: int = int(request.query.get("vc", 0))
ac: int = int(request.query.get("ac", 0)) ac: int = int(request.query.get("ac", 0))
@ -245,7 +235,7 @@ async def subtitles_get(request: Request, config: Config, app: web.Application)
Response: The response object. Response: The response object.
""" """
file: str | None = request.match_info.get("file") file: str = request.match_info.get("file")
if not file: if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
@ -303,7 +293,7 @@ async def subtitles_manifest_get(request: Request, config: Config, app: web.Appl
Response: The response object. Response: The response object.
""" """
file: str | None = request.match_info.get("file") file: str = request.match_info.get("file")
if not file: if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
@ -358,7 +348,7 @@ async def subtitles_track_get(request: Request, config: Config, app: web.Applica
Response: The response object. Response: The response object.
""" """
file: str | None = request.match_info.get("file") file: str = request.match_info.get("file")
source_format: str | None = request.match_info.get("source_format") source_format: str | None = request.match_info.get("source_format")
if not file: if not file:

View file

@ -4,7 +4,6 @@ from pathlib import Path
from typing import Any from typing import Any
import pytest import pytest
from aiohttp import web
from app.features.streaming.library.segments import Segments from app.features.streaming.library.segments import Segments
from app.tests.helpers import get_test_system_temp_root from app.tests.helpers import get_test_system_temp_root
@ -149,18 +148,18 @@ class _FakeProc:
self.killed = True self.killed = True
class _FakeResp(web.StreamResponse): class _FakeResp:
def __init__(self, fail_with: BaseException | None = None) -> None: def __init__(self, fail_with: Exception | None = None) -> None:
self.data: bytearray = bytearray() self.data: bytearray = bytearray()
self.eof = False self.eof = False
self._exc = fail_with self._exc = fail_with
async def write(self, data: bytes | bytearray | memoryview, *_args: Any, **_kwargs: Any) -> None: async def write(self, data: bytes) -> None:
if self._exc: if self._exc:
raise self._exc raise self._exc
self.data.extend(data) self.data.extend(data)
async def write_eof(self, *_args: Any, **_kwargs: Any) -> None: async def write_eof(self) -> None:
self.eof = True self.eof = True
@ -256,14 +255,14 @@ async def test_stream_gpu_fallback(
# Encourage GPU preference # Encourage GPU preference
seg.vcodec = "" # empty -> try GPUs first seg.vcodec = "" # empty -> try GPUs first
resp = _FakeResp() resp = _FakeResp()
with caplog.at_level(logging.WARNING, logger="ytptube"): with caplog.at_level(logging.WARNING, logger="player.segments"):
await seg.stream(tmp_path / "file.mp4", resp) await seg.stream(tmp_path / "file.mp4", resp)
# Ensure fallback path streamed data # Ensure fallback path streamed data
assert len(resp.data) > 0 assert len(resp.data) > 0
# Ensure we logged the reason for GPU failure (message text may vary) # Ensure we logged the reason for GPU failure (message text may vary)
assert any( assert any(
("hardware encoder" in r.message.lower()) or ("transcoding has failed" in r.message) for r in caplog.records ("Hardware encoder failed" in r.message) or ("transcoding has failed" in r.message) for r in caplog.records
) )
assert any("nvenc failure" 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) # Verify second invocation switched codec to a safe fallback (software)

View file

@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
@ -261,7 +260,7 @@ async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None:
output = temp_dir / ".jpg" output = temp_dir / ".jpg"
media.write_text("video") media.write_text("video")
ff_info: Any = FFProbeResult() ff_info = FFProbeResult()
ff_info.metadata = {"duration": "120.0"} ff_info.metadata = {"duration": "120.0"}
ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()] ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()]
@ -322,7 +321,7 @@ async def test_limit_wait(monkeypatch: pytest.MonkeyPatch) -> None:
media1.write_text("video") media1.write_text("video")
media2.write_text("video") media2.write_text("video")
ff_info: Any = FFProbeResult() ff_info = FFProbeResult()
ff_info.metadata = {"duration": "60.0"} ff_info.metadata = {"duration": "60.0"}
ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()] ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()]
monkeypatch.setattr(thumbnail, "ffprobe", AsyncMock(return_value=ff_info)) monkeypatch.setattr(thumbnail, "ffprobe", AsyncMock(return_value=ff_info))

View file

@ -1,3 +1,4 @@
# flake8: noqa: ARG004
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult
@ -11,7 +12,6 @@ if TYPE_CHECKING:
class BaseHandler: class BaseHandler:
@staticmethod @staticmethod
async def can_handle(task: HandleTask) -> bool: async def can_handle(task: HandleTask) -> bool:
_ = task
return False return False
@staticmethod @staticmethod
@ -24,7 +24,6 @@ class BaseHandler:
@staticmethod @staticmethod
def parse(url: str) -> Any | None: def parse(url: str) -> Any | None:
_ = url
return None return None
@staticmethod @staticmethod

View file

@ -6,6 +6,7 @@ import asyncio
import fnmatch import fnmatch
import hashlib import hashlib
import json import json
import logging
import re import re
from collections.abc import Mapping from collections.abc import Mapping
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -25,7 +26,6 @@ from app.features.ytdlp.utils import get_archive_id
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config 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.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 from ._base_handler import BaseHandler
@ -34,7 +34,7 @@ if TYPE_CHECKING:
from parsel.selector import SelectorList from parsel.selector import SelectorList
LOG = get_logger() LOG: logging.Logger = logging.getLogger("handlers.generic")
CACHE: Cache = Cache() CACHE: Cache = Cache()
@ -64,15 +64,12 @@ class GenericTaskHandler(BaseHandler):
from app.features.tasks.definitions.utils import model_to_schema from app.features.tasks.definitions.utils import model_to_schema
repo = TaskDefinitionsRepository.get_instance() repo = TaskDefinitionsRepository.get_instance()
models = await repo.all() models = await repo.list()
cls._definitions = [model_to_schema(model) for model in models] cls._definitions = [model_to_schema(model) for model in models]
return cls._definitions return cls._definitions
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error(f"Failed to load task definitions from database: {exc}")
"Failed to load generic task definitions.",
extra={"error": str(exc), "exception_type": type(exc).__name__},
)
return [] return []
@classmethod @classmethod
@ -105,15 +102,7 @@ class GenericTaskHandler(BaseHandler):
if pattern_str and re.match(pattern_str, url): if pattern_str and re.match(pattern_str, url):
return definition return definition
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error(f"Error while matching definition '{definition.name}': {exc}")
"Failed to match a generic task definition.",
extra={
"definition": definition.name,
"url": url,
"error": str(exc),
"exception_type": type(exc).__name__,
},
)
return None return None
@ -131,18 +120,13 @@ class GenericTaskHandler(BaseHandler):
""" """
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url) definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
if definition: if definition:
LOG.debug( LOG.debug(f"'{task.name}': Matched generic task definition '{definition.name}'.")
"Task '%s' matched a generic task definition.",
task.name,
extra={"task_name": task.name, "url": task.url, "definition": definition.name},
)
return True return True
return False return False
@staticmethod @staticmethod
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: # noqa: ARG004
_ = config
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url) definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
if not definition: if not definition:
return TaskFailure(message="No generic task definition matched the provided URL.") return TaskFailure(message="No generic task definition matched the provided URL.")
@ -150,16 +134,7 @@ class GenericTaskHandler(BaseHandler):
ytdlp_opts: dict[str, Any] = task.get_ytdlp_opts().get_all() ytdlp_opts: dict[str, Any] = task.get_ytdlp_opts().get_all()
target_url: str = definition.definition.request.url or task.url target_url: str = definition.definition.request.url or task.url
LOG.debug( LOG.debug(f"{task.name!r}: Fetching '{target_url}' using engine '{definition.definition.engine.type}'.")
"Fetching content for task '%s'.",
task.name,
extra={
"task_name": task.name,
"definition": definition.name,
"url": target_url,
"engine": definition.definition.engine.type,
},
)
try: try:
body_text, json_data = await GenericTaskHandler._fetch_content( body_text, json_data = await GenericTaskHandler._fetch_content(
@ -168,16 +143,7 @@ class GenericTaskHandler(BaseHandler):
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
return TaskFailure(message="Failed to fetch target URL.", error=str(exc)) return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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)) return TaskFailure(message="Failed to fetch target URL.", error=str(exc))
if "json" == definition.definition.response.type and json_data is None: if "json" == definition.definition.response.type and json_data is None:
@ -215,9 +181,7 @@ class GenericTaskHandler(BaseHandler):
continue continue
else: else:
LOG.warning( LOG.warning(
"Task '%s' could not generate a static archive ID. Fetching it with yt-dlp.", 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.name,
extra={"definition": definition.name, "task_name": task.name, "url": url},
) )
(info, _) = await fetch_info( (info, _) = await fetch_info(
@ -230,18 +194,14 @@ class GenericTaskHandler(BaseHandler):
if not info: if not info:
LOG.error( LOG.error(
"Task '%s' failed to extract info to generate an archive ID. Skipping item.", f"[{definition.name}]: '{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping."
task.name,
extra={"definition": definition.name, "task_name": task.name, "url": url},
) )
CACHE.set(cache_key, None) CACHE.set(cache_key, None)
continue continue
if not info.get("id") or not info.get("extractor_key"): if not info.get("id") or not info.get("extractor_key"):
LOG.error( LOG.error(
"Task '%s' returned incomplete info while generating an archive ID. Skipping item.", f"[{definition.name}]: '{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping."
task.name,
extra={"definition": definition.name, "task_name": task.name, "url": url},
) )
CACHE.set(cache_key, None) CACHE.set(cache_key, None)
continue continue
@ -337,17 +297,7 @@ class GenericTaskHandler(BaseHandler):
try: try:
json_data: dict[str, Any] = response.json() json_data: dict[str, Any] = response.json()
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error(f"Failed to decode JSON response from '{url}': {exc}")
"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, None
return response.text, json_data return response.text, json_data
@ -377,11 +327,7 @@ class GenericTaskHandler(BaseHandler):
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
except ImportError as exc: except ImportError as exc:
LOG.exception( LOG.error(f"Selenium engine requested but selenium is not installed: {exc!s}.")
"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) return (None, None)
options_map: dict[str, Any] = definition.definition.engine.options options_map: dict[str, Any] = definition.definition.engine.options
@ -394,12 +340,7 @@ class GenericTaskHandler(BaseHandler):
browser: str = str(options_map.get("browser", "chrome")).lower() browser: str = str(options_map.get("browser", "chrome")).lower()
if "chrome" != browser: if "chrome" != browser:
LOG.error( LOG.error(f"Unsupported selenium browser '{browser}'. Only 'chrome' is supported.")
"Task definition '%s' requested unsupported Selenium browser '%s'.",
definition.name,
browser,
extra={"definition": definition.name, "browser": browser},
)
return (None, None) return (None, None)
arguments: list[str] | str = options_map.get("arguments", ["--headless", "--disable-gpu"]) arguments: list[str] | str = options_map.get("arguments", ["--headless", "--disable-gpu"])
@ -481,9 +422,7 @@ class GenericTaskHandler(BaseHandler):
link_values: list[str] = extracted.get("link", []) link_values: list[str] = extracted.get("link", [])
if not link_values: if not link_values:
LOG.debug( LOG.debug(f"Definition '{definition.name}' produced no link values.")
"Definition '%s' produced no link values.", definition.name, extra={"definition": definition.name}
)
return [] return []
total_items: int = len(link_values) total_items: int = len(link_values)
@ -518,11 +457,7 @@ class GenericTaskHandler(BaseHandler):
base_url: str, base_url: str,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
if json_data is None: if json_data is None:
LOG.debug( LOG.debug(f"Definition '{definition.name}' expects JSON but no data was parsed.")
"Definition '%s' expects JSON but no data was parsed.",
definition.name,
extra={"definition": definition.name},
)
return [] return []
if definition.definition.parse.get("items"): if definition.definition.parse.get("items"):
@ -561,11 +496,7 @@ class GenericTaskHandler(BaseHandler):
container_type = container.get("type", "css") container_type = container.get("type", "css")
container_selector = container.get("selector") or container.get("expression") or "" container_selector = container.get("selector") or container.get("expression") or ""
if not container_selector: if not container_selector:
LOG.error( LOG.error(f"Container missing selector/expression. Definition '{definition.name}'.")
"Task definition '%s' is missing an item container selector.",
definition.name,
extra={"definition": definition.name},
)
return [] return []
container_fields = container.get("fields", {}) container_fields = container.get("fields", {})
@ -619,11 +550,7 @@ class GenericTaskHandler(BaseHandler):
container_fields = container.get("fields", {}) container_fields = container.get("fields", {})
if "jsonpath" != container_type: if "jsonpath" != container_type:
LOG.error( LOG.error(f"JSON response requires container selector type 'jsonpath'. Definition '{definition.name}'.")
"JSON response requires container selector type 'jsonpath'. Definition '%s'.",
definition.name,
extra={"definition": definition.name, "container_type": container_type},
)
return [] return []
nodes: Any = GenericTaskHandler._json_search(json_data, container_selector) nodes: Any = GenericTaskHandler._json_search(json_data, container_selector)
@ -679,16 +606,7 @@ class GenericTaskHandler(BaseHandler):
try: try:
pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL) pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
except re.error as exc: except re.error as exc:
LOG.exception( LOG.error(f"Invalid regex expression '{rule.expression}': {exc}")
"Invalid regex expression '%s'.",
rule.expression,
extra={
"field": field,
"expression": rule.expression,
"error": str(exc),
"exception_type": type(exc).__name__,
},
)
return values return values
for match in pattern.finditer(target): for match in pattern.finditer(target):
@ -699,12 +617,7 @@ class GenericTaskHandler(BaseHandler):
return values return values
LOG.error( LOG.error(f"Unsupported extraction type '{rule.type}' for JSON data in field '{field}'.")
"Unsupported extraction type '%s' for JSON data in field '%s'.",
rule.type,
field,
extra={"field": field, "rule_type": rule.type},
)
return values return values
@staticmethod @staticmethod
@ -712,11 +625,7 @@ class GenericTaskHandler(BaseHandler):
try: try:
return jmespath.search(expression, data) return jmespath.search(expression, data)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error(f"JSONPath search failed for expression '{expression}': {exc}")
"JSONPath search failed for expression '%s'.",
expression,
extra={"expression": expression, "error": str(exc), "exception_type": type(exc).__name__},
)
return None return None
@staticmethod @staticmethod
@ -751,16 +660,7 @@ class GenericTaskHandler(BaseHandler):
try: try:
pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL) pattern: re.Pattern[str] = re.compile(rule.expression, re.MULTILINE | re.DOTALL)
except re.error as exc: except re.error as exc:
LOG.exception( LOG.error(f"Invalid regex expression '{rule.expression}': {exc}")
"Invalid regex expression '%s'.",
rule.expression,
extra={
"field": field,
"expression": rule.expression,
"error": str(exc),
"exception_type": type(exc).__name__,
},
)
return values return values
for match in pattern.finditer(html): for match in pattern.finditer(html):
@ -772,7 +672,7 @@ class GenericTaskHandler(BaseHandler):
return values return values
if "jsonpath" == rule.type: if "jsonpath" == rule.type:
LOG.error("Field '%s' uses 'jsonpath' on a non-JSON response.", field, extra={"field": field}) LOG.error("Extraction type 'jsonpath' is only valid for JSON responses.")
return values return values
selection: SelectorList[Selector] = ( selection: SelectorList[Selector] = (
@ -804,12 +704,7 @@ class GenericTaskHandler(BaseHandler):
try: try:
return match.group(attribute) return match.group(attribute)
except (IndexError, KeyError): except (IndexError, KeyError):
LOG.debug( LOG.debug(f"Regex group '{attribute}' not found in pattern '{match.re.pattern}'.")
"Regex group '%s' not found in pattern '%s'.",
attribute,
match.re.pattern,
extra={"attribute": attribute, "pattern": match.re.pattern},
)
return None return None
if match.groupdict(): if match.groupdict():

View file

@ -1,4 +1,5 @@
import hashlib import hashlib
import logging
import re import re
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
@ -9,15 +10,13 @@ from app.features.tasks.definitions.results import HandleTask, TaskFailure, Task
from app.features.ytdlp.extractor import fetch_info from app.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.utils import get_archive_id from app.features.ytdlp.utils import get_archive_id
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config
from app.library.log import get_logger
from ._base_handler import BaseHandler from ._base_handler import BaseHandler
if TYPE_CHECKING: if TYPE_CHECKING:
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
LOG = get_logger() LOG: logging.Logger = logging.getLogger("handlers.rss")
CACHE: Cache = Cache() CACHE: Cache = Cache()
@ -29,11 +28,7 @@ class RssGenericHandler(BaseHandler):
@staticmethod @staticmethod
async def can_handle(task: HandleTask) -> bool: async def can_handle(task: HandleTask) -> bool:
LOG.debug( LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
"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 return RssGenericHandler.parse(task.url) is not None
@staticmethod @staticmethod
@ -57,11 +52,7 @@ class RssGenericHandler(BaseHandler):
from defusedxml.ElementTree import fromstring from defusedxml.ElementTree import fromstring
feed_url: str = parsed["url"] feed_url: str = parsed["url"]
LOG.debug( LOG.debug(f"'{task.name}': Fetching RSS/Atom feed from {feed_url}")
"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 = await RssGenericHandler.request(url=feed_url, ytdlp_opts=params)
response.raise_for_status() response.raise_for_status()
@ -82,12 +73,7 @@ class RssGenericHandler(BaseHandler):
# Try to parse as Atom feed first # Try to parse as Atom feed first
entries = root.findall("atom:entry", ns) entries = root.findall("atom:entry", ns)
if entries: if entries:
LOG.debug( LOG.debug(f"'{task.name}': Detected Atom feed format with {len(entries)} entries")
"'%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: for entry in entries:
link_elem: Element | None = entry.find("atom:link[@rel='alternate']", ns) link_elem: Element | None = entry.find("atom:link[@rel='alternate']", ns)
if link_elem is None: if link_elem is None:
@ -98,11 +84,7 @@ class RssGenericHandler(BaseHandler):
url = link_elem.get("href", "") url = link_elem.get("href", "")
if not url: if not url:
LOG.warning( LOG.warning(f"'{task.name}': Atom entry missing URL. Skipping.")
"'%s': Atom entry missing URL. Skipping.",
task.name,
extra={"task_name": task.name, "feed_url": feed_url},
)
continue continue
title_elem: Element | None = entry.find("atom:title", ns) title_elem: Element | None = entry.find("atom:title", ns)
@ -116,12 +98,7 @@ class RssGenericHandler(BaseHandler):
else: else:
# Try to parse as RSS feed # Try to parse as RSS feed
rss_items = root.findall(".//item") rss_items = root.findall(".//item")
LOG.debug( LOG.debug(f"'{task.name}': Detected RSS feed format with {len(rss_items)} items")
"'%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: for item in rss_items:
# Try different link element names (link, url, media:content) # Try different link element names (link, url, media:content)
@ -142,11 +119,7 @@ class RssGenericHandler(BaseHandler):
url = enclosure_elem.get("url", "") url = enclosure_elem.get("url", "")
if not url: if not url:
LOG.warning( LOG.warning(f"'{task.name}': RSS item missing URL. Skipping.")
"'%s': RSS item missing URL. Skipping.",
task.name,
extra={"task_name": task.name, "feed_url": feed_url},
)
continue continue
title_elem = item.find("title") title_elem = item.find("title")
@ -161,14 +134,12 @@ class RssGenericHandler(BaseHandler):
return feed_url, items, real_count return feed_url, items, real_count
@staticmethod @staticmethod
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: async def extract(task: HandleTask) -> TaskResult | TaskFailure:
_ = config
""" """
Extract items from an RSS/Atom feed. Extract items from an RSS/Atom feed.
Args: Args:
task (Task): The task containing the feed URL. task (Task): The task containing the feed URL.
config (Config | None): Optional handler configuration.
Returns: Returns:
TaskResult | TaskFailure: Extraction result with parsed items or failure information. TaskResult | TaskFailure: Extraction result with parsed items or failure information.
@ -185,16 +156,7 @@ class RssGenericHandler(BaseHandler):
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc)) return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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)) return TaskFailure(message="Failed to fetch RSS/Atom feed.", error=str(exc))
task_items: list[TaskItem] = [] task_items: list[TaskItem] = []
@ -214,17 +176,12 @@ class RssGenericHandler(BaseHandler):
if CACHE.has(cache_key): if CACHE.has(cache_key):
archive_id = CACHE.get(cache_key) archive_id = CACHE.get(cache_key)
if not archive_id: if not archive_id:
LOG.debug( LOG.debug(f"'{task.name}': Cached failure for URL '{url}'. Skipping.")
"Task '%s' has a cached archive ID lookup failure. Skipping item.",
task.name,
extra={"task_name": task.name, "url": url},
)
continue continue
else: else:
LOG.warning( LOG.warning(
"Task '%s' could not generate a static archive ID. Fetching it with yt-dlp.", f"'{task.name}': Unable to generate static archive ID for '{url}' in feed. "
task.name, "Doing real request to fetch yt-dlp archive ID."
extra={"task_name": task.name, "url": url},
) )
(info, _) = await fetch_info( (info, _) = await fetch_info(
@ -237,18 +194,14 @@ class RssGenericHandler(BaseHandler):
if not info: if not info:
LOG.error( LOG.error(
"Task '%s' failed to extract info to generate an archive ID. Skipping item.", f"'{task.name}': Failed to extract info for URL '{url}' to generate archive ID. Skipping."
task.name,
extra={"task_name": task.name, "url": url},
) )
CACHE.set(cache_key, None) CACHE.set(cache_key, None)
continue continue
if not info.get("id") or not info.get("extractor_key"): if not info.get("id") or not info.get("extractor_key"):
LOG.error( LOG.error(
"Task '%s' returned incomplete info while generating an archive ID. Skipping item.", f"'{task.name}': Incomplete info extracted for URL '{url}' to generate archive ID. Skipping."
task.name,
extra={"task_name": task.name, "url": url},
) )
CACHE.set(cache_key, None) CACHE.set(cache_key, None)
continue continue

View file

@ -1,15 +1,14 @@
import logging
import re import re
import httpx import httpx
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.features.ytdlp.utils import get_archive_id 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 from ._base_handler import BaseHandler
LOG = get_logger() LOG: logging.Logger = logging.getLogger("handlers.tver")
class TverHandler(BaseHandler): class TverHandler(BaseHandler):
@ -25,11 +24,7 @@ class TverHandler(BaseHandler):
@staticmethod @staticmethod
async def can_handle(task: HandleTask) -> bool: async def can_handle(task: HandleTask) -> bool:
LOG.debug( LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
"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 return TverHandler.parse(task.url) is not None
@staticmethod @staticmethod
@ -60,11 +55,7 @@ class TverHandler(BaseHandler):
return {"platform_uid": platform_uid, "platform_token": platform_token} return {"platform_uid": platform_uid, "platform_token": platform_token}
except Exception as exc: except Exception as exc:
LOG.warning( LOG.warning(f"Failed to create tver session: {exc}")
"Failed to create Tver session.",
extra={"error": str(exc), "exception_type": type(exc).__name__},
exc_info=True,
)
return None return None
@staticmethod @staticmethod
@ -92,11 +83,7 @@ class TverHandler(BaseHandler):
feed_url = TverHandler.SERIES_API.format(id=series_id) feed_url = TverHandler.SERIES_API.format(id=series_id)
LOG.debug( LOG.debug(f"Fetching '{task.name}' episodes from tver series {series_id}.")
"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( response = await TverHandler.request(
url=feed_url, url=feed_url,
@ -118,11 +105,7 @@ class TverHandler(BaseHandler):
try: try:
contents = data.get("result", {}).get("contents", []) contents = data.get("result", {}).get("contents", [])
if not contents: if not contents:
LOG.warning( LOG.warning(f"No contents found in tver series response for '{task.name}'.")
"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 return feed_url, items, has_items
season_block = contents[0] if contents else {} season_block = contents[0] if contents else {}
@ -133,13 +116,9 @@ class TverHandler(BaseHandler):
continue continue
content = episode_data.get("content", {}) content = episode_data.get("content", {})
episode_id = content.pop("id", None) episode_id = content.pop("id")
if not episode_id: if not episode_id:
LOG.warning( LOG.warning(f"Episode missing ID in '{task.name}' feed. Skipping.")
"Episode missing ID in '%s' feed. Skipping.",
task.name,
extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url},
)
continue continue
url = f"https://tver.jp/episodes/{episode_id}" url = f"https://tver.jp/episodes/{episode_id}"
@ -150,9 +129,7 @@ class TverHandler(BaseHandler):
archive_id = id_dict.get("archive_id") archive_id = id_dict.get("archive_id")
if not archive_id: if not archive_id:
LOG.warning( LOG.warning(
"Task '%s' could not compute an archive ID for an episode. Skipping item.", f"Could not compute archive ID for episode '{episode_id}' in '{task.name}' feed. Skipping."
task.name,
extra={"task_name": task.name, "series_id": series_id, "episode_id": episode_id, "url": url},
) )
continue continue
@ -162,24 +139,12 @@ class TverHandler(BaseHandler):
) )
except Exception as exc: except Exception as exc:
LOG.warning( LOG.warning(f"Error parsing tver episodes for '{task.name}': {exc}")
"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 return feed_url, items, has_items
@staticmethod @staticmethod
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: async def extract(task: HandleTask) -> TaskResult | TaskFailure:
_ = config
series_id: str | None = TverHandler.parse(task.url) series_id: str | None = TverHandler.parse(task.url)
if not series_id: if not series_id:
return TaskFailure(message="Unrecognized Tver series URL.") return TaskFailure(message="Unrecognized Tver series URL.")
@ -191,17 +156,7 @@ class TverHandler(BaseHandler):
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc)) return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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)) return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc))
task_items: list[TaskItem] = [] task_items: list[TaskItem] = []
@ -210,11 +165,10 @@ class TverHandler(BaseHandler):
if not (url := entry.get("url")): if not (url := entry.get("url")):
continue continue
archive_id: str | None = entry.get("archive_id") archive_id: str = entry.get("archive_id")
metadata = entry.get("metadata", {}) task_items.append(
if not isinstance(metadata, dict): TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=entry.get("metadata", {}))
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}) return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})

View file

@ -1,3 +1,4 @@
import logging
import re import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
@ -6,15 +7,13 @@ import httpx
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.features.ytdlp.utils import get_archive_id 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 from ._base_handler import BaseHandler
if TYPE_CHECKING: if TYPE_CHECKING:
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
LOG = get_logger() LOG: logging.Logger = logging.getLogger("handlers.twitch")
class TwitchHandler(BaseHandler): class TwitchHandler(BaseHandler):
@ -24,11 +23,7 @@ class TwitchHandler(BaseHandler):
@staticmethod @staticmethod
async def can_handle(task: HandleTask) -> bool: async def can_handle(task: HandleTask) -> bool:
LOG.debug( LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
"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 return TwitchHandler.parse(task.url) is not None
@staticmethod @staticmethod
@ -41,7 +36,7 @@ class TwitchHandler(BaseHandler):
feed_url: str = TwitchHandler.FEED.format(handle=handle_name) feed_url: str = TwitchHandler.FEED.format(handle=handle_name)
LOG.debug("Fetching '%s' feed.", task.name, extra={"task_name": task.name, "feed_url": feed_url}) LOG.debug(f"Fetching '{task.name}' feed.")
response = await TwitchHandler.request(url=feed_url, ytdlp_opts=params) response = await TwitchHandler.request(url=feed_url, ytdlp_opts=params)
response.raise_for_status() response.raise_for_status()
@ -53,22 +48,14 @@ class TwitchHandler(BaseHandler):
link_elem: Element[str] | None = entry.find("link") 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 "" url: str = link_elem.text.strip() if link_elem is not None and link_elem.text else ""
if not url: if not url:
LOG.warning( LOG.warning(f"Entry in '{task.name}' feed is missing URL. Skipping entry.")
"Entry in '%s' feed is missing URL. Skipping entry.",
task.name,
extra={"task_name": task.name, "feed_url": feed_url},
)
continue continue
match: re.Match[str] | None = re.search( match: re.Match[str] | None = re.search(
r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url r"^https?://(?:www\.)?twitch\.tv/videos/(?P<id>\d+)(?:[/?].*)?$", url
) )
if not match: if not match:
LOG.warning( LOG.warning(f"URL in '{task.name}' feed does not look like a VOD link: {url}")
"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 continue
vid: str = match.group("id") vid: str = match.group("id")
@ -81,11 +68,7 @@ class TwitchHandler(BaseHandler):
id_dict = get_archive_id(url) id_dict = get_archive_id(url)
archive_id: str | None = id_dict.get("archive_id") archive_id: str | None = id_dict.get("archive_id")
if not archive_id: if not archive_id:
LOG.warning( LOG.warning(f"Could not compute archive ID for video '{vid}' in '{task.name}' feed. Skipping entry.")
"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 continue
items.append({"id": vid, "url": url, "title": title, "archive_id": archive_id}) items.append({"id": vid, "url": url, "title": title, "archive_id": archive_id})
@ -93,8 +76,7 @@ class TwitchHandler(BaseHandler):
return feed_url, items, has_items return feed_url, items, has_items
@staticmethod @staticmethod
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: async def extract(task: HandleTask) -> TaskResult | TaskFailure:
_ = config
handle_name: str | None = TwitchHandler.parse(task.url) handle_name: str | None = TwitchHandler.parse(task.url)
if not handle_name: if not handle_name:
return TaskFailure(message="Unrecognized Twitch channel URL.") return TaskFailure(message="Unrecognized Twitch channel URL.")
@ -106,16 +88,7 @@ class TwitchHandler(BaseHandler):
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc)) return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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)) return TaskFailure(message="Failed to fetch Twitch feed.", error=str(exc))
task_items: list[TaskItem] = [] task_items: list[TaskItem] = []
@ -124,7 +97,7 @@ class TwitchHandler(BaseHandler):
if not (url := entry.get("url")): if not (url := entry.get("url")):
continue continue
archive_id: str | None = entry.get("archive_id") archive_id: str = entry.get("archive_id")
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=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}) return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items})

View file

@ -1,3 +1,4 @@
import logging
import re import re
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
@ -6,15 +7,13 @@ import httpx
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.features.ytdlp.utils import get_archive_id 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 from ._base_handler import BaseHandler
if TYPE_CHECKING: if TYPE_CHECKING:
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
LOG = get_logger() LOG: logging.Logger = logging.getLogger("handlers.youtube")
class YoutubeHandler(BaseHandler): class YoutubeHandler(BaseHandler):
@ -30,11 +29,7 @@ class YoutubeHandler(BaseHandler):
@staticmethod @staticmethod
async def can_handle(task: HandleTask) -> bool: async def can_handle(task: HandleTask) -> bool:
LOG.debug( LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
"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 return YoutubeHandler.parse(task.url) is not None
@staticmethod @staticmethod
@ -54,7 +49,7 @@ class YoutubeHandler(BaseHandler):
from defusedxml.ElementTree import fromstring from defusedxml.ElementTree import fromstring
feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"]) feed_url: str = YoutubeHandler.FEED.format(type=parsed["type"], id=parsed["id"])
LOG.debug("'%s': Fetching feed.", task.name, extra={"task_name": task.name, "feed_url": feed_url}) LOG.debug(f"'{task.name}': Fetching feed.")
response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params) response = await YoutubeHandler.request(url=feed_url, ytdlp_opts=params)
response.raise_for_status() response.raise_for_status()
@ -72,11 +67,7 @@ class YoutubeHandler(BaseHandler):
vid_elem: Element[str] | None = entry.find("yt:videoId", ns) 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 "" vid: str = vid_elem.text if vid_elem is not None and vid_elem.text else ""
if not vid: if not vid:
LOG.warning( LOG.warning(f"'{task.name}': Entry in the feed is missing a video ID. Skipping.")
"'%s': Entry in the feed is missing a video ID. Skipping.",
task.name,
extra={"task_name": task.name, "feed_url": feed_url},
)
continue continue
url: str = f"https://www.youtube.com/watch?v={vid}" url: str = f"https://www.youtube.com/watch?v={vid}"
@ -84,11 +75,7 @@ class YoutubeHandler(BaseHandler):
id_dict: dict[str, str | None] = get_archive_id(url) id_dict: dict[str, str | None] = get_archive_id(url)
archive_id: str | None = id_dict.get("archive_id") archive_id: str | None = id_dict.get("archive_id")
if not archive_id: if not archive_id:
LOG.warning( LOG.warning(f"'{task.name}': Could not compute archive ID for video '{vid}' in feed. Skipping.")
"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 continue
title_elem: Element[str] | None = entry.find("atom:title", ns) title_elem: Element[str] | None = entry.find("atom:title", ns)
@ -104,8 +91,7 @@ class YoutubeHandler(BaseHandler):
return feed_url, items, real_count return feed_url, items, real_count
@staticmethod @staticmethod
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: async def extract(task: HandleTask) -> TaskResult | TaskFailure:
_ = config
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url) parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
if not parsed: if not parsed:
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.") return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
@ -117,16 +103,7 @@ class YoutubeHandler(BaseHandler):
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc)) return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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)) return TaskFailure(message="Failed to fetch YouTube feed.", error=str(exc))
task_items: list[TaskItem] = [] task_items: list[TaskItem] = []
@ -135,7 +112,7 @@ class YoutubeHandler(BaseHandler):
if not (url := entry.get("url")): if not (url := entry.get("url")):
continue continue
archive_id: str | None = entry.get("archive_id") archive_id: str = entry.get("archive_id")
metadata: dict[str, Any] = {"published": entry.get("published")} metadata: dict[str, Any] = {"published": entry.get("published")}
task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata)) task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata))

View file

@ -1,17 +1,17 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.features.core.migration import Migration as FeatureMigration from app.features.core.migration import Migration as FeatureMigration
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from app.features.tasks.definitions.repository import TaskDefinitionsRepository from app.features.tasks.definitions.repository import TaskDefinitionsRepository
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
class Migration(FeatureMigration): class Migration(FeatureMigration):
@ -48,11 +48,7 @@ class Migration(FeatureMigration):
await self._repo.create(normalized) await self._repo.create(normalized)
inserted += 1 inserted += 1
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to insert task definition '%s': %s", normalized.get("name"), exc)
"Failed to insert task definition '%s'.",
normalized.get("name"),
extra={"definition": normalized.get("name"), "exception_type": type(exc).__name__},
)
finally: finally:
await self._move_file(path) await self._move_file(path)
@ -66,21 +62,13 @@ class Migration(FeatureMigration):
try: try:
content = path.read_text(encoding="utf-8") content = path.read_text(encoding="utf-8")
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error("Failed to read task definition '%s': %s", path, exc)
"Failed to read task definition '%s'.",
path,
extra={"path": str(path), "exception_type": type(exc).__name__},
)
return None return None
try: try:
payload = json.loads(content) payload = json.loads(content)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error("Failed to parse JSON for '%s': %s", path, exc)
"Failed to parse JSON for task definition '%s'.",
path,
extra={"path": str(path), "exception_type": type(exc).__name__},
)
return None return None
if not isinstance(payload, dict): if not isinstance(payload, dict):

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@ -9,28 +10,24 @@ from app.features.core.schemas import CEFeature, ConfigEvent
from app.features.tasks.definitions.migration import Migration from app.features.tasks.definitions.migration import Migration
from app.features.tasks.definitions.models import TaskDefinitionModel from app.features.tasks.definitions.models import TaskDefinitionModel
from app.library.Events import Event, EventBus, Events from app.library.Events import Event, EventBus, Events
from app.library.log import get_logger
from app.library.Services import Services from app.library.Services import Services
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import AsyncGenerator
from contextlib import AbstractAsyncContextManager
from sqlalchemy.engine.result import Result from sqlalchemy.engine.result import Result
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.selectable import Select from sqlalchemy.sql.selectable import Select
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] LOG: logging.Logger = logging.getLogger(__name__)
LOG = get_logger()
class TaskDefinitionsRepository(metaclass=Singleton): class TaskDefinitionsRepository(metaclass=Singleton):
def __init__(self, session: SessionFactory | None = None) -> None: def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
self._migrated = False self._migrated = False
self.session: SessionFactory = session or get_session self.session: AsyncGenerator[AsyncSession] = session or get_session
async def run_migrations(self) -> None: async def run_migrations(self) -> None:
if self._migrated: if self._migrated:
@ -54,12 +51,12 @@ class TaskDefinitionsRepository(metaclass=Singleton):
LOG.debug("Refreshing task definitions due to configuration update.") LOG.debug("Refreshing task definitions due to configuration update.")
await GenericTaskHandler.refresh_definitions(force=True) await GenericTaskHandler.refresh_definitions(force=True)
Services.get_instance().add(TaskDefinitionsRepository.__name__, self) Services.get_instance().add(__class__.__name__, self)
EventBus.get_instance().subscribe( EventBus.get_instance().subscribe(
Events.STARTED, handle_event, f"{TaskDefinitionsRepository.__name__}.run_migrations" Events.STARTED, handle_event, f"{__class__.__name__}.run_migrations"
).subscribe(Events.CONFIG_UPDATE, handler, "GenericTaskHandler.refresh_definitions") ).subscribe(Events.CONFIG_UPDATE, handler, "GenericTaskHandler.refresh_definitions")
async def all(self) -> list[TaskDefinitionModel]: async def list(self) -> list[TaskDefinitionModel]:
async with self.session() as session: async with self.session() as session:
result: Result[tuple[TaskDefinitionModel]] = await session.execute( result: Result[tuple[TaskDefinitionModel]] = await session.execute(
select(TaskDefinitionModel).order_by(TaskDefinitionModel.priority.asc(), TaskDefinitionModel.name.asc()) select(TaskDefinitionModel).order_by(TaskDefinitionModel.priority.asc(), TaskDefinitionModel.name.asc())
@ -118,9 +115,9 @@ class TaskDefinitionsRepository(metaclass=Singleton):
async def create(self, payload: dict[str, Any]) -> TaskDefinitionModel: async def create(self, payload: dict[str, Any]) -> TaskDefinitionModel:
async with self.session() as session: async with self.session() as session:
model: TaskDefinitionModel = TaskDefinitionModel(**payload) model: TaskDefinitionModel = TaskDefinitionModel(**payload) if isinstance(payload, dict) else payload
if model.id is not None: if model.id is not None:
model.id = None # ty: ignore model.id = None
if await self.get_by_name(name=model.name) is not None: if await self.get_by_name(name=model.name) is not None:
msg: str = f"Task definition with name '{model.name}' already exists." msg: str = f"Task definition with name '{model.name}' already exists."

View file

@ -48,13 +48,10 @@ class HandleTask(TaskSchema):
if isinstance(ret, tuple): if isinstance(ret, tuple):
return ret return ret
archive_file = ret.get("file") archive_file: Path = ret.get("file")
items = ret.get("items", set()) items: set[str] = 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(archive_items) < 1 or not archive_add(archive_file, archive_items): if len(items) < 1 or not archive_add(archive_file, list(items)):
return (True, "No new items to mark as downloaded.") return (True, "No new items to mark as downloaded.")
return (True, f"Task '{self.name}' items marked as downloaded.") return (True, f"Task '{self.name}' items marked as downloaded.")
@ -73,13 +70,10 @@ class HandleTask(TaskSchema):
if isinstance(ret, tuple): if isinstance(ret, tuple):
return ret return ret
archive_file = ret.get("file") archive_file: Path = ret.get("file")
items = ret.get("items", set()) items: set[str] = 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(archive_items) < 1 or not archive_delete(archive_file, archive_items): if len(items) < 1 or not archive_delete(archive_file, list(items)):
return (True, "No items to remove from archive file.") return (True, "No items to remove from archive file.")
return (True, f"Removed '{self.name}' items from archive file.") return (True, f"Removed '{self.name}' items from archive file.")

View file

@ -1,3 +1,4 @@
import logging
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
@ -15,10 +16,9 @@ from app.features.tasks.definitions.schemas import (
from app.features.tasks.definitions.utils import model_to_schema, schema_to_payload from app.features.tasks.definitions.utils import model_to_schema, schema_to_payload
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.router import route from app.library.router import route
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/tasks/definitions/", "task_definitions") @route("GET", "api/tasks/definitions/", "task_definitions")
@ -95,11 +95,7 @@ async def task_definitions_create(request: Request, encoder: Encoder, notify: Ev
except ValueError as exc: except ValueError as exc:
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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( return web.json_response(
data={"error": "Failed to create task definition."}, data={"error": "Failed to create task definition."},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
@ -148,15 +144,7 @@ async def task_definitions_update(request: Request, encoder: Encoder, notify: Ev
except ValueError as exc: except ValueError as exc:
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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( return web.json_response(
data={"error": "Failed to update task definition."}, data={"error": "Failed to update task definition."},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
@ -214,11 +202,7 @@ async def task_definitions_patch(request: Request, encoder: Encoder, notify: Eve
except ValueError as exc: except ValueError as exc:
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"Failed to patch task definition '%s'.",
identifier,
extra={"definition_id": identifier, "exception_type": type(exc).__name__},
)
return web.json_response( return web.json_response(
data={"error": "Failed to patch task definition."}, data={"error": "Failed to patch task definition."},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
@ -244,11 +228,7 @@ async def task_definitions_delete(request: Request, encoder: Encoder, notify: Ev
except KeyError as exc: except KeyError as exc:
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"Failed to delete task definition '%s'.",
identifier,
extra={"definition_id": identifier, "exception_type": type(exc).__name__},
)
return web.json_response( return web.json_response(
data={"error": "Failed to delete task definition."}, data={"error": "Failed to delete task definition."},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,

View file

@ -3,19 +3,18 @@ from __future__ import annotations
import asyncio import asyncio
import importlib import importlib
import inspect import inspect
import logging
import pkgutil import pkgutil
import random import random
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any 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.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.features.tasks.models import TaskModel from app.features.tasks.models import TaskModel
from app.features.ytdlp.utils import archive_read from app.features.ytdlp.utils import archive_read
from app.library.downloads.queue_manager import DownloadQueue from app.library.downloads.queue_manager import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item, ItemDTO from app.library.ItemDTO import Item, ItemDTO
from app.library.log import get_logger
from app.library.Services import Services from app.library.Services import Services
if TYPE_CHECKING: if TYPE_CHECKING:
@ -23,12 +22,12 @@ if TYPE_CHECKING:
from app.library.config import Config from app.library.config import Config
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
LOG = get_logger() LOG: logging.Logger = logging.getLogger("definitions.service")
class TaskHandle: class TaskHandle:
def __init__(self, scheduler: Scheduler, tasks: TasksRepository, config: Config) -> None: def __init__(self, scheduler: Scheduler, tasks: TasksRepository, config: Config) -> None:
self._handlers: list[type[BaseHandler]] = [] self._handlers: list[type] = []
"The available handlers." "The available handlers."
self._repo: TasksRepository = tasks self._repo: TasksRepository = tasks
"The tasks manager." "The tasks manager."
@ -36,7 +35,7 @@ class TaskHandle:
"The scheduler." "The scheduler."
self._config: Config = config self._config: Config = config
"The configuration." "The configuration."
self._task_name: str = f"{TaskHandle.__name__}._dispatcher" self._task_name: str = f"{__class__.__name__}._dispatcher"
"The task name for the scheduler." "The task name for the scheduler."
self._queued: dict[str, set[str]] = {} self._queued: dict[str, set[str]] = {}
"Queued archive IDs per handler." "Queued archive IDs per handler."
@ -46,11 +45,11 @@ class TaskHandle:
EventBus.get_instance().subscribe( EventBus.get_instance().subscribe(
Events.ITEM_ERROR, Events.ITEM_ERROR,
self._handle_item_error, self._handle_item_error,
f"{TaskHandle.__name__}.item_error", f"{__class__.__name__}.item_error",
) )
def load(self) -> None: def load(self) -> None:
self._handlers: list[type[BaseHandler]] = self._discover() self._handlers: list[type] = self._discover()
timer: str = self._config.tasks_handler_timer timer: str = self._config.tasks_handler_timer
try: try:
@ -59,30 +58,21 @@ class TaskHandle:
CronSim(timer, datetime.now(UTC)) CronSim(timer, datetime.now(UTC))
except Exception as e: except Exception as e:
timer = "15 */1 * * *" timer = "15 */1 * * *"
LOG.error( LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
"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( self._scheduler.add(
timer=timer, timer=timer,
func=lambda: asyncio.create_task(self._dispatcher(), name="task-handler-dispatcher"), func=lambda: asyncio.create_task(self._dispatcher(), name="task-handler-dispatcher"),
id=f"{TaskHandle.__name__}._dispatcher", id=f"{__class__.__name__}._dispatcher",
) )
async def _dispatcher(self): async def _dispatcher(self):
s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []} s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []}
handler_groups: dict[str, list[tuple[HandleTask, type[BaseHandler]]]] = {} handler_groups: dict[str, list[tuple[HandleTask, type]]] = {}
dispatches: list[tuple[HandleTask, type[BaseHandler], asyncio.Task[TaskResult | TaskFailure | None]]] = [] dispatches: list[tuple[HandleTask, type, asyncio.Task[TaskResult | TaskFailure | None]]] = []
tasks: list[TaskModel] = await self._repo.all() tasks: list[TaskModel] = await self._repo.list()
for task_model in tasks: for task_model in tasks:
task: HandleTask = HandleTask.model_validate(task_model) task: HandleTask = HandleTask.model_validate(task_model)
@ -92,16 +82,12 @@ class TaskHandle:
continue continue
if not task.get_ytdlp_opts().get_all().get("download_archive"): if not task.get_ytdlp_opts().get_all().get("download_archive"):
LOG.debug( LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
"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) s["f"].append(task.name)
continue continue
try: try:
handler: type[BaseHandler] | None = await self._find_handler(task) handler: type | None = await self._find_handler(task)
if handler is None: if handler is None:
s["u"].append(task.name) s["u"].append(task.name)
continue continue
@ -111,11 +97,7 @@ class TaskHandle:
handler_groups[handler_name] = [] handler_groups[handler_name] = []
handler_groups[handler_name].append((task, handler)) handler_groups[handler_name].append((task, handler))
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
"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) s["f"].append(task.name)
for tasks_with_handlers in handler_groups.values(): for tasks_with_handlers in handler_groups.values():
@ -132,17 +114,7 @@ class TaskHandle:
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t)) t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
dispatches.append((task, handler, t)) dispatches.append((task, handler, t))
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to dispatch task '{task.name}'. '{e!s}'.")
"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) s["f"].append(task.name)
if dispatches: if dispatches:
@ -158,33 +130,16 @@ class TaskHandle:
continue continue
if result is None: if result is None:
LOG.error( LOG.error(f"Handler '{handler.__name__}' returned no result for task '{task.name}'.")
"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) s["f"].append(task.name)
if len(tasks) > 0: if len(tasks) > 0:
LOG.info( LOG.info(
"Task dispatch finished: %s handled, %s unhandled, %s disabled, %s failed.", f"Tasks handler summary: Handled: {len(s['h'])}, Unhandled: {len(s['u'])}, Disabled: {len(s['d'])}, Failed: {len(s['f'])}."
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( async def _dispatch(self, task: HandleTask, handler: type, delay: float) -> TaskResult | TaskFailure | None:
self, task: HandleTask, handler: type[BaseHandler], delay: float
) -> TaskResult | TaskFailure | None:
""" """
Dispatch a task after a random delay to avoid rate limiting. Dispatch a task after a random delay to avoid rate limiting.
@ -198,12 +153,7 @@ class TaskHandle:
""" """
if delay > 0: if delay > 0:
LOG.debug( LOG.debug(f"Delaying dispatch of task '{task.name}' by {delay:.1f} seconds.")
"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) await asyncio.sleep(delay)
return await self.dispatch(task, handler=handler) return await self.dispatch(task, handler=handler)
@ -212,29 +162,15 @@ class TaskHandle:
return return
if exc := fut.exception(): if exc := fut.exception():
LOG.exception( LOG.error(f"Exception while handling task '{task.name}': {exc}")
"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[BaseHandler] | None: async def _find_handler(self, task: HandleTask) -> type | None:
for cls in self._handlers: for cls in self._handlers:
try: try:
if await Services.get_instance().handle_async(handler=cls.can_handle, task=task): if await Services.get_instance().handle_async(handler=cls.can_handle, task=task):
return cls return cls
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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 continue
return None return None
@ -242,10 +178,9 @@ class TaskHandle:
async def dispatch( async def dispatch(
self, self,
task: HandleTask, task: HandleTask,
handler: type[BaseHandler] | None = None, handler: type | None = None,
**kwargs, **kwargs, # noqa: ARG002
) -> TaskResult | TaskFailure | None: ) -> TaskResult | TaskFailure | None:
_ = kwargs
""" """
Dispatch a task to the appropriate handler. Dispatch a task to the appropriate handler.
@ -269,31 +204,11 @@ class TaskHandle:
extraction: TaskResult | TaskFailure = await services.handle_async( extraction: TaskResult | TaskFailure = await services.handle_async(
handler=handler.extract, task=task, config=self._config handler=handler.extract, task=task, config=self._config
) )
except NotImplementedError as exc: except NotImplementedError:
LOG.exception( LOG.error(f"Handler '{handler.__name__}' does not implement extract().")
"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.") return TaskFailure(message="Handler does not support extraction.")
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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 raise
if isinstance(extraction, TaskFailure): if isinstance(extraction, TaskFailure):
@ -301,27 +216,12 @@ class TaskHandle:
if extraction.error and extraction.error != extraction.message: if extraction.error and extraction.error != extraction.message:
msg = f"{msg} {extraction.error}" msg = f"{msg} {extraction.error}"
LOG.error( LOG.error(f"Handler '{handler.__name__}' failed to extract items for task '{task.name}': {msg}")
"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 return extraction
if not isinstance(extraction, TaskResult): if not isinstance(extraction, TaskResult):
LOG.error( LOG.error(
"Handler '%s' returned unexpected result type '%s' for task '%s'.", f"Handler '{handler.__name__}' returned unexpected result type '{type(extraction).__name__}'.",
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( return TaskFailure(
message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__} message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__}
@ -349,18 +249,7 @@ class TaskHandle:
for item in raw_items: for item in raw_items:
if not isinstance(item, TaskItem): if not isinstance(item, TaskItem):
LOG.warning( LOG.warning(f"Handler '{handler.__name__}' returned unexpected result: {item!r}")
"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 continue
url: str = item.url url: str = item.url
@ -369,13 +258,7 @@ class TaskHandle:
archive_id: str | None = item.archive_id archive_id: str | None = item.archive_id
if not archive_id: if not archive_id:
LOG.warning( LOG.warning(f"'{task.name}': Item with URL '{url}' is missing an archive ID. Skipping.")
"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 continue
if archive_id in queued: if archive_id in queued:
@ -404,31 +287,12 @@ class TaskHandle:
if not filtered: if not filtered:
if raw_items: if raw_items:
LOG.debug( LOG.debug(
"Handler '%s' found %s item(s) for task '%s', but none were queued.", f"Handler '{handler.__name__}' produced '{len(raw_items)}' for '{task.name}' items, none queued after filtering."
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) return TaskResult(items=[], metadata=metadata)
LOG.info( LOG.info(
"Handler '%s' found %s new item(s) for task '%s'.", f"Handler '{handler.__name__}' Found '{len(filtered)}' new items for '{task.name}' (raw={len(raw_items)})."
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( base_item = Item.format(
@ -503,12 +367,7 @@ class TaskHandle:
try: try:
matched = await services.handle_async(handler=handler_cls.can_handle, task=task) matched = await services.handle_async(handler=handler_cls.can_handle, task=task)
except Exception as exc: # pragma: no cover - defensive except Exception as exc: # pragma: no cover - defensive
LOG.exception( LOG.exception(exc)
"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) message = str(exc)
return TaskFailure( return TaskFailure(
message=message, message=message,
@ -546,12 +405,7 @@ class TaskHandle:
metadata={**base_metadata, "supported": False}, metadata={**base_metadata, "supported": False},
) )
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"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) message = str(exc)
return TaskFailure( return TaskFailure(
message=message, message=message,
@ -572,11 +426,7 @@ class TaskHandle:
if not isinstance(extraction, TaskResult): if not isinstance(extraction, TaskResult):
LOG.error( LOG.error(
"Handler '%s' returned unexpected result type '%s' while inspecting '%s'.", f"Handler '{handler_cls.__name__}' returned unexpected result type '{type(extraction).__name__}' during inspection.",
handler_cls.__name__,
type(extraction).__name__,
url,
extra={"handler_name": handler_cls.__name__, "url": url, "result_type": type(extraction).__name__},
) )
extraction = TaskResult() extraction = TaskResult()
@ -586,11 +436,11 @@ class TaskHandle:
return TaskResult(items=list(extraction.items), metadata=combined_metadata) return TaskResult(items=list(extraction.items), metadata=combined_metadata)
def _discover(self) -> list[type[BaseHandler]]: def _discover(self) -> list[type]:
"""Discover all available task handlers.""" """Discover all available task handlers."""
import app.features.tasks.definitions.handlers as handlers_pkg import app.features.tasks.definitions.handlers as handlers_pkg
handlers: list[type[BaseHandler]] = [] handlers: list[type] = []
for _, module_name, _ in pkgutil.iter_modules(handlers_pkg.__path__): for _, module_name, _ in pkgutil.iter_modules(handlers_pkg.__path__):
if module_name.startswith("_"): if module_name.startswith("_"):

View file

@ -8,7 +8,6 @@ from app.features.tasks.definitions.results import TaskFailure, TaskResult
from app.features.tasks.definitions.schemas import ( from app.features.tasks.definitions.schemas import (
Definition, Definition,
EngineConfig, EngineConfig,
Parse,
RequestConfig, RequestConfig,
ResponseConfig, ResponseConfig,
TaskDefinition, TaskDefinition,
@ -31,12 +30,10 @@ def test_build_def_payload():
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "link": {"type": "css", "expression": ".article a.link::attr(href)"},
"link": {"type": "css", "expression": ".article a.link::attr(href)"}, "title": {"type": "css", "expression": ".article .title", "attribute": "text"},
"title": {"type": "css", "expression": ".article .title", "attribute": "text"}, },
}
),
engine=EngineConfig(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(), response=ResponseConfig(),
@ -57,17 +54,15 @@ def test_build_def_container():
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "items": {
"items": { "selector": ".cards .card",
"selector": ".cards .card", "fields": {
"fields": { "link": {"type": "css", "expression": ".card-header a", "attribute": "href"},
"link": {"type": "css", "expression": ".card-header a", "attribute": "href"}, "title": {"type": "css", "expression": ".card-header a", "attribute": "text"},
"title": {"type": "css", "expression": ".card-header a", "attribute": "text"}, },
},
}
} }
), },
engine=EngineConfig(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(), response=ResponseConfig(),
@ -87,18 +82,16 @@ def test_build_def_json():
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "items": {
"items": { "type": "jsonpath",
"type": "jsonpath", "selector": "items",
"selector": "items", "fields": {
"fields": { "link": {"type": "jsonpath", "expression": "url"},
"link": {"type": "jsonpath", "expression": "url"}, "title": {"type": "jsonpath", "expression": "title"},
"title": {"type": "jsonpath", "expression": "title"}, },
},
}
} }
), },
engine=EngineConfig(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(type="json"), response=ResponseConfig(type="json"),
@ -119,13 +112,11 @@ def test_parse_items_basic():
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "link": {"type": "css", "expression": ".article a.link::attr(href)", "attribute": None},
"link": {"type": "css", "expression": ".article a.link::attr(href)", "attribute": None}, "title": {"type": "css", "expression": ".article .title", "attribute": "text"},
"title": {"type": "css", "expression": ".article .title", "attribute": "text"}, "id": {"type": "css", "expression": ".article", "attribute": "data-id"},
"id": {"type": "css", "expression": ".article", "attribute": "data-id"}, },
}
),
engine=EngineConfig(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(), response=ResponseConfig(),
@ -163,36 +154,34 @@ def test_parse_items_cards():
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "items": {
"items": { "type": "css",
"type": "css", "selector": ".columns .card",
"selector": ".columns .card", "fields": {
"fields": { "link": {
"link": { "type": "css",
"type": "css", "expression": ".card-header a[href]",
"expression": ".card-header a[href]", "attribute": "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(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(), response=ResponseConfig(),
@ -260,19 +249,17 @@ def test_parse_items_json():
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "items": {
"items": { "type": "jsonpath",
"type": "jsonpath", "selector": "entries",
"selector": "entries", "fields": {
"fields": { "link": {"type": "jsonpath", "expression": "url"},
"link": {"type": "jsonpath", "expression": "url"}, "title": {"type": "jsonpath", "expression": "title"},
"title": {"type": "jsonpath", "expression": "title"}, "id": {"type": "jsonpath", "expression": "id"},
"id": {"type": "jsonpath", "expression": "id"}, },
},
}
} }
), },
engine=EngineConfig(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(type="json"), response=ResponseConfig(type="json"),
@ -310,18 +297,16 @@ async def test_generic_task_handler_inspect(monkeypatch):
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "items": {
"items": { "type": "jsonpath",
"type": "jsonpath", "selector": "items",
"selector": "items", "fields": {
"fields": { "link": {"type": "jsonpath", "expression": "url"},
"link": {"type": "jsonpath", "expression": "url"}, "title": {"type": "jsonpath", "expression": "title"},
"title": {"type": "jsonpath", "expression": "title"}, },
},
}
} }
), },
engine=EngineConfig(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(type="json"), response=ResponseConfig(type="json"),
@ -366,18 +351,16 @@ def test_parse_items_json_list():
created_at=datetime.now(), created_at=datetime.now(),
updated_at=datetime.now(), updated_at=datetime.now(),
definition=Definition( definition=Definition(
parse=Parse.model_validate( parse={
{ "items": {
"items": { "type": "jsonpath",
"type": "jsonpath", "selector": "[]",
"selector": "[]", "fields": {
"fields": { "link": {"type": "jsonpath", "expression": "url"},
"link": {"type": "jsonpath", "expression": "url"}, "title": {"type": "jsonpath", "expression": "title"},
"title": {"type": "jsonpath", "expression": "title"}, },
},
}
} }
), },
engine=EngineConfig(), engine=EngineConfig(),
request=RequestConfig(), request=RequestConfig(),
response=ResponseConfig(type="json"), response=ResponseConfig(type="json"),

View file

@ -69,7 +69,7 @@ class TestTaskDefinitionsRepository:
await repo.create(_sample_definition("Alpha", priority=2)) await repo.create(_sample_definition("Alpha", priority=2))
await repo.create(_sample_definition("Beta", priority=1)) await repo.create(_sample_definition("Beta", priority=1))
items = await repo.all() items = await repo.list()
assert len(items) == 2, "Should return two task definitions" assert len(items) == 2, "Should return two task definitions"
assert [item.name for item in items] == ["Beta", "Alpha"], "Should sort by priority then name" assert [item.name for item in items] == ["Beta", "Alpha"], "Should sort by priority then name"

View file

@ -1,17 +1,9 @@
from typing import Any, Literal, overload from typing import Any
from app.features.tasks.definitions.models import TaskDefinitionModel from app.features.tasks.definitions.models import TaskDefinitionModel
from app.features.tasks.definitions.schemas import Definition, TaskDefinition, TaskDefinitionSummary 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: def model_to_schema(model: TaskDefinitionModel, summary: bool = False) -> TaskDefinition | TaskDefinitionSummary:
""" """
Convert a TaskDefinitionModel to a TaskDefinition or TaskDefinitionSummary schema. Convert a TaskDefinitionModel to a TaskDefinition or TaskDefinitionSummary schema.

View file

@ -1,18 +1,18 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.features.core.migration import Migration as FeatureMigration from app.features.core.migration import Migration as FeatureMigration
from app.features.tasks.schemas import Task from app.features.tasks.schemas import Task
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from app.features.tasks.repository import TasksRepository from app.features.tasks.repository import TasksRepository
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
class Migration(FeatureMigration): class Migration(FeatureMigration):
@ -36,11 +36,7 @@ class Migration(FeatureMigration):
try: try:
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text()) items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
"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) await self._move_file(self._source_file)
return return
@ -58,11 +54,7 @@ class Migration(FeatureMigration):
await self._repo.create(normalized) await self._repo.create(normalized)
inserted += 1 inserted += 1
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception("Failed to insert task '%s': %s", normalized["name"], exc)
"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) LOG.info("Migrated %s task(s) from %s.", inserted, self._source_file)
await self._move_file(self._source_file) await self._move_file(self._source_file)

View file

@ -1,48 +1,29 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from app.features.core.deps import get_session from app.features.core.deps import get_session
from app.features.tasks.models import TaskModel from app.features.tasks.models import TaskModel
from app.library.log import get_logger
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import AsyncGenerator
from contextlib import AbstractAsyncContextManager
from sqlalchemy.engine.result import Result from sqlalchemy.engine.result import Result
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.selectable import Select from sqlalchemy.sql.selectable import Select
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] LOG: logging.Logger = logging.getLogger(__name__)
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): class TasksRepository(metaclass=Singleton):
def __init__(self, session: SessionFactory | None = None) -> None: def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
self._migrated = False self._migrated = False
self.session: SessionFactory = session or get_session self.session = session or get_session
async def run_migrations(self) -> None: async def run_migrations(self) -> None:
if self._migrated: if self._migrated:
@ -57,7 +38,7 @@ class TasksRepository(metaclass=Singleton):
def get_instance() -> TasksRepository: def get_instance() -> TasksRepository:
return TasksRepository() return TasksRepository()
async def all(self) -> list[TaskModel]: async def list(self) -> list[TaskModel]:
async with self.session() as session: async with self.session() as session:
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).order_by(TaskModel.name.asc())) result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).order_by(TaskModel.name.asc()))
return list(result.scalars().all()) return list(result.scalars().all())
@ -109,7 +90,7 @@ class TasksRepository(metaclass=Singleton):
"""Get all enabled tasks.""" """Get all enabled tasks."""
async with self.session() as session: async with self.session() as session:
result: Result[tuple[TaskModel]] = await session.execute( result: Result[tuple[TaskModel]] = await session.execute(
select(TaskModel).where(TaskModel.enabled).order_by(TaskModel.name.asc()) select(TaskModel).where(TaskModel.enabled == True).order_by(TaskModel.name.asc()) # noqa: E712
) )
return list(result.scalars().all()) return list(result.scalars().all())
@ -121,11 +102,11 @@ class TasksRepository(metaclass=Singleton):
) )
return list(result.scalars().all()) return list(result.scalars().all())
async def create(self, payload: TaskModel | dict[str, Any]) -> TaskModel: async def create(self, payload: TaskModel | dict) -> TaskModel:
async with self.session() as session: async with self.session() as session:
model = _coerce_model(payload) model: TaskModel = TaskModel(**payload) if isinstance(payload, dict) else payload
if model.id is not None: if model.id is not None:
model.id = None # ty: ignore model.id = None
if await self.get_by_name(name=model.name) is not None: if await self.get_by_name(name=model.name) is not None:
msg: str = f"Task with name '{model.name}' already exists." msg: str = f"Task with name '{model.name}' already exists."

View file

@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from aiohttp import web from aiohttp import web
@ -17,7 +18,6 @@ from app.library.ag_utils import ag
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.router import route from app.library.router import route
from app.library.Utils import get_channel_images, get_file, validate_url from app.library.Utils import get_channel_images, get_file, validate_url
@ -25,7 +25,7 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
TIMER_SLOTS_PER_HOUR: int = 12 TIMER_SLOTS_PER_HOUR: int = 12
@ -94,11 +94,7 @@ def _offset_timer(timer: str, index: int) -> str:
return f"{minute} {hour} {dom} {month} {dow}" return f"{minute} {hour} {dom} {month} {dow}"
except Exception as e: except Exception as e:
LOG.warning( LOG.warning(f"Failed to offset timer '{timer}': {e}")
"Failed to offset task timer.",
extra={"timer": timer, "index": index, "error": str(e), "exception_type": type(e).__name__},
exc_info=True,
)
return timer return timer
@ -141,15 +137,10 @@ async def _get_info(url: str, preset: str) -> tuple[str | None, str | None]:
return (name, converted_url) return (name, converted_url)
except TimeoutError: except TimeoutError:
LOG.debug("Timeout while inferring name from '%s'.", url, extra={"url": url, "preset": preset}) LOG.debug(f"Timeout while inferring name from '{url}'")
return (None, None) return (None, None)
except Exception as e: except Exception as e:
LOG.debug( LOG.debug(f"Failed to infer name from '{url}': {e}")
"Failed to infer a task name from '%s' because %s.",
url,
e,
extra={"url": url, "preset": preset, "error": str(e)},
)
return (None, None) return (None, None)
@ -243,12 +234,12 @@ async def tasks_add(
for idx, item in enumerate(data): for idx, item in enumerate(data):
if not isinstance(item, dict): if not isinstance(item, dict):
LOG.warning("Skipping item %s: not a dict.", idx, extra={"index": idx}) LOG.warning(f"Skipping item {idx}: not a dict")
continue continue
url = str(item.get("url", "")).strip() url = str(item.get("url", "")).strip()
if not url: if not url:
LOG.debug("Skipping item %s: empty URL.", idx, extra={"index": idx}) LOG.debug(f"Skipping item {idx}: empty URL")
continue continue
inferred_name, converted_url = await _get_info(url, item.get("preset", first_task.preset)) inferred_name, converted_url = await _get_info(url, item.get("preset", first_task.preset))
@ -302,11 +293,7 @@ async def tasks_add(
data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved), data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved),
) )
except ValueError as exc: except ValueError as exc:
LOG.warning( LOG.warning(f"Failed to create task {idx}: {exc}")
"Failed to create task from request item %s.",
idx,
extra={"index": idx, "error": str(exc), "exception_type": type(exc).__name__},
)
continue continue
if len(created_tasks) == 0: if len(created_tasks) == 0:
@ -495,16 +482,7 @@ async def task_handler_inspect(request: Request, handler: TaskHandle, encoder: E
url=url, preset=preset, handler_name=handler_name, static_only=static_only url=url, preset=preset, handler_name=handler_name, static_only=static_only
) )
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( return web.json_response(
{"error": "Failed to inspect handler.", "message": str(e)}, {"error": "Failed to inspect handler.", "message": str(e)},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
@ -653,12 +631,7 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
if not save_path.exists(): if not save_path.exists():
save_path.mkdir(parents=True, exist_ok=True) save_path.mkdir(parents=True, exist_ok=True)
except Exception as e: except Exception as e:
LOG.warning( LOG.warning(f"Failed to resolve final path from outtmpl. '{e!s}'")
"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 = { info = {
"id": ag(metadata, ["id", "channel_id"]), "id": ag(metadata, ["id", "channel_id"]),
@ -676,12 +649,7 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code
) )
LOG.info( LOG.info(f"Generating metadata for task '{task.name}' in '{save_path!s}'")
"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 from yt_dlp.utils import sanitize_filename
@ -705,9 +673,8 @@ 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' 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"): if info.get("uploader"):
xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n" xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n"
tags = info.get("tags", []) if info.get("tags", []):
if isinstance(tags, list): for tag in info.get("tags", []):
for tag in tags:
xml_content += f" <tag>{NFOMakerPP._escape_text(tag)}</tag>\n" xml_content += f" <tag>{NFOMakerPP._escape_text(tag)}</tag>\n"
if info.get("year"): if info.get("year"):
xml_content += f" <year>{info.get('year')}</year>\n" xml_content += f" <year>{info.get('year')}</year>\n"
@ -739,25 +706,14 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
url: str | None = None url: str | None = None
try: try:
url = thumbnails.get(key) url = thumbnails.get(key)
LOG.info( LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
"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: if not url:
continue continue
try: try:
await asyncio.to_thread(validate_url, url, config.allow_internal_urls) await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
except ValueError as exc: except ValueError:
LOG.warning( LOG.warning(f"Invalid thumbnail url '{url}'")
"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 continue
resp = await client.request( resp = await client.request(
@ -771,27 +727,11 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
img_file.write_bytes(resp.content) img_file.write_bytes(resp.content)
thumbnails[key] = str(img_file.relative_to(config.download_path)) thumbnails[key] = str(img_file.relative_to(config.download_path))
except Exception as e: except Exception as e:
LOG.warning( url_log = url or "unknown"
"Failed to fetch '%s' thumbnail for task '%s'.", LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url_log}'. '{e!s}'")
key,
task.name,
extra={
"task_id": task.id,
"task_name": task.name,
"thumbnail": key,
"url": url,
"error": str(e),
},
exc_info=True,
)
continue continue
except Exception as e: except Exception as e:
LOG.warning( LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")
"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) return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
except ValueError as e: except ValueError as e:

View file

@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent from app.features.core.schemas import CEAction, CEFeature, ConfigEvent
from app.features.tasks.models import TaskModel from app.features.tasks.models import TaskModel
from app.features.tasks.utils import cron_time from app.features.tasks.utils import cron_time
from app.library.Events import Event, EventBus, Events from app.library.Events import Event, EventBus, Events
from app.library.log import get_logger
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
from app.library.Services import Services from app.library.Services import Services
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
@ -14,7 +14,7 @@ from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from aiohttp import web from aiohttp import web
LOG = get_logger() LOG: logging.Logger = logging.getLogger("tasks.service")
class Tasks(metaclass=Singleton): class Tasks(metaclass=Singleton):
@ -51,7 +51,7 @@ class Tasks(metaclass=Singleton):
pass pass
async def _load_tasks(self) -> None: async def _load_tasks(self) -> None:
tasks = await self._repo.all() tasks = await self._repo.list()
for task in tasks: for task in tasks:
if not task.timer or not task.enabled: if not task.timer or not task.enabled:
@ -59,23 +59,10 @@ class Tasks(metaclass=Singleton):
try: try:
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=f"task-cronjob-{task.id}") self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=f"task-cronjob-{task.id}")
LOG.info( LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
"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: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to queue task '%s'.", LOG.error(f"Failed to queue task '{task.name}'. '{e!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: async def _init_handlers_service(self, scheduler) -> None:
"""Initialize the handlers service after migrations.""" """Initialize the handlers service after migrations."""
@ -108,12 +95,7 @@ class Tasks(metaclass=Singleton):
if task.timer and task.enabled: if task.timer and task.enabled:
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task_id) self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task_id)
LOG.info( LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
"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: async def _runner(self, task: TaskModel) -> None:
""" """
@ -131,29 +113,17 @@ class Tasks(metaclass=Singleton):
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
timeNow: str = datetime.now(UTC).isoformat() timeNow: str = datetime.now(UTC).isoformat()
task_id = task.id
task_name = task.name
try: try:
current_task = await self._repo.get(task_id) if not (task := await self._repo.get(task.id)):
if not current_task: LOG.info(f"Task '{task.name}' no longer exists.")
LOG.info("Task '%s' no longer exists.", task_name, extra={"task_id": task_id, "task_name": task_name})
return return
task = current_task
if not task.enabled: if not task.enabled:
LOG.debug( LOG.debug(f"Task '{task.name}' is disabled. Skipping execution.")
"Task '%s' is disabled. Skipping execution.",
task.name,
extra={"task_id": task.id, "task_name": task.name},
)
return return
if not task.url: if not task.url:
LOG.error( LOG.error(f"Failed to dispatch '{task.name}'. No URL found.")
"Failed to dispatch task '%s' because it has no URL.",
task.name,
extra={"task_id": task.id, "task_name": task.name},
)
return return
started: float = time.time() started: float = time.time()
@ -186,19 +156,7 @@ class Tasks(metaclass=Singleton):
timeNow = datetime.now(UTC).isoformat() timeNow = datetime.now(UTC).isoformat()
ended: float = time.time() ended: float = time.time()
LOG.info( LOG.info(f"Task '{task.name}' completed at '{timeNow}' took '{ended - started:.2f}' seconds.")
"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( notify.emit(
Events.TASK_DISPATCHED, Events.TASK_DISPATCHED,
@ -213,17 +171,7 @@ class Tasks(metaclass=Singleton):
message=f"Task '{task.name}' completed in '{ended - started:.2f}'.", message=f"Task '{task.name}' completed in '{ended - started:.2f}'.",
) )
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
"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( EventBus.get_instance().emit(
Events.LOG_ERROR, Events.LOG_ERROR,
data={"preset": task.preset}, data={"preset": task.preset},

View file

@ -260,7 +260,7 @@ class TestTasksRepository:
await repo.create({"name": "Alice", "url": "https://example.com"}) await repo.create({"name": "Alice", "url": "https://example.com"})
await repo.create({"name": "Bob", "url": "https://example.com"}) await repo.create({"name": "Bob", "url": "https://example.com"})
items = await repo.all() items = await repo.list()
assert items[0].name == "Alice", "Should be alphabetically first" assert items[0].name == "Alice", "Should be alphabetically first"
assert items[1].name == "Bob", "Should be alphabetically second" assert items[1].name == "Bob", "Should be alphabetically second"

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any
import pytest import pytest
import pytest_asyncio import pytest_asyncio
@ -42,13 +41,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: def _json_request(method: str, path: str, payload: object, *, match_info: dict[str, str] | None = None) -> web.Request:
mock_request: Any = make_mocked_request(method, path, match_info=match_info or {}, app=SimpleNamespace()) request = make_mocked_request(method, path, match_info=match_info or {}, app=SimpleNamespace())
async def _json() -> object: async def _json() -> object:
return payload return payload
mock_request.json = _json request.json = _json # type: ignore[attr-defined]
return mock_request return request
class _Notify: class _Notify:
@ -86,7 +85,7 @@ async def test_add_requires_timer_without_handler(repo) -> None:
assert response.status == web.HTTPBadRequest.status_code assert response.status == web.HTTPBadRequest.status_code
assert b"requires a timer" in response.body assert b"requires a timer" in response.body
assert await repo.all() == [] assert await repo.list() == []
@pytest.mark.asyncio @pytest.mark.asyncio
@ -110,7 +109,7 @@ async def test_add_all_or_nothing(repo) -> None:
assert response.status == web.HTTPBadRequest.status_code assert response.status == web.HTTPBadRequest.status_code
assert b"requires a timer" in response.body assert b"requires a timer" in response.body
assert await repo.all() == [] assert await repo.list() == []
@pytest.mark.asyncio @pytest.mark.asyncio
@ -124,7 +123,7 @@ async def test_add_allows_handler_only(repo) -> None:
response = await router.tasks_add(request, repo, Encoder(), _Notify(), _Handler(matched=True)) response = await router.tasks_add(request, repo, Encoder(), _Notify(), _Handler(matched=True))
assert response.status == web.HTTPOk.status_code assert response.status == web.HTTPOk.status_code
items = await repo.all() items = await repo.list()
assert len(items) == 1 assert len(items) == 1
assert items[0].name == "Handler Only" assert items[0].name == "Handler Only"

View file

@ -1,6 +1,6 @@
from app.library.log import get_logger import logging
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
def cron_time(timer: str) -> str: def cron_time(timer: str) -> str:
@ -12,9 +12,5 @@ def cron_time(timer: str) -> str:
cs = CronSim(timer, datetime.now(UTC)) cs = CronSim(timer, datetime.now(UTC))
return cs.explain() return cs.explain()
except Exception as exc: except Exception as exc:
LOG.exception( LOG.exception(exc)
"Failed to explain task timer '%s'.",
timer,
extra={"timer": timer, "exception_type": type(exc).__name__},
)
return timer return timer

View file

@ -1,12 +1,12 @@
import logging
import os import os
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from app.library.log import get_logger
from app.library.Singleton import ThreadSafe from app.library.Singleton import ThreadSafe
LOG = get_logger() LOG: logging.Logger = logging.getLogger("Archiver")
class _Entry: class _Entry:
@ -169,21 +169,12 @@ class Archiver(metaclass=ThreadSafe):
continue continue
ids.add(s) ids.add(s)
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed to read archive file '{key}': {e!s}")
"Failed to read archive file '%s'.",
key,
extra={"archive_file": key, "operation": "read", "exception_type": type(e).__name__},
)
ids = set() ids = set()
try: try:
elapsed_ms: float = (time.perf_counter() - start) * 1000.0 elapsed_ms: float = (time.perf_counter() - start) * 1000.0
LOG.debug( LOG.debug(f"_ensure_loaded took {elapsed_ms:.2f}ms (loaded={len(ids)})")
"_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: except Exception:
pass pass
@ -292,17 +283,7 @@ class Archiver(metaclass=ThreadSafe):
f.write("".join(f"{x}\n" for x in new_ids)) f.write("".join(f"{x}\n" for x in new_ids))
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed to write to archive file '{key}': {e!s}")
"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 return False
entry.ids.update(new_ids) entry.ids.update(new_ids)
@ -354,17 +335,7 @@ class Archiver(metaclass=ThreadSafe):
continue continue
kept_lines.append(line) kept_lines.append(line)
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed reading archive for delete '{key}': {e!s}")
"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 return False
if not changed: if not changed:
@ -380,17 +351,7 @@ class Archiver(metaclass=ThreadSafe):
with path.open("w", encoding="utf-8") as f: with path.open("w", encoding="utf-8") as f:
f.writelines(kept_lines) f.writelines(kept_lines)
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed writing archive after delete '{key}': {e!s}")
"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 return False
if entry.loaded: if entry.loaded:

View file

@ -12,43 +12,25 @@ from aiohttp import web
from app.features.ytdlp.utils import _DATA, LogWrapper, get_archive_id from app.features.ytdlp.utils import _DATA, LogWrapper, get_archive_id
from app.features.ytdlp.ytdlp import YTDLP from app.features.ytdlp.ytdlp import YTDLP
from app.library.log import get_logger
from app.library.Services import Services from app.library.Services import Services
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
LOG = get_logger() LOG: logging.Logger = logging.getLogger("downloads.extractor")
LIVE_REEXTRACT_STATUSES: set[str] = {"is_live", "post_live"}
REEXTRACT_INFO_KEY = "_ytptube_reextract"
def _ytdlp_logger(target: logging.Logger): def _ytdlp_logger(target: logging.Logger):
return _YTDLPLogger(target) def _log(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
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] "): if level <= logging.DEBUG and isinstance(msg, str) and msg.startswith("[debug] "):
self.target.debug(msg.removeprefix("[debug] "), *args, **kwargs) target.debug(msg.removeprefix("[debug] "), *args, **kwargs)
return return
if level <= logging.DEBUG: if level <= logging.DEBUG:
self.target.info(msg, *args, **kwargs) target.info(msg, *args, **kwargs)
return return
self.target.log(level, msg, *args, **kwargs) 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]: def _get_process_pool_kwargs() -> dict[str, Any]:
@ -120,7 +102,7 @@ class ExtractorPool(metaclass=Singleton):
def attach(self, app: web.Application) -> None: def attach(self, app: web.Application) -> None:
"""Attach the extractor pool to the application (no-op for now).""" """Attach the extractor pool to the application (no-op for now)."""
app.on_shutdown.append(self.shutdown) app.on_shutdown.append(self.shutdown)
Services.get_instance().add(type(self).__name__, self) Services.get_instance().add(__class__.__name__, self)
def _ensure_initialized(self, config: ExtractorConfig) -> None: def _ensure_initialized(self, config: ExtractorConfig) -> None:
""" """
@ -181,10 +163,7 @@ class ExtractorPool(metaclass=Singleton):
self._pool.shutdown(wait=False, cancel_futures=False) self._pool.shutdown(wait=False, cancel_futures=False)
LOG.debug("Extractor process pool shutdown complete") LOG.debug("Extractor process pool shutdown complete")
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error("Error shutting down extractor process pool: %s", exc)
"Failed to shut down the extractor process pool.",
extra={"exception_type": type(exc).__name__},
)
else: else:
self._pool = None self._pool = None
@ -254,23 +233,6 @@ def _sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
return sanitized 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( def extract_info_sync(
config: dict[str, Any], config: dict[str, Any],
url: str, url: str,
@ -279,9 +241,8 @@ def extract_info_sync(
follow_redirect: bool = False, follow_redirect: bool = False,
sanitize_info: bool = False, sanitize_info: bool = False,
capture_logs: int | None = None, capture_logs: int | None = None,
process_safe: bool = False,
**kwargs, **kwargs,
) -> tuple[dict[str, Any] | None, list[str]]: ) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
""" """
Extract video information from a URL. Extract video information from a URL.
@ -293,11 +254,10 @@ def extract_info_sync(
follow_redirect: Follow URL redirects follow_redirect: Follow URL redirects
sanitize_info: Sanitize the extracted information sanitize_info: Sanitize the extracted information
capture_logs: If provided (e.g., logging.WARNING), capture logs at this level. 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 **kwargs: Additional arguments
Returns: Returns:
tuple[dict | None, list[str]]: Extracted information and captured logs. tuple[dict | None, list[dict]]: Extracted information and captured logs.
""" """
params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True} params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True}
@ -326,7 +286,7 @@ def extract_info_sync(
captured_logs: list[str] = kwargs.get("captured_logs", []) captured_logs: list[str] = kwargs.get("captured_logs", [])
if capture_logs is not None: if capture_logs is not None:
log_wrapper.add_target( log_wrapper.add_target(
target=_LogCapture(captured_logs), target=lambda _, msg: captured_logs.append(msg),
level=capture_logs, level=capture_logs,
name="log-capture", name="log-capture",
) )
@ -356,7 +316,6 @@ def extract_info_sync(
follow_redirect=follow_redirect, follow_redirect=follow_redirect,
sanitize_info=sanitize_info, sanitize_info=sanitize_info,
capture_logs=capture_logs, capture_logs=capture_logs,
process_safe=process_safe,
captured_logs=captured_logs, captured_logs=captured_logs,
**kwargs, **kwargs,
) )
@ -375,9 +334,6 @@ def extract_info_sync(
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data 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) return (result, captured_logs)
finally: finally:
logging.Logger.manager.loggerDict.pop(logger_name, None) logging.Logger.manager.loggerDict.pop(logger_name, None)
@ -394,7 +350,7 @@ async def fetch_info(
extractor_config: ExtractorConfig | None = None, extractor_config: ExtractorConfig | None = None,
budget_sleep: bool = False, budget_sleep: bool = False,
**kwargs, **kwargs,
) -> tuple[dict[str, Any] | None, list[str]]: ) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
""" """
Extract video information from a URL. Extract video information from a URL.
@ -414,7 +370,7 @@ async def fetch_info(
**kwargs: Additional arguments **kwargs: Additional arguments
Returns: Returns:
tuple[dict | None, list[str]]: Extracted information and captured logs. tuple[dict | None, list[dict]]: Extracted information and captured logs.
""" """
if extractor_config is None: if extractor_config is None:
@ -434,8 +390,6 @@ async def fetch_info(
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
safe_config = _sanitize_config(config) 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) timeout = _sleep_timeout(safe_config, extractor_config.timeout, budget_sleep)
try: try:
@ -454,8 +408,7 @@ async def fetch_info(
follow_redirect=follow_redirect, follow_redirect=follow_redirect,
sanitize_info=sanitize_info, sanitize_info=sanitize_info,
capture_logs=capture_logs, capture_logs=capture_logs,
process_safe=True, **kwargs,
**safe_kwargs,
), ),
), ),
timeout=timeout, timeout=timeout,
@ -465,23 +418,8 @@ async def fetch_info(
raise raise
except Exception as exc: except Exception as exc:
LOG.warning( LOG.exception(exc)
"yt-dlp extraction for '%s' fell back to the thread pool after the process pool failed.", LOG.warning("extract_info process pool failed, falling back to thread pool url=%s error=%s", url, exc)
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( return await asyncio.wait_for(
fut=loop.run_in_executor( fut=loop.run_in_executor(
None, None,

View file

@ -1,10 +1,9 @@
import logging
import subprocess import subprocess
import sys import sys
from typing import Any from typing import Any
from app.library.log import get_logger LOG: logging.Logger = logging.getLogger("ytdlp.utils")
LOG = get_logger()
def patch_metadataparser() -> None: def patch_metadataparser() -> None:
@ -15,12 +14,7 @@ def patch_metadataparser() -> None:
from yt_dlp.postprocessor.metadataparser import MetadataParserPP from yt_dlp.postprocessor.metadataparser import MetadataParserPP
from yt_dlp.utils import Namespace from yt_dlp.utils import Namespace
except Exception as exc: except Exception as exc:
LOG.warning( LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
"Unable to import yt-dlp metadata parser for patching: %s",
exc,
extra={"patch": "metadata_parser", "exception_type": type(exc).__name__},
exc_info=True,
)
return return
if getattr(MetadataParserPP.Actions, "_ytptube_patched", False): if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
@ -67,12 +61,7 @@ def patch_windows_popen_wait() -> None:
try: try:
from yt_dlp.utils import Popen from yt_dlp.utils import Popen
except Exception as exc: except Exception as exc:
LOG.warning( LOG.warning(f"Unable to import yt_dlp Popen for patching: {exc!s}")
"Unable to import yt-dlp Popen for patching: %s",
exc,
extra={"patch": "windows_popen_wait", "exception_type": type(exc).__name__},
exc_info=True,
)
return return
if getattr(Popen, "_ytptube_wait_patched", False): if getattr(Popen, "_ytptube_wait_patched", False):

View file

@ -18,11 +18,10 @@ from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
from app.library.log import get_logger
from app.library.router import route from app.library.router import route
from app.library.Utils import validate_url from app.library.Utils import validate_url
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
def _get_preset_archive(preset: str) -> str | None: def _get_preset_archive(preset: str) -> str | None:
@ -38,12 +37,7 @@ def _get_preset_archive(preset: str) -> str | None:
try: try:
opts: dict = YTDLPOpts.get_instance().preset(preset).get_all() opts: dict = YTDLPOpts.get_instance().preset(preset).get_all()
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to build yt-dlp opts for preset '{preset}'. {e!s}")
"Failed to build yt-dlp options for preset '%s': %s.",
preset,
e,
extra={"preset": preset, "exception_type": type(e).__name__},
)
return None return None
if not (archive_file := opts.get("download_archive")): if not (archive_file := opts.get("download_archive")):
@ -131,19 +125,7 @@ async def archiver_get(request: Request) -> Response:
data={"file": archive_file, "items": data, "count": len(data)}, status=web.HTTPOk.status_code data={"file": archive_file, "items": data, "count": len(data)}, status=web.HTTPOk.status_code
) )
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( return web.json_response(
data={"error": f"Failed to read archive file for preset '{preset}'.", "message": str(e)}, data={"error": f"Failed to read archive file for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
@ -189,21 +171,7 @@ async def archiver_add(request: Request) -> Response:
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code, status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
) )
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( return web.json_response(
data={"error": f"Failed to add items to archive for preset '{preset}'.", "message": str(e)}, data={"error": f"Failed to add items to archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
@ -247,20 +215,7 @@ async def archiver_delete(request: Request) -> Response:
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code, status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
) )
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( return web.json_response(
data={"error": f"Failed to delete items from archive for preset '{preset}'.", "message": str(e)}, data={"error": f"Failed to delete items from archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,
@ -286,7 +241,7 @@ async def convert(request: Request) -> Response:
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
try: try:
response: dict[str, Any] = {"opts": {}, "output_template": None, "download_path": None} response = {"opts": {}, "output_template": None, "download_path": None}
data = arg_converter(args, dumps=True) data = arg_converter(args, dumps=True)
@ -384,8 +339,6 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
if cache.has(key) and not request.query.get("force", False): if cache.has(key) and not request.query.get("force", False):
data: Any | None = cache.get(key) data: Any | None = cache.get(key)
if data is None:
data = {}
data["_cached"] = { data["_cached"] = {
"status": "hit", "status": "hit",
"preset": preset, "preset": preset,
@ -395,7 +348,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(), "ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
"expires": data.get("_cached", {}).get("expires", time.time() + 300), "expires": data.get("_cached", {}).get("expires", time.time() + 300),
} }
return web.json_response(text=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code) return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
ytdlp_opts: dict = opts.get_all() ytdlp_opts: dict = opts.get_all()
@ -452,21 +405,10 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
cache.set(key=key, value=data, ttl=300) cache.set(key=key, value=data, ttl=300)
return web.json_response(text=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code) return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to get video info for '%s': %s.", LOG.error(f"Error encountered while getting video info for '{url}'. '{e!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( return web.json_response(
data={ data={
"error": "failed to get video info.", "error": "failed to get video info.",
@ -488,7 +430,7 @@ async def get_options() -> Response:
""" """
from app.features.ytdlp.ytdlp import ytdlp_options from app.features.ytdlp.ytdlp import ytdlp_options
return web.json_response(text=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code) return web.json_response(body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code)
@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids") @route("POST", "api/yt-dlp/archive_id/", "get_archive_ids")
@ -510,11 +452,7 @@ async def get_archive_ids(request: Request, config: Config) -> Response:
response = [] response = []
for i, url in enumerate(data): for i, url in enumerate(data):
dct: dict[str, Any] = {"index": i, "url": url} dct = {"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: try:
await asyncio.to_thread(validate_url, url, config.allow_internal_urls) await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
dct.update(get_archive_id(url)) dct.update(get_archive_id(url))
@ -558,19 +496,7 @@ async def make_command(request: Request, config: Config, encoder: Encoder) -> Re
try: try:
command, info = YTDLPCli(item=it, config=config).build() command, info = YTDLPCli(item=it, config=config).build()
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( return web.json_response(
data={"error": "Failed to build CLI command"}, data={"error": "Failed to build CLI command"},
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,

View file

@ -1,18 +1,13 @@
import logging import logging
import pickle
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from app.features.ytdlp.extractor import ( from app.features.ytdlp.extractor import (
ExtractorConfig, ExtractorConfig,
ExtractorPool, ExtractorPool,
REEXTRACT_INFO_KEY,
_LogCapture,
_get_process_pool_kwargs, _get_process_pool_kwargs,
_process_safe_info,
_ytdlp_logger, _ytdlp_logger,
extract_info_sync, extract_info_sync,
) )
from app.features.ytdlp.utils import LogWrapper
class TestProcessPoolConfiguration: class TestProcessPoolConfiguration:
@ -122,7 +117,6 @@ class TestExtractInfo:
{}, "https://example.com/video", debug=True, capture_logs=logging.WARNING {}, "https://example.com/video", debug=True, capture_logs=logging.WARNING
) )
assert result is not None
assert result["id"] == "test123" assert result["id"] == "test123"
assert logs == ["[generic_browser] Browser fallback warning"] assert logs == ["[generic_browser] Browser fallback warning"]
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen
@ -156,50 +150,11 @@ class TestExtractInfo:
{}, "https://example.com/video", debug=False, capture_logs=logging.WARNING {}, "https://example.com/video", debug=False, capture_logs=logging.WARNING
) )
assert result is not None
assert result["id"] == "test123" assert result["id"] == "test123"
assert logs == ["[generic_browser] Browser fallback warning"] assert logs == ["[generic_browser] Browser fallback warning"]
assert (logging.INFO, "[generic_browser] Using remote browser for https://example.com/video") in seen 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 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: class TestYtdlpLogger:
def test_debug_prefix_uses_debug(self) -> None: def test_debug_prefix_uses_debug(self) -> None:
@ -207,26 +162,11 @@ class TestYtdlpLogger:
_ytdlp_logger(logger)(logging.DEBUG, "[debug] hello") _ytdlp_logger(logger)(logging.DEBUG, "[debug] hello")
logger.debug.assert_called_once_with("hello", stacklevel=4) logger.debug.assert_called_once_with("hello")
def test_screen_style_debug_uses_info(self) -> None: def test_screen_style_debug_uses_info(self) -> None:
logger = MagicMock() logger = MagicMock()
_ytdlp_logger(logger)(logging.DEBUG, "screen line") _ytdlp_logger(logger)(logging.DEBUG, "screen line")
logger.info.assert_called_once_with("screen line", stacklevel=4) logger.info.assert_called_once_with("screen line")
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"]

View file

@ -1,6 +1,5 @@
import importlib import importlib
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, Mock, patch from unittest.mock import MagicMock, Mock, patch
import pytest import pytest
@ -220,8 +219,7 @@ class TestYTDLP:
"""Test _delete_downloaded_files skips cleanup when _interrupted is True.""" """Test _delete_downloaded_files skips cleanup when _interrupted is True."""
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None): with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params={}) ytdlp = YTDLP(params={})
mock_obj: Any = ytdlp ytdlp.to_screen = Mock()
mock_obj.to_screen = Mock()
# Set interrupted flag # Set interrupted flag
ytdlp._interrupted = True ytdlp._interrupted = True
@ -231,7 +229,7 @@ class TestYTDLP:
# Should not call super method # Should not call super method
mock_super_delete.assert_not_called() mock_super_delete.assert_not_called()
# Should show message # Should show message
mock_obj.to_screen.assert_called_once_with("[info] Cancelled — skipping temp cleanup.") ytdlp.to_screen.assert_called_once_with("[info] Cancelled — skipping temp cleanup.")
# Should return None # Should return None
assert result is None assert result is None
@ -351,14 +349,6 @@ class TestYTDLP:
assert len(result) == 8 assert len(result) == 8
assert result.isalnum() 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: def test_outtmpl_reuses_value(self) -> None:
ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}}) ytdlp = YTDLP(params={"outtmpl": {"default": "%(title)s"}})

View file

@ -442,7 +442,7 @@ class TestYTDLPOpts:
opts = YTDLPOpts() opts = YTDLPOpts()
opts.get_all(keep=True) opts.get_all(keep=True)
mock_log.debug.assert_called_once_with("Prepared final yt-dlp options.", extra={"ytdlp_options": test_data}) mock_log.debug.assert_called_once_with(f"Final yt-dlp options: '{test_data!s}'.")
def test_cookie_loading_error_handling(self): def test_cookie_loading_error_handling(self):
"""Test error handling when cookie loading fails.""" """Test error handling when cookie loading fails."""
@ -475,12 +475,11 @@ class TestYTDLPOpts:
opts = YTDLPOpts() opts = YTDLPOpts()
opts.preset("cookie_preset") opts.preset("cookie_preset")
# Should log exception but not raise # Should log error but not raise
mock_log.exception.assert_called_once() mock_log.error.assert_called_once()
error_fields = mock_log.exception.call_args.kwargs["extra"] error_args = mock_log.error.call_args[0][0]
assert error_fields["preset"] == "Cookie Preset" assert "Failed to load" in error_args
assert error_fields["has_cookies"] is True assert "Cookie Preset" in error_args
assert error_fields["exception_type"] == "ValueError"
assert "cookiefile" not in opts._preset_opts, "cookiefile should not be set" assert "cookiefile" not in opts._preset_opts, "cookiefile should not be set"
@ -776,7 +775,7 @@ class TestYTDLPCli:
from app.features.ytdlp.ytdlp_opts import YTDLPCli from app.features.ytdlp.ytdlp_opts import YTDLPCli
with pytest.raises(ValueError, match="Expected Item instance"): with pytest.raises(ValueError, match="Expected Item instance"):
YTDLPCli(item="not an item") YTDLPCli(item="not an item") # type: ignore
@patch("app.features.presets.service.Presets") @patch("app.features.presets.service.Presets")
@patch("app.features.ytdlp.ytdlp_opts.Config") @patch("app.features.ytdlp.ytdlp_opts.Config")

View file

@ -46,8 +46,7 @@ class TestLogWrapper:
def test_add_target_type_validation(self) -> None: def test_add_target_type_validation(self) -> None:
lw = LogWrapper() lw = LogWrapper()
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"): with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
bad: Any = 123 lw.add_target(123) # type: ignore[arg-type]
lw.add_target(bad)
def test_add_target_names(self) -> None: def test_add_target_names(self) -> None:
lw = LogWrapper() lw = LogWrapper()
@ -91,7 +90,6 @@ class TestLogWrapper:
assert len(cap.records) == 1 assert len(cap.records) == 1
assert cap.records[0].levelno == logging.INFO assert cap.records[0].levelno == logging.INFO
assert cap.records[0].getMessage() == "hello X" assert cap.records[0].getMessage() == "hello X"
assert cap.records[0].funcName == "test_level_filtering_and_dispatch"
assert len(calls) == 0 assert len(calls) == 0
# WARNING hits both # WARNING hits both
@ -129,7 +127,7 @@ class TestExtractYtdlpLogs:
def test_extract_ytdlp_logs_with_filters(self): def test_extract_ytdlp_logs_with_filters(self):
"""Test log extraction with filters.""" """Test log extraction with filters."""
logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"] logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"]
filters: list[str | re.Pattern[str]] = [re.compile(r"ERROR")] filters = [re.compile(r"ERROR")]
result = extract_ytdlp_logs(logs, filters) result = extract_ytdlp_logs(logs, filters)
assert result == ["ERROR: Failed"] assert result == ["ERROR: Failed"]
@ -313,12 +311,9 @@ class TestGetThumbnail:
def test_non_list(self): def test_non_list(self):
"""Test that None is returned for non-list input.""" """Test that None is returned for non-list input."""
bad_none: Any = None assert get_thumbnail(None) is None
bad_str: Any = "not a list" assert get_thumbnail("not a list") is None
bad_dict: Any = {"not": "list"} assert get_thumbnail({"not": "list"}) is None
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): def test_thumbnail_preference(self):
"""Test that the thumbnail with highest preference is returned.""" """Test that the thumbnail with highest preference is returned."""
@ -353,7 +348,6 @@ class TestGetThumbnail:
] ]
result = get_thumbnail(thumbnails) result = get_thumbnail(thumbnails)
assert result is not None
assert result["url"] == "with_pref.jpg" assert result["url"] == "with_pref.jpg"
def test_all_equal(self): def test_all_equal(self):
@ -370,15 +364,12 @@ class TestGetThumbnail:
class TestGetExtras: class TestGetExtras:
def test_none(self): def test_none(self):
"""Test that empty dict is returned for None input.""" """Test that empty dict is returned for None input."""
bad: Any = None assert get_extras(None) == {}
assert get_extras(bad) == {}
def test_non_dict(self): def test_non_dict(self):
"""Test that empty dict is returned for non-dict input.""" """Test that empty dict is returned for non-dict input."""
bad_str: Any = "not a dict" assert get_extras("not a dict") == {}
bad_list: Any = [] assert get_extras([]) == {}
assert get_extras(bad_str) == {}
assert get_extras(bad_list) == {}
def test_extracts_video_information(self): def test_extracts_video_information(self):
"""Test extracting information from a video entry.""" """Test extracting information from a video entry."""

View file

@ -11,10 +11,9 @@ from typing import Any
from app.features.ytdlp.patches import apply_ytdlp_patches from app.features.ytdlp.patches import apply_ytdlp_patches
from app.features.ytdlp.ytdlp import YTDLP from app.features.ytdlp.ytdlp import YTDLP
from app.library.log import get_logger
from app.library.Utils import merge_dict, timed_lru_cache from app.library.Utils import merge_dict, timed_lru_cache
LOG = get_logger() LOG: logging.Logger = logging.getLogger("ytdlp.utils")
class _DATA: class _DATA:
@ -101,12 +100,12 @@ class LogWrapper:
name (str|None): The name of the logging target. Defaults to None. name (str|None): The name of the logging target. Defaults to None.
""" """
if not isinstance(target, logging.Logger) and not callable(target): if not isinstance(target, logging.Logger | Callable):
msg = "Target must be a logging.Logger instance or a callable." msg = "Target must be a logging.Logger instance or a callable."
raise TypeError(msg) raise TypeError(msg)
if name is None: if name is None:
name = target.name if isinstance(target, logging.Logger) else getattr(target, "__name__", "callable") name = target.name if isinstance(target, logging.Logger) else target.__name__
self.targets.append( self.targets.append(
LogTarget( LogTarget(
@ -131,11 +130,9 @@ class LogWrapper:
if level < target.level: if level < target.level:
continue continue
if isinstance(target.target, logging.Logger): if target.logger:
log_kwargs: dict[str, Any] = {**kwargs} target.target.log(level, msg, *args, **kwargs)
log_kwargs.setdefault("stacklevel", 3) else:
target.target.log(level, msg, *args, **log_kwargs)
elif callable(target.target):
target.target(level, msg, *args, **kwargs) target.target(level, msg, *args, **kwargs)
def debug(self, msg, *args, **kwargs): def debug(self, msg, *args, **kwargs):
@ -179,17 +176,13 @@ def arg_converter(
create_parser = yt_dlp.options.create_parser create_parser = yt_dlp.options.create_parser
def _default_opts(args: str | list[str]): def _default_opts(args: str):
patched_parser = create_parser() patched_parser = create_parser()
def patched_create_parser():
return patched_parser
try: try:
yt_dlp.options.__dict__["create_parser"] = patched_create_parser yt_dlp.options.create_parser = lambda: patched_parser
return yt_dlp.parse_options(args) return yt_dlp.parse_options(args)
finally: finally:
yt_dlp.options.__dict__["create_parser"] = create_parser yt_dlp.options.create_parser = create_parser
apply_ytdlp_patches() apply_ytdlp_patches()
@ -242,7 +235,7 @@ def arg_converter(
return diff return diff
def extract_ytdlp_logs(logs: list[str], filters: list[str | re.Pattern[str]] | None = None) -> list[str]: def extract_ytdlp_logs(logs: list[str], filters: list[str | re.Pattern] = None) -> list[str]:
""" """
Extract yt-dlp log lines matching built-in filters plus any extras. Extract yt-dlp log lines matching built-in filters plus any extras.
@ -336,7 +329,7 @@ def get_ytdlp(params: dict | None = None) -> YTDLP:
return _DATA.YTDLP_INFO_CLS return _DATA.YTDLP_INFO_CLS
def get_thumbnail(thumbnails: list) -> dict | None: def get_thumbnail(thumbnails: list) -> str | None:
""" """
Extract thumbnail URL from a yt-dlp entry. Extract thumbnail URL from a yt-dlp entry.
@ -344,7 +337,7 @@ def get_thumbnail(thumbnails: list) -> dict | None:
thumbnails (list): The list of thumbnail dictionaries from yt-dlp entry. thumbnails (list): The list of thumbnail dictionaries from yt-dlp entry.
Returns: Returns:
dict | None: The best thumbnail dict if available, otherwise None. str | None: The thumbnail URL if available, otherwise None.
""" """
if not thumbnails or not isinstance(thumbnails, list): if not thumbnails or not isinstance(thumbnails, list):
@ -385,7 +378,7 @@ def get_extras(entry: dict, kind: str = "video") -> dict:
extras[f"playlist_{property}"] = val extras[f"playlist_{property}"] = val
if thumbnail := get_thumbnail(entry.get("thumbnails", [])): if thumbnail := get_thumbnail(entry.get("thumbnails", [])):
extras["thumbnail"] = thumbnail.get("url") if isinstance(thumbnail, dict) else thumbnail extras["thumbnail"] = thumbnail.get("url")
elif thumbnail := entry.get("thumbnail"): elif thumbnail := entry.get("thumbnail"):
extras["thumbnail"] = thumbnail extras["thumbnail"] = thumbnail
@ -443,7 +436,7 @@ def get_archive_id(url: str) -> dict[str, str | None]:
} }
""" """
idDict: dict[str, str | None] = { idDict: dict[str, None] = {
"id": None, "id": None,
"ie_key": None, "ie_key": None,
"archive_id": None, "archive_id": None,
@ -467,12 +460,8 @@ def get_archive_id(url: str) -> dict[str, str | None]:
idDict["archive_id"] = make_archive_id(_ie, temp_id) idDict["archive_id"] = make_archive_id(_ie, temp_id)
break break
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to get archive ID for '%s' with extractor '%s'.", LOG.error(f"Error getting archive ID: {e}")
url,
key,
extra={"url": url, "extractor": key, "exception_type": type(e).__name__},
)
return idDict return idDict

View file

@ -1,4 +1,5 @@
# flake8: noqa: F401, RUF100, W291, I001 # flake8: noqa: F401, RUF100, W291, I001
import logging
import sys import sys
from typing import Any from typing import Any
@ -9,7 +10,6 @@ from yt_dlp.utils import make_archive_id
from app.features.ytdlp.outtmpl import rewrite_outtmpl from app.features.ytdlp.outtmpl import rewrite_outtmpl
from app.features.ytdlp.patches import apply_ytdlp_patches from app.features.ytdlp.patches import apply_ytdlp_patches
from app.library.cf_solver_handler import set_cf_handler from app.library.cf_solver_handler import set_cf_handler
from app.library.log import get_logger
class _ArchiveProxy: class _ArchiveProxy:
@ -67,7 +67,7 @@ class YTDLP(yt_dlp.YoutubeDL):
postprocessors.value.update({"NFOMakerPP": NFOMakerPP}) postprocessors.value.update({"NFOMakerPP": NFOMakerPP})
YTDLP._registered = True YTDLP._registered = True
except Exception: except Exception:
get_logger().exception("Failed to register yt-dlp plugins") logging.getLogger("ytdlp.wrapper").exception("Failed to register yt-dlp plugins")
# Avoid yt-dlp preloading the archive file by stripping the param first # Avoid yt-dlp preloading the archive file by stripping the param first
orig_file = None orig_file = None
@ -81,9 +81,6 @@ class YTDLP(yt_dlp.YoutubeDL):
except Exception: except Exception:
patched_params = params 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) super().__init__(params=patched_params, auto_init=auto_init)
# Restore param and replace upstream archive set with our proxy # Restore param and replace upstream archive set with our proxy
@ -93,6 +90,8 @@ class YTDLP(yt_dlp.YoutubeDL):
except Exception: except Exception:
pass pass
self._ytptube_outtmpl_info: dict[str, Any] | None = None
self._ytptube_outtmpl_cache: dict[str, Any] = {}
self.archive = _ArchiveProxy(orig_file) self.archive = _ArchiveProxy(orig_file)
def _delete_downloaded_files(self, *args, **kwargs) -> None: def _delete_downloaded_files(self, *args, **kwargs) -> None:
@ -106,14 +105,14 @@ class YTDLP(yt_dlp.YoutubeDL):
self._ytptube_outtmpl_info = None self._ytptube_outtmpl_info = None
self._ytptube_outtmpl_cache = {} self._ytptube_outtmpl_cache = {}
def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False, *, _exec=False): def prepare_outtmpl(self, outtmpl, info_dict, sanitize=False):
if self._ytptube_outtmpl_info is not info_dict: if self._ytptube_outtmpl_info is not info_dict:
self._ytptube_outtmpl_info = info_dict self._ytptube_outtmpl_info = info_dict
self._ytptube_outtmpl_cache = {} self._ytptube_outtmpl_cache = {}
outtmpl, enriched = rewrite_outtmpl(outtmpl, info_dict, cache=self._ytptube_outtmpl_cache) outtmpl, enriched = rewrite_outtmpl(outtmpl, info_dict, cache=self._ytptube_outtmpl_cache)
return super().prepare_outtmpl(outtmpl, enriched, sanitize=sanitize, _exec=_exec) return super().prepare_outtmpl(outtmpl, enriched, sanitize=sanitize)
def process_info(self, info_dict): def process_info(self, info_dict):
try: try:

View file

@ -1,3 +1,4 @@
import logging
import shlex import shlex
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -5,10 +6,9 @@ from typing import Any
from app.features.presets.schemas import Preset from app.features.presets.schemas import Preset
from app.features.ytdlp.utils import arg_converter from app.features.ytdlp.utils import arg_converter
from app.library.config import Config 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 from app.library.Utils import calc_download_path, create_cookies_file, merge_dict
LOG = get_logger() LOG: logging.Logger = logging.getLogger("ytdlp.ytdlp_opts")
class ARGSMerger: class ARGSMerger:
@ -50,12 +50,12 @@ class ARGSMerger:
""" """
return str(self) return str(self)
def as_dict(self) -> list[str]: def as_dict(self) -> dict:
""" """
Get all the options as a shell argument list. Get all the options as a dict.
Returns: Returns:
list[str]: The options as shell arguments. dict: The options as a dict
""" """
return shlex.split(shlex.join(self.args)) return shlex.split(shlex.join(self.args))
@ -335,12 +335,7 @@ class YTDLPOpts:
if file and file.exists(): if file and file.exists():
self._preset_opts["cookiefile"] = str(file) self._preset_opts["cookiefile"] = str(file)
except ValueError as e: except ValueError as e:
LOG.exception( LOG.error(f"Failed to load '{preset.name}' cookies. {e!s}")
"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: if preset.template:
self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter} self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter}
@ -397,30 +392,6 @@ class YTDLPOpts:
data: dict = merge_dict(user_cli, merge_dict(self._item_opts, merge_dict(self._preset_opts, default_opts))) 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: if not keep:
self.reset() self.reset()
@ -432,7 +403,7 @@ class YTDLPOpts:
data["format"] = data["format"][1:] data["format"] = data["format"][1:]
if self._config.debug: if self._config.debug:
LOG.debug("Prepared final yt-dlp options.", extra={"ytdlp_options": data}) LOG.debug(f"Final yt-dlp options: '{data!s}'.")
return data return data

View file

@ -1,16 +1,15 @@
import asyncio import asyncio
import inspect import inspect
import logging
import threading import threading
from queue import Empty, Queue from queue import Empty, Queue
from aiohttp import web from aiohttp import web
from app.library.log import get_logger
from .Services import Services from .Services import Services
from .Singleton import Singleton from .Singleton import Singleton
LOG = get_logger() LOG: logging.Logger = logging.getLogger("BackgroundWorker")
class CloseThread: class CloseThread:
@ -29,7 +28,7 @@ class BackgroundWorker(metaclass=Singleton):
"The queue to hold the tasks." "The queue to hold the tasks."
self.running = True self.running = True
"Whether the background worker is running or not." "Whether the background worker is running or not."
self.thread: threading.Thread | None = None self.thread: threading.Thread = None
"The thread that runs the background worker." "The thread that runs the background worker."
@staticmethod @staticmethod
@ -40,23 +39,19 @@ class BackgroundWorker(metaclass=Singleton):
Services.get_instance().add("background_worker", self) Services.get_instance().add("background_worker", self)
app.on_shutdown.append(self.on_shutdown) app.on_shutdown.append(self.on_shutdown)
LOG.debug("Started background worker thread.") LOG.debug("Starting background worker...")
self.thread = threading.Thread(target=self._run, daemon=True) self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
async def on_shutdown(self, _: web.Application): async def on_shutdown(self, _: web.Application):
self.running = False self.running = False
try: try:
LOG.debug("Stopping background worker thread.") LOG.debug("Shutting down background worker...")
self.queue.put((CloseThread, (), {})) self.queue.put((CloseThread, (), {}))
if self.thread: self.thread.join(timeout=5)
self.thread.join(timeout=5) LOG.debug("Background worker has been shut down.")
LOG.debug("Background worker thread has been shut down.")
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to shut down background worker: {e}")
"Failed to shut down background worker thread.",
extra={"exception_type": type(e).__name__},
)
def _run(self): def _run(self):
asyncio.set_event_loop(asyncio.new_event_loop()) asyncio.set_event_loop(asyncio.new_event_loop())
@ -84,16 +79,8 @@ class BackgroundWorker(metaclass=Singleton):
if inspect.iscoroutine(result): if inspect.iscoroutine(result):
loop.call_soon_threadsafe(loop.create_task, result) loop.call_soon_threadsafe(loop.create_task, result)
except Exception as e: except Exception as e:
function = getattr(fn, "__name__", fn.__class__.__name__) LOG.exception(e)
LOG.exception( LOG.error(f"Failed to run '{fn.__name__}'. {e!s}")
"Failed to run background worker function '%s'.",
function,
extra={
"function": function,
"thread_name": threading.current_thread().name,
"exception_type": type(e).__name__,
},
)
except Empty: except Empty:
continue continue
@ -101,12 +88,9 @@ class BackgroundWorker(metaclass=Singleton):
loop.call_soon_threadsafe(loop.stop) loop.call_soon_threadsafe(loop.stop)
loop_thread.join(timeout=5) loop_thread.join(timeout=5)
loop.close() loop.close()
LOG.debug("Stopped background worker event loop.") LOG.debug("Event loop has been stopped and closed.")
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to stop the event loop: {e!s}")
"Failed to stop background worker event loop.",
extra={"thread_name": loop_thread.name, "exception_type": type(e).__name__},
)
def submit(self, fn, *args, **kwargs): def submit(self, fn, *args, **kwargs):
self.queue.put((fn, args, kwargs)) self.queue.put((fn, args, kwargs))

View file

@ -1,16 +1,15 @@
import copy import copy
import logging
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Iterable from collections.abc import Iterable
from enum import Enum from enum import Enum
from app.library.log import get_logger
from .downloads import Download from .downloads import Download
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .operations import matches_condition from .operations import matches_condition
from .sqlite_store import SqliteStore from .sqlite_store import SqliteStore
LOG = get_logger() LOG = logging.getLogger("datastore")
class StoreType(str, Enum): class StoreType(str, Enum):
@ -166,12 +165,6 @@ class DataStore:
def items(self): def items(self):
return self._dict.items() 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: async def put(self, value: Download, no_notify: bool = False) -> Download:
_ = no_notify _ = no_notify
self._dict[value.info._id] = value self._dict[value.info._id] = value

View file

@ -1,16 +1,16 @@
import asyncio import asyncio
import datetime import datetime
import logging
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from app.features.core.utils import gen_random from app.features.core.utils import gen_random
from app.library.log import get_logger
from .BackgroundWorker import BackgroundWorker from .BackgroundWorker import BackgroundWorker
from .Singleton import Singleton from .Singleton import Singleton
LOG = get_logger() LOG: logging.Logger = logging.getLogger("events")
class Events: class Events:
@ -34,7 +34,6 @@ class Events:
ITEM_ADDED: str = "item_added" ITEM_ADDED: str = "item_added"
ITEM_UPDATED: str = "item_updated" ITEM_UPDATED: str = "item_updated"
ITEM_PROGRESS: str = "item_progress"
ITEM_COMPLETED: str = "item_completed" ITEM_COMPLETED: str = "item_completed"
ITEM_CANCELLED: str = "item_cancelled" ITEM_CANCELLED: str = "item_cancelled"
ITEM_DELETED: str = "item_deleted" ITEM_DELETED: str = "item_deleted"
@ -87,7 +86,6 @@ class Events:
Events.LOG_SUCCESS, Events.LOG_SUCCESS,
Events.ITEM_ADDED, Events.ITEM_ADDED,
Events.ITEM_UPDATED, Events.ITEM_UPDATED,
Events.ITEM_PROGRESS,
Events.ITEM_CANCELLED, Events.ITEM_CANCELLED,
Events.ITEM_DELETED, Events.ITEM_DELETED,
Events.ITEM_BULK_DELETED, Events.ITEM_BULK_DELETED,
@ -105,7 +103,7 @@ class Events:
list: The list of debug events. list: The list of debug events.
""" """
return [Events.ITEM_UPDATED, Events.ITEM_PROGRESS] return [Events.ITEM_UPDATED]
@dataclass(kw_only=True) @dataclass(kw_only=True)
@ -244,12 +242,7 @@ class EventBus(metaclass=Singleton):
event = Events.frontend() event = Events.frontend()
else: else:
if event not in all_events: if event not in all_events:
LOG.error( LOG.error(f"'{name}' attempted to listen on '{event}' which does not exist.")
"Listener '%s' tried to subscribe to unknown event '%s'.",
name,
event,
extra={"listener": name, "event": event},
)
return self return self
event = [event] event = [event]
@ -259,12 +252,7 @@ class EventBus(metaclass=Singleton):
for e in event: for e in event:
if e not in all_events: if e not in all_events:
LOG.error( LOG.error(f"'{name}' attempted to listen on '{e}' which does not exist.")
"Listener '%s' tried to subscribe to unknown event '%s'.",
name,
e,
extra={"listener": name, "event": e},
)
continue continue
if e not in self._listeners: if e not in self._listeners:
@ -273,7 +261,7 @@ class EventBus(metaclass=Singleton):
self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name] self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
self._listeners[e].append((name, EventListener(name, callback))) self._listeners[e].append((name, EventListener(name, callback)))
LOG.debug("Listener '%s' has subscribed.", name, extra={"listener": name, "events": event}) LOG.debug(f"'{name}' subscribed to '{event}'.")
return self return self
@ -301,7 +289,7 @@ class EventBus(metaclass=Singleton):
events.append(e) events.append(e)
if len(events) > 0: if len(events) > 0:
LOG.debug("Listener '%s' unsubscribed from '%s'.", name, events, extra={"listener": name, "events": events}) LOG.debug(f"'{name}' unsubscribed from '{events}'.")
return self return self
@ -328,17 +316,7 @@ class EventBus(metaclass=Singleton):
ev = Event(event=event, title=title, message=message, data=data) ev = Event(event=event, title=title, message=message, data=data)
if self.debug or event not in Events.only_debug(): if self.debug or event not in Events.only_debug():
LOG.debug( LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data})
"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: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@ -351,35 +329,16 @@ class EventBus(metaclass=Singleton):
if asyncio.iscoroutine(coro): if asyncio.iscoroutine(coro):
await coro await coro
else: else:
LOG.warning( LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
"Async handler '%s' returned '%s' instead of a coroutine.",
handler.name,
type(coro).__name__,
extra={"handler": handler.name, "returned_type": type(coro).__name__},
)
else: else:
await self._call(handler, ev, kwargs) await self._call(handler, ev, kwargs)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to emit '%s' event to listener '%s'.", LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!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()) loop.create_task(execute_handlers())
except RuntimeError: except RuntimeError:
LOG.debug( LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers")
"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]: for _, handler in self._listeners[event]:
try: try:
if not self._offload: if not self._offload:
@ -387,17 +346,8 @@ class EventBus(metaclass=Singleton):
self._offload.submit(handler.handle, ev, **kwargs) self._offload.submit(handler.handle, ev, **kwargs)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to offload '%s' event to listener '%s'.", LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!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: def clear(self) -> None:
""" """

View file

@ -1,19 +1,15 @@
import base64 import base64
import hmac import hmac
import inspect import inspect
import logging
from collections.abc import Awaitable
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, cast
import anyio import anyio
from aiohttp import web from aiohttp import web
from aiohttp.typedefs import Handler, Middleware from aiohttp.web import Request, RequestHandler, Response
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 app.library.Services import Services
from .cache import Cache from .cache import Cache
@ -23,33 +19,7 @@ from .Events import EventBus
from .router import RouteType, get_routes from .router import RouteType, get_routes
from .Utils import decrypt_data, encrypt_data, get_file, load_modules from .Utils import decrypt_data, encrypt_data, get_file, load_modules
LOG = get_logger("http") LOG: logging.Logger = logging.getLogger("http_api")
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: class HttpAPI:
@ -119,10 +89,7 @@ class HttpAPI:
try: try:
app.on_response_prepare.append(on_prepare) app.on_response_prepare.append(on_prepare)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to register response preparation middleware.",
extra={"operation": "register_on_response_prepare", "exception_type": type(e).__name__},
)
app.on_shutdown.append(self.on_shutdown) app.on_shutdown.append(self.on_shutdown)
@ -163,13 +130,7 @@ class HttpAPI:
route.path = f"{base_path}/{route.path.lstrip('/')}" route.path = f"{base_path}/{route.path.lstrip('/')}"
if self.config.debug: if self.config.debug:
LOG.debug( LOG.debug(f"Add ({route.name}) {route.method}: {route.path}.")
"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) app.router.add_route(route.method, route.path, handler=_handle(route.handler), name=route.name)
@ -180,7 +141,7 @@ class HttpAPI:
registered_options.append(route.path) registered_options.append(route.path)
@staticmethod @staticmethod
def basic_auth(username: str, password: str) -> Middleware: def basic_auth(username: str, password: str) -> Awaitable:
""" """
Middleware to handle basic authentication. Middleware to handle basic authentication.
@ -194,7 +155,7 @@ class HttpAPI:
""" """
@web.middleware @web.middleware
async def auth_handler(request: Request, handler: Handler) -> StreamResponse: async def auth_handler(request: Request, handler: RequestHandler) -> Response:
# if OPTIONS request, skip auth # if OPTIONS request, skip auth
if "OPTIONS" == request.method: if "OPTIONS" == request.method:
return await handler(request) return await handler(request)
@ -207,19 +168,12 @@ class HttpAPI:
auth_cookie = request.cookies.get("auth") auth_cookie = request.cookies.get("auth")
if auth_cookie is not None: if auth_cookie is not None:
try: try:
data = decrypt_data(auth_cookie, key=cast("bytes", Config.get_instance().secret_key)) data = decrypt_data(auth_cookie, key=Config.get_instance().secret_key)
if data is not None: if data is not None:
data = base64.b64encode(data.encode()).decode() data = base64.b64encode(data.encode()).decode()
auth_header = f"Basic {data}" auth_header = f"Basic {data}"
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to decrypt authentication cookie.",
extra={
"route": str(request.rel_url),
"method": request.method,
"exception_type": type(e).__name__,
},
)
if auth_header is None: if auth_header is None:
return web.json_response( return web.json_response(
@ -253,7 +207,7 @@ class HttpAPI:
}, },
) )
response: StreamResponse = await handler(request) response: Response = await handler(request)
contentType: str | None = response.headers.get("content-type", None) contentType: str | None = response.headers.get("content-type", None)
if contentType and not contentType.startswith("text/html"): if contentType and not contentType.startswith("text/html"):
@ -264,7 +218,7 @@ class HttpAPI:
"auth", "auth",
encrypt_data( encrypt_data(
f"{user_name}:{user_password}", f"{user_name}:{user_password}",
key=cast("bytes", Config.get_instance().secret_key), key=Config.get_instance().secret_key,
), ),
max_age=60 * 60 * 24 * 7, max_age=60 * 60 * 24 * 7,
expires=(datetime.now(UTC) + timedelta(days=7)).strftime("%a, %d %b %Y %H:%M:%S GMT"), expires=(datetime.now(UTC) + timedelta(days=7)).strftime("%a, %d %b %Y %H:%M:%S GMT"),
@ -272,20 +226,16 @@ class HttpAPI:
samesite="Strict", samesite="Strict",
) )
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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 response
return auth_handler return auth_handler
@staticmethod @staticmethod
def middle_wares(app: web.Application, base_path: str, download_path: str) -> Middleware: def middle_wares(app: web.Application, base_path: str, download_path: str) -> Awaitable:
@web.middleware @web.middleware
async def middleware_handler(request: Request, handler: Handler) -> StreamResponse: async def middleware_handler(request: Request, handler: RequestHandler) -> Response:
static_path = str(app.router["download_static"].url_for(filename="")) static_path = str(app.router["download_static"].url_for(filename=""))
if request.path.startswith(static_path): if request.path.startswith(static_path):
realFile, status = get_file( realFile, status = get_file(
@ -304,13 +254,12 @@ class HttpAPI:
}, },
) )
response: StreamResponse = await handler(request) response: Response = await handler(request)
contentType: str = str(response.headers.get("content-type", "")) contentType: str = str(response.headers.get("content-type", ""))
if contentType.startswith("text/html") and getattr(response, "_path", None): if contentType.startswith("text/html") and getattr(response, "_path", None):
rewrite_path: str = base_path.rstrip("/") rewrite_path: str = base_path.rstrip("/")
response_path = cast("Any", response)._path async with await anyio.open_file(response._path, "rb") as f:
async with await anyio.open_file(response_path, "rb") as f:
content = await f.read() content = await f.read()
content: str = ( content: str = (
content.decode("utf-8") content.decode("utf-8")
@ -328,13 +277,13 @@ class HttpAPI:
new_response.set_cookie( new_response.set_cookie(
morsel.key, morsel.key,
morsel.value, morsel.value,
expires=morsel.get("expires"), expires=morsel["expires"],
domain=morsel.get("domain"), domain=morsel["domain"],
max_age=morsel.get("max-age"), max_age=morsel["max-age"],
path=morsel.get("path") or "/", path=morsel["path"],
secure=bool(morsel.get("secure")), secure=bool(morsel["secure"]),
httponly=bool(morsel.get("httponly")), httponly=bool(morsel["httponly"]),
samesite=morsel.get("samesite") or None, samesite=morsel["samesite"] or None,
) )
return new_response return new_response
@ -343,7 +292,7 @@ class HttpAPI:
return middleware_handler return middleware_handler
async def _handle(self, handler: Handler, request: Request) -> StreamResponse: async def _handle(self, handler: RequestHandler, request: Request) -> Response:
""" """
Call the handler with the request and return the response. Call the handler with the request and return the response.
@ -365,16 +314,7 @@ class HttpAPI:
else: else:
response = await Services.get_instance().handle_async(handler, request=request) response = await Services.get_instance().handle_async(handler, request=request)
except TypeError as te: except TypeError as te:
LOG.exception( LOG.exception(te)
"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): if "missing 1 required positional argument" in str(te) and "request" in str(te):
response = await handler(request) response = await handler(request)
else: else:
@ -382,12 +322,7 @@ class HttpAPI:
except web.HTTPException as e: except web.HTTPException as e:
return web.json_response(data={"error": str(e)}, status=e.status_code) return web.json_response(data={"error": str(e)}, status=e.status_code)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"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( response = web.json_response(
data={"error": "Internal Server Error"}, data={"error": "Internal Server Error"},
status=web.HTTPInternalServerError.status_code, status=web.HTTPInternalServerError.status_code,

View file

@ -1,13 +1,13 @@
import asyncio import asyncio
import functools import functools
import json import json
import logging
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from app.features.core.utils import gen_random 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.router import Route, RouteType, get_routes
from app.library.Services import Services from app.library.Services import Services
from app.library.Utils import load_modules from app.library.Utils import load_modules
@ -17,7 +17,7 @@ from .encoder import Encoder
from .Events import Event, EventBus, Events from .Events import Event, EventBus, Events
from .ItemDTO import Item from .ItemDTO import Item
LOG = get_logger() LOG: logging.Logger = logging.getLogger("socket_api")
class WebSocketHub: class WebSocketHub:
@ -104,20 +104,19 @@ class HttpSocket:
} }
) )
self._notify.subscribe("frontend", event_handler, f"{HttpSocket.__name__}.emit") self._notify.subscribe("frontend", event_handler, f"{__class__.__name__}.emit")
@staticmethod @staticmethod
def ws_event(func): def ws_event(func): # type: ignore
""" """
Decorator to mark a method as a socket event. Decorator to mark a method as a socket event.
""" """
@functools.wraps(func) @functools.wraps(func) # type: ignore
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
return await func(*args, **kwargs) return await func(*args, **kwargs) # type: ignore
wrapper_with_event: Any = wrapper wrapper._ws_event = func.__name__ # type: ignore
wrapper_with_event._ws_event = func.__name__
return wrapper return wrapper
async def on_shutdown(self, _: web.Application): async def on_shutdown(self, _: web.Application):
@ -135,11 +134,9 @@ class HttpSocket:
if not (data and data.data): if not (data and data.data):
return return
queue = Services.get_instance().get("queue") await Services.get_instance().get("queue").add(item=Item.format(data.data))
if queue is not None:
await queue.add(item=Item.format(data.data))
self._notify.subscribe(Events.ADD_URL, event_handler, f"{HttpSocket.__name__}.add") self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
load_modules(self.rootPath, self.rootPath / "routes" / "socket") load_modules(self.rootPath, self.rootPath / "routes" / "socket")
@ -148,10 +145,7 @@ class HttpSocket:
for route in socket_routes.values(): for route in socket_routes.values():
if self.config.debug: if self.config.debug:
LOG.debug( LOG.debug(
"Registered socket route '%s' for %s %s.", f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
route.name,
route.method.value if isinstance(route.method, RouteType) else route.method,
route.path,
) )
async def handle_message(sid: str, message: str) -> None: async def handle_message(sid: str, message: str) -> None:
@ -167,7 +161,7 @@ class HttpSocket:
return return
if not (route := socket_routes.get(event)): if not (route := socket_routes.get(event)):
LOG.debug("Unknown WebSocket event '%s'.", event, extra={"sid": sid, "event": event}) LOG.debug(f"Unknown websocket event '{event}'.")
return return
data = payload.get("data") if isinstance(payload, dict) else None data = payload.get("data") if isinstance(payload, dict) else None
@ -189,7 +183,7 @@ class HttpSocket:
sid: str = gen_random(14) sid: str = gen_random(14)
self.sio.add(sid, ws) self.sio.add(sid, ws)
LOG.debug("WebSocket client '%s' connected.", sid, extra={"sid": sid}) LOG.debug(f"WebSocket client '{sid}' connected.")
await handle_connect(sid) await handle_connect(sid)
@ -198,11 +192,8 @@ class HttpSocket:
try: try:
await handle_message(sid, message) await handle_message(sid, message)
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to handle WebSocket message from client '%s'.", LOG.error(f"Error handling WebSocket message from client '{sid}': {e}")
sid,
extra={"sid": sid, "message_size": len(message), "exception_type": type(e).__name__},
)
try: try:
i: int = 0 i: int = 0
@ -211,22 +202,15 @@ class HttpSocket:
i = i + 1 i = i + 1
asyncio.create_task(handle_message_safe(sid, msg.data), name=f"ws_msg_{sid}_{i}") asyncio.create_task(handle_message_safe(sid, msg.data), name=f"ws_msg_{sid}_{i}")
elif msg.type == web.WSMsgType.ERROR: elif msg.type == web.WSMsgType.ERROR:
LOG.error( LOG.error(f"WebSocket connection closed with exception {ws.exception()}")
"WebSocket connection closed with exception %s",
ws.exception(),
extra={
"sid": sid,
"exception": str(ws.exception()) if ws.exception() else None,
},
)
finally: finally:
await handle_disconnect(sid, getattr(ws, "close_reason", None)) await handle_disconnect(sid, getattr(ws, "close_reason", None))
self.sio.remove(sid) self.sio.remove(sid)
LOG.debug("WebSocket client '%s' disconnected.", sid, extra={"sid": sid}) LOG.debug(f"WebSocket client '{sid}' disconnected.")
return ws return ws
if self.config.debug: if self.config.debug:
LOG.debug("Registered WebSocket endpoint at '%s'.", ws_path, extra={"method": "GET", "path": ws_path}) LOG.debug(f"Add (ws) GET: {ws_path}.")
app.router.add_get(ws_path, ws_handler, name="ws") app.router.add_get(ws_path, ws_handler, name="ws")

View file

@ -1,3 +1,4 @@
import logging
import re import re
import time import time
import uuid import uuid
@ -9,13 +10,12 @@ 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.utils import archive_add, archive_delete, archive_read, get_archive_id
from app.features.ytdlp.ytdlp_opts import YTDLPOpts from app.features.ytdlp.ytdlp_opts import YTDLPOpts
from app.library.encoder import Encoder 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 from app.library.Utils import clean_item, get_file, get_file_sidecar
if TYPE_CHECKING: if TYPE_CHECKING:
from app.features.presets.schemas import Preset from app.features.presets.schemas import Preset
LOG = get_logger() LOG: logging.Logger = logging.getLogger("ItemDTO")
@dataclass(kw_only=True) @dataclass(kw_only=True)
@ -93,7 +93,7 @@ class Item:
bool: True if the item has extras, False otherwise. bool: True if the item has extras, False otherwise.
""" """
return bool(self.extras and len(self.extras) > 0) return self.extras and len(self.extras) > 0
def add_extras(self, key: str, value: Any) -> None: 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. bool: True if the item has command options for yt, False otherwise.
""" """
return bool(self.cli and len(self.cli) > 2) return self.cli and len(self.cli) > 2
@staticmethod @staticmethod
def _default_preset() -> str: def _default_preset() -> str:
@ -161,7 +161,7 @@ class Item:
Item: The formatted item. Item: The formatted item.
""" """
url = item.get("url") url: str = item.get("url")
if not url or not isinstance(url, str): if not url or not isinstance(url, str):
msg = "url param is required." 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): if len(url) >= 11 and re.fullmatch(r"[A-Za-z0-9_-]{11}", url):
url = f"https://www.youtube.com/watch?v={url}" url = f"https://www.youtube.com/watch?v={url}"
data: dict[str, Any] = {"url": url} data: dict[str, str] = {"url": url}
preset: str | None = item.get("preset") preset: str | None = item.get("preset")
if preset and isinstance(preset, str) and preset != Item._default_preset(): if preset and isinstance(preset, str) and preset != Item._default_preset():
@ -185,28 +185,24 @@ class Item:
data["preset"] = preset data["preset"] = preset
folder = item.get("folder") if item.get("folder") and isinstance(item.get("folder"), str):
if isinstance(folder, str) and folder: data["folder"] = item.get("folder")
data["folder"] = folder
cookies = item.get("cookies") if item.get("cookies") and isinstance(item.get("cookies"), str):
if isinstance(cookies, str) and cookies: data["cookies"] = item.get("cookies")
data["cookies"] = cookies
template = item.get("template") if item.get("template") and isinstance(item.get("template"), str):
if isinstance(template, str) and template: data["template"] = item.get("template")
data["template"] = template
if isinstance(item.get("auto_start"), bool): if "auto_start" in item and isinstance(item.get("auto_start"), bool):
data["auto_start"] = item["auto_start"] data["auto_start"] = bool(item.get("auto_start"))
extras: dict | None = item.get("extras") extras: dict | None = item.get("extras")
if isinstance(extras, dict) and len(extras) > 0: if extras and isinstance(extras, dict) and len(extras) > 0:
data["extras"] = extras data["extras"] = extras
requeued = item.get("requeued") if item.get("requeued") and isinstance(item.get("requeued"), bool):
if isinstance(requeued, bool): data["requeued"] = item.get("requeued")
data["requeued"] = requeued
cli: str | None = item.get("cli") cli: str | None = item.get("cli")
if cli and len(cli) > 2: if cli and len(cli) > 2:
@ -578,10 +574,8 @@ class ItemDTO:
self.is_archivable = bool(self._archive_file) self.is_archivable = bool(self._archive_file)
if not self.is_archivable: if not self.is_archivable:
self.is_archived = False self.is_archived = False
elif self.archive_id and self._archive_file:
self.is_archived = len(archive_read(self._archive_file, [self.archive_id])) > 0
else: else:
self.is_archived = False self.is_archived = len(archive_read(self._archive_file, [self.archive_id])) > 0
def get_archive_file(self) -> str | None: def get_archive_file(self) -> str | None:
""" """

View file

@ -1,15 +1,14 @@
import importlib import importlib
import importlib.metadata import importlib.metadata
import logging
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from app.library.log import get_logger
from .httpx_client import sync_client from .httpx_client import sync_client
LOG = get_logger() LOG: logging.Logger = logging.getLogger("package_installer")
def parse_version(v: str) -> tuple[int, ...]: def parse_version(v: str) -> tuple[int, ...]:
@ -17,7 +16,7 @@ def parse_version(v: str) -> tuple[int, ...]:
class Packages: class Packages:
def __init__(self, env: str | None, file: str | Path | None, upgrade: bool = False): def __init__(self, env: str | None, file: str | None, upgrade: bool = False):
from_env: list[str] = env.split() if env else [] from_env: list[str] = env.split() if env else []
from_file = [] from_file = []
@ -65,34 +64,19 @@ class PackageInstaller:
current_version: str | None = self._get_installed_version(pkg) current_version: str | None = self._get_installed_version(pkg)
if current_version and version and self.compare_versions(current_version, version): if current_version and version and self.compare_versions(current_version, version):
LOG.info( LOG.info(f"'{pkg}' is already installed with the specified version ({version}). Skipping installation.")
"Package '%s' is already installed at version '%s'; skipping installation.",
pkg,
version,
extra={"package": pkg, "installed_version": current_version, "requested_version": version},
)
return return
if upgrade and current_version and not version: if upgrade and current_version and not version:
latest_version: str | None = self._get_latest_version(pkg) latest_version: str | None = self._get_latest_version(pkg)
if latest_version and parse_version(current_version) >= parse_version(latest_version): if latest_version and parse_version(current_version) >= parse_version(latest_version):
LOG.info( LOG.info(f"'{pkg}' is already the latest version ({current_version}). Skipping upgrade.")
"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 return
if current_version: if current_version:
LOG.info( LOG.info(f"'{pkg}' is already installed (version: {current_version}). Proceeding with upgrade...")
"Package '%s' is installed at '%s'; proceeding with upgrade.",
pkg,
current_version,
extra={"package": pkg, "installed_version": current_version},
)
else: else:
LOG.info("Package '%s' is not installed; installing it.", pkg, extra={"package": pkg}) LOG.info(f"'{pkg}' is not installed. Installing...")
self._install_pkg(pkg, version=version) self._install_pkg(pkg, version=version)
@ -109,20 +93,9 @@ class PackageInstaller:
resp = client.get(url) resp = client.get(url)
if 200 == resp.status_code: if 200 == resp.status_code:
return resp.json()["info"]["version"] return resp.json()["info"]["version"]
LOG.warning( LOG.warning(f"Failed to fetch '{pkg}' info: HTTP {resp.status_code}")
"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: except Exception as e:
LOG.warning( LOG.warning(f"Error while querying PyPI for '{pkg}': {e}")
"Failed to query PyPI for '%s': %s",
pkg,
e,
extra={"package": pkg, "url": url, "exception_type": type(e).__name__},
exc_info=True,
)
return None return None
def _install_pkg(self, pkg: str, version: str | None = None) -> bool: def _install_pkg(self, pkg: str, version: str | None = None) -> bool:
@ -162,12 +135,7 @@ class PackageInstaller:
return 0 == proc.returncode return 0 == proc.returncode
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
LOG.exception( LOG.error(f"Failed to install '{pkg}' (exit {e.returncode}). {e!s}")
"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) self.out(out=e.stdout, err=e.stderr)
raise raise
@ -182,20 +150,13 @@ class PackageInstaller:
if not pkgs.has_packages() or not self.user_site: if not pkgs.has_packages() or not self.user_site:
return return
LOG.info("Checking configured user pip packages.", extra={"packages": pkgs.packages}) LOG.info(f"Checking for user pip packages: {', '.join(pkgs.packages)}")
for package in pkgs.packages: for package in pkgs.packages:
try: try:
self.action(package, upgrade=pkgs.allow_upgrade()) self.action(package, upgrade=pkgs.allow_upgrade())
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to install or upgrade package '{package}'. Error message: {e!s}")
"Failed to install or upgrade package '%s'.", LOG.exception(e)
package,
extra={
"package": package,
"allow_upgrade": pkgs.allow_upgrade(),
"exception_type": type(e).__name__,
},
)
def compare_versions(self, current: str, target: str) -> bool: def compare_versions(self, current: str, target: str) -> bool:
""" """

View file

@ -1,16 +1,13 @@
import asyncio import asyncio
from collections.abc import Callable import logging
from typing import Any
from aiocron import Cron from aiocron import Cron
from aiohttp import web from aiohttp import web
from app.library.log import get_logger
from .Events import EventBus, Events from .Events import EventBus, Events
from .Singleton import Singleton from .Singleton import Singleton
LOG = get_logger() LOG = logging.getLogger("scheduler")
class Scheduler(metaclass=Singleton): class Scheduler(metaclass=Singleton):
@ -49,13 +46,10 @@ class Scheduler(metaclass=Singleton):
for job in self._jobs: for job in self._jobs:
try: try:
self._jobs[job].stop() self._jobs[job].stop()
LOG.debug("Stopped job '%s'.", job, extra={"job_id": job}) LOG.debug(f"Stopped job '{job}'.")
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to stop scheduler job '%s'.", LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.")
job,
extra={"job_id": job, "exception_type": type(e).__name__},
)
self._jobs = {} self._jobs = {}
@ -68,7 +62,7 @@ class Scheduler(metaclass=Singleton):
if data and data.data: if data and data.data:
self.add(**data.data) self.add(**data.data)
EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{type(self).__name__}.add") EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add")
def get_all(self) -> dict[str, Cron]: def get_all(self) -> dict[str, Cron]:
"""Return the jobs.""" """Return the jobs."""
@ -101,7 +95,7 @@ class Scheduler(metaclass=Singleton):
return id in self._jobs return id in self._jobs
def add( def add(
self, timer: str, func: Callable[..., Any], args: tuple = (), kwargs: dict | None = None, id: str | None = None self, timer: str, func: callable, args: tuple = (), kwargs: dict | None = None, id: str | None = None
) -> str: ) -> str:
""" """
Add a job to the schedule. Add a job to the schedule.
@ -133,7 +127,7 @@ class Scheduler(metaclass=Singleton):
self._jobs[job_id] = job self._jobs[job_id] = job
LOG.debug("Added job '%s' to run on '%s'.", job_id, timer, extra={"job_id": job_id, "timer": timer}) LOG.debug(f"Added '{job_id}' to the scheduler to run on '{timer}'.")
return job_id return job_id
@ -157,15 +151,12 @@ class Scheduler(metaclass=Singleton):
try: try:
self._jobs[id].stop() self._jobs[id].stop()
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to stop scheduler job '%s'.", LOG.error(f"Failed to stop job '{id}'. Error message '{e!s}'.")
id,
extra={"job_id": id, "exception_type": type(e).__name__},
)
return False return False
del self._jobs[id] del self._jobs[id]
LOG.debug("Removed job '%s' from the scheduler.", id, extra={"job_id": id}) LOG.debug(f"Removed job '{id}' from the scheduler.")
return True return True
return False return False

View file

@ -1,13 +1,12 @@
import inspect import inspect
from collections.abc import Callable import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Annotated, Any, TypeVar, get_args, get_origin, get_type_hints 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 from app.library.Singleton import Singleton
T = TypeVar("T") T = TypeVar("T")
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
def _unwrap_annotation(ann: Any) -> Any: def _unwrap_annotation(ann: Any) -> Any:
@ -118,7 +117,7 @@ class Services(metaclass=Singleton):
return candidates[0] return candidates[0]
def _build_call_args(self, handler: Callable[..., Any], overrides: dict[str, Any]) -> dict[str, Any]: def _build_call_args(self, handler: callable, overrides: dict[str, Any]) -> dict[str, Any]:
sig: inspect.Signature = inspect.signature(handler) sig: inspect.Signature = inspect.signature(handler)
try: try:
@ -170,10 +169,10 @@ class Services(metaclass=Singleton):
return resolved return resolved
async def handle_async(self, handler: Callable[..., Any], **kwargs) -> Any: async def handle_async(self, handler: callable, **kwargs) -> Any:
resolved: dict[str, Any] = self._build_call_args(handler, kwargs) resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
return await handler(**resolved) return await handler(**resolved)
def handle_sync(self, handler: Callable[..., Any], **kwargs) -> Any: def handle_sync(self, handler: callable, **kwargs) -> Any:
resolved: dict[str, Any] = self._build_call_args(handler, kwargs) resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
return handler(**resolved) return handler(**resolved)

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import errno import errno
import json import json
import logging
import os import os
import shlex import shlex
import shutil import shutil
@ -15,7 +16,6 @@ from typing import TYPE_CHECKING, Any
from aiohttp import web from aiohttp import web
from app.library.config import Config from app.library.config import Config
from app.library.log import get_logger
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
from app.library.Services import Services from app.library.Services import Services
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
@ -26,7 +26,7 @@ if TYPE_CHECKING:
from aiohttp.web import Request from aiohttp.web import Request
LOG = get_logger() LOG: logging.Logger = logging.getLogger("terminal_manager")
ACTIVE_FILE_NAME = "active.json" ACTIVE_FILE_NAME = "active.json"
METADATA_FILE_NAME = "metadata.json" METADATA_FILE_NAME = "metadata.json"
@ -75,7 +75,7 @@ class TerminalSessionManager(metaclass=Singleton):
self._cleanup_job_id = Scheduler.get_instance().add( self._cleanup_job_id = Scheduler.get_instance().add(
timer=CLEANUP_SCHEDULE, timer=CLEANUP_SCHEDULE,
func=self.cleanup, func=self.cleanup,
id=f"{type(self).__name__}.{type(self).cleanup.__name__}", id=f"{__class__.__name__}.{__class__.cleanup.__name__}",
) )
async def on_startup(self, _: web.Application) -> None: async def on_startup(self, _: web.Application) -> None:
@ -296,7 +296,7 @@ class TerminalSessionManager(metaclass=Singleton):
master_fd: int | None = None master_fd: int | None = None
try: try:
LOG.info("Starting terminal session '%s' command.", session_id, extra={"session_id": session_id}) LOG.info("Cli command from client. '%s'", command)
args = ["yt-dlp", *shlex.split(command, posix=os.name != "nt")] args = ["yt-dlp", *shlex.split(command, posix=os.name != "nt")]
env_vars = self._build_env() env_vars = self._build_env()
@ -343,10 +343,7 @@ class TerminalSessionManager(metaclass=Singleton):
try: try:
os.close(slave_fd) os.close(slave_fd)
except Exception as exc: except Exception as exc:
LOG.exception( LOG.error("Error closing PTY. '%s'.", str(exc))
"Failed to close the PTY slave file descriptor.",
extra={"exception_type": type(exc).__name__},
)
read_task = asyncio.create_task( read_task = asyncio.create_task(
self._read_process_output(session_id=session_id, proc=proc, use_pty=use_pty, master_fd=master_fd), self._read_process_output(session_id=session_id, proc=proc, use_pty=use_pty, master_fd=master_fd),
@ -359,11 +356,8 @@ class TerminalSessionManager(metaclass=Singleton):
final_status = "interrupted" final_status = "interrupted"
except Exception as exc: except Exception as exc:
final_status = "failed" final_status = "failed"
LOG.exception( LOG.error("CLI execute exception was thrown.")
"Terminal session '%s' command failed.", LOG.exception(exc)
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)}) await self._append_event(session_id, "output", {"type": "stderr", "line": str(exc)})
finally: finally:
final_status = await self._resolve_final_status(session_id=session_id, status=final_status) final_status = await self._resolve_final_status(session_id=session_id, status=final_status)
@ -377,7 +371,7 @@ class TerminalSessionManager(metaclass=Singleton):
try: try:
return_code = await asyncio.wait_for(proc.wait(), timeout=self._shutdown_timeout) return_code = await asyncio.wait_for(proc.wait(), timeout=self._shutdown_timeout)
except TimeoutError: except TimeoutError:
LOG.warning("Terminal session '%s' did not exit after repeated shutdown attempts.", session_id) LOG.warning("Terminal session '%s' process did not exit cleanly.", session_id)
if proc is not None: if proc is not None:
proc_returncode = getattr(proc, "returncode", None) proc_returncode = getattr(proc, "returncode", None)

View file

@ -1,11 +1,10 @@
import asyncio import asyncio
import logging
import re import re
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from app.library.log import get_logger
from .cache import Cache from .cache import Cache
from .config import Config from .config import Config
from .Events import EventBus, Events from .Events import EventBus, Events
@ -14,7 +13,7 @@ from .Scheduler import Scheduler
from .Singleton import Singleton from .Singleton import Singleton
from .version import APP_VERSION from .version import APP_VERSION
LOG = get_logger() LOG: logging.Logger = logging.getLogger("update_checker")
class UpdateChecker(metaclass=Singleton): class UpdateChecker(metaclass=Singleton):
@ -80,7 +79,7 @@ class UpdateChecker(metaclass=Singleton):
self._notify.subscribe( self._notify.subscribe(
event=Events.STARTED, event=Events.STARTED,
callback=event_handler, callback=event_handler,
name=f"{UpdateChecker.__name__}.{type(self).attach.__name__}", name=f"{__class__.__name__}.{__class__.attach.__name__}",
) )
self._schedule_check() self._schedule_check()
@ -108,7 +107,7 @@ class UpdateChecker(metaclass=Singleton):
self._job_id = self._scheduler.add( self._job_id = self._scheduler.add(
timer=timer, timer=timer,
func=lambda: asyncio.create_task(self.check_for_updates()), func=lambda: asyncio.create_task(self.check_for_updates()),
id=f"{UpdateChecker.__name__}.{self.check_for_updates.__name__}", id=f"{__class__.__name__}.{self.check_for_updates.__name__}",
) )
async def check_for_updates(self) -> tuple[tuple[str, str | None], tuple[str, str | None]]: async def check_for_updates(self) -> tuple[tuple[str, str | None], tuple[str, str | None]]:
@ -146,11 +145,7 @@ class UpdateChecker(metaclass=Singleton):
strip_v_prefix: bool = False, strip_v_prefix: bool = False,
) -> tuple[str, str | None]: ) -> tuple[str, str | None]:
try: try:
LOG.info( LOG.info(f"Checking for {name} updates...")
"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) client = get_async_client(use_curl=False)
response = await client.get( response = await client.get(
@ -160,59 +155,32 @@ class UpdateChecker(metaclass=Singleton):
) )
if 200 != response.status_code: if 200 != response.status_code:
LOG.warning( LOG.warning(f"Failed to check for {name} updates: HTTP {response.status_code}")
"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) return ("error", None)
data: dict[str, Any] = response.json() data: dict[str, Any] = response.json()
latest_tag: str = data.get("tag_name", "") latest_tag: str = data.get("tag_name", "")
if not latest_tag: if not latest_tag:
LOG.warning( LOG.warning(f"No tag_name found in {name} GitHub release data.")
"No tag_name found in %s GitHub release data.",
name,
extra={"target_name": name, "api_url": api_url},
)
return ("error", None) return ("error", None)
compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version 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 compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag
if self._compare_versions(compare_current, compare_latest): if self._compare_versions(compare_current, compare_latest):
LOG.warning( LOG.warning(f"{name} update available: {current_version} -> {latest_tag}")
"%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) result = ("update_available", latest_tag)
await self._cache.aset(cache_key, result, self.CACHE_DURATION) await self._cache.aset(cache_key, result, self.CACHE_DURATION)
return result return result
LOG.info( LOG.info(f"No {name} updates available.")
"%s is already up to date.",
name,
extra={"target_name": name, "current_version": current_version},
)
result = ("up_to_date", None) result = ("up_to_date", None)
await self._cache.aset(cache_key, result, self.CACHE_DURATION) await self._cache.aset(cache_key, result, self.CACHE_DURATION)
return result return result
except Exception as e: except Exception as e:
LOG.exception( LOG.exception(e)
"Failed to check whether %s has an update available.", LOG.error(f"Error checking for {name} updates: {e!s}")
name,
extra={
"target_name": name,
"api_url": api_url,
"cache_key": cache_key,
"exception_type": type(e).__name__,
},
)
return ("error", None) return ("error", None)
async def _check_app_version(self) -> tuple[str, str | None]: async def _check_app_version(self) -> tuple[str, str | None]:
@ -235,7 +203,7 @@ class UpdateChecker(metaclass=Singleton):
async def _check_ytdlp_version(self) -> tuple[str, str | None]: async def _check_ytdlp_version(self) -> tuple[str, str | None]:
current_version: str = self._config._ytdlp_version() current_version: str = self._config._ytdlp_version()
if not current_version or "0.0.0" == current_version: if not current_version or "0.0.0" == current_version:
LOG.warning("Skipping the yt-dlp update check because the current version could not be determined.") LOG.warning("Could not determine yt-dlp version, skipping yt-dlp update check.")
return ("error", None) return ("error", None)
status, new_version = await self._check_github_version( status, new_version = await self._check_github_version(
@ -270,12 +238,5 @@ class UpdateChecker(metaclass=Singleton):
return parse_version(latest) > parse_version(current) return parse_version(latest) > parse_version(current)
except Exception as e: except Exception as e:
LOG.warning( LOG.warning(f"Error comparing versions '{current}' vs '{latest}': {e}")
"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 return False

View file

@ -2,13 +2,11 @@ import base64
import copy import copy
import glob import glob
import ipaddress import ipaddress
import json
import logging import logging
import os import os
import re import re
import socket import socket
import time import time
import traceback
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from functools import lru_cache, wraps from functools import lru_cache, wraps
@ -18,9 +16,7 @@ from typing import Any
from Crypto.Cipher import AES from Crypto.Cipher import AES
from app.library.log import get_logger LOG: logging.Logger = logging.getLogger("Utils")
LOG = get_logger()
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"} ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
"Allowed subtitle file extensions." "Allowed subtitle file extensions."
@ -40,179 +36,11 @@ TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
class FileLogFormatter(logging.Formatter): class FileLogFormatter(logging.Formatter):
def formatTime(self, record, datefmt=None): # noqa: N802 def formatTime(self, record, datefmt=None): # noqa: ARG002, N802
_ = datefmt
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
LOG_RECORD_ATTRS: set[str] = set(logging.makeLogRecord({}).__dict__) | {"asctime", "message"} def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
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. Decorator that applies an LRU cache with a time-to-live (TTL) to a function.
Supports both synchronous and asynchronous functions. Supports both synchronous and asynchronous functions.
@ -282,9 +110,8 @@ def timed_lru_cache(ttl_seconds: float, max_size: int = 128):
return _CacheInfo(hits=0, misses=len(cache), maxsize=max_size, currsize=len(cache)) return _CacheInfo(hits=0, misses=len(cache), maxsize=max_size, currsize=len(cache))
async_wrapper_cache: Any = async_wrapper async_wrapper.cache_clear = cache_clear
async_wrapper_cache.cache_clear = cache_clear async_wrapper.cache_info = cache_info
async_wrapper_cache.cache_info = cache_info
return async_wrapper return async_wrapper
# For sync functions, use the original implementation # For sync functions, use the original implementation
@ -312,9 +139,8 @@ def timed_lru_cache(ttl_seconds: float, max_size: int = 128):
return result return result
# expose cache_clear, cache_info # expose cache_clear, cache_info
sync_wrapper_cache: Any = sync_wrapper sync_wrapper.cache_clear = cached.cache_clear
sync_wrapper_cache.cache_clear = cached.cache_clear sync_wrapper.cache_info = cached.cache_info
sync_wrapper_cache.cache_info = cached.cache_info
return sync_wrapper return sync_wrapper
return decorator return decorator
@ -385,12 +211,7 @@ def _is_safe_key(key: Any) -> bool:
def merge_dict( def merge_dict(
source: dict, source: dict, destination: dict, max_depth: int = 50, max_list_size: int = 10000, _depth: int = 0, _seen: set = None
destination: dict,
max_depth: int = 50,
max_list_size: int = 10000,
_depth: int = 0,
_seen: set | None = None,
) -> dict: ) -> dict:
""" """
Merge data from source into destination. Merge data from source into destination.
@ -481,7 +302,7 @@ def merge_dict(
return destination_copy return destination_copy
def check_id(file: Path) -> bool | Path: def check_id(file: Path) -> bool | str:
""" """
Check if we are able to get an id from the file name. Check if we are able to get an id from the file name.
if so check if any video file with the same id exists. if so check if any video file with the same id exists.
@ -500,8 +321,6 @@ def check_id(file: Path) -> bool | Path:
return False return False
id: str | None = match.groupdict().get("id") id: str | None = match.groupdict().get("id")
if id is None:
return False
try: try:
if not file.parent.exists(): if not file.parent.exists():
@ -516,11 +335,7 @@ def check_id(file: Path) -> bool | Path:
return f.absolute() return f.absolute()
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Error checking file '{file}': {e!s}")
"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
return False return False
@ -556,9 +371,9 @@ def validate_url(url: str, allow_internal: bool = False) -> bool:
from yarl import URL from yarl import URL
parsed_url = URL(url) parsed_url = URL(url)
except ValueError as exc: except ValueError:
msg = "Invalid URL." msg = "Invalid URL."
raise ValueError(msg) from exc raise ValueError(msg) # noqa: B904
# Check allowed schemes # Check allowed schemes
if parsed_url.scheme not in ["http", "https"]: if parsed_url.scheme not in ["http", "https"]:
@ -576,11 +391,7 @@ def validate_url(url: str, allow_internal: bool = False) -> bool:
msg = "Access to internal urls or private networks is not allowed." msg = "Access to internal urls or private networks is not allowed."
raise ValueError(msg) raise ValueError(msg)
except socket.gaierror as e: except socket.gaierror as e:
LOG.exception( LOG.error(f"Error resolving hostname '{hostname}': {e!s}")
"Failed to resolve hostname '%s'.",
hostname,
extra={"hostname": hostname, "exception_type": type(e).__name__},
)
msg = "Invalid hostname." msg = "Invalid hostname."
raise ValueError(msg) from e raise ValueError(msg) from e
@ -607,7 +418,7 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool:
@timed_lru_cache(ttl_seconds=60, max_size=256) @timed_lru_cache(ttl_seconds=60, max_size=256)
def get_file_sidecar(file: Path | None = None) -> dict[str, list[dict[str, Any]]]: def get_file_sidecar(file: Path | None = None) -> dict[dict]:
""" """
Get sidecar files for the given file. Get sidecar files for the given file.
@ -708,46 +519,16 @@ def rename_file(old_path: Path, new_name: str) -> tuple[Path, list[tuple[Path, P
renamed_sidecar: Path = old_sidecar.rename(new_sidecar) renamed_sidecar: Path = old_sidecar.rename(new_sidecar)
renamed_sidecars.append((old_sidecar, renamed_sidecar)) renamed_sidecars.append((old_sidecar, renamed_sidecar))
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed to rename sidecar '{old_sidecar}': {e}")
"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: try:
renamed_main.rename(old_path) renamed_main.rename(old_path)
except OSError as rollback_error: except OSError:
LOG.exception( LOG.error(f"Failed to rollback main file rename from '{renamed_main}' to '{old_path}'")
"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 raise
return renamed_main, renamed_sidecars return renamed_main, renamed_sidecars
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed to rename '{old_path}' to '{new_path}': {e}")
"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 raise
@ -805,62 +586,24 @@ def move_file(old_path: Path, target_dir: Path) -> tuple[Path, list[tuple[Path,
moved_sidecar: Path = old_sidecar.rename(new_sidecar) moved_sidecar: Path = old_sidecar.rename(new_sidecar)
renamed_sidecars.append((old_sidecar, moved_sidecar)) renamed_sidecars.append((old_sidecar, moved_sidecar))
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed to move sidecar '{old_sidecar}': {e}")
"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: try:
moved_main.rename(old_path) moved_main.rename(old_path)
# Rollback any sidecars that were already moved # Rollback any sidecars that were already moved
for rolled_back_old, rolled_back_new in renamed_sidecars: for rolled_back_old, rolled_back_new in renamed_sidecars:
try: try:
rolled_back_new.rename(rolled_back_old) rolled_back_new.rename(rolled_back_old)
except OSError as rollback_error: except OSError:
LOG.exception( LOG.error(
"Failed to roll back sidecar move from '%s' to '%s'.", f"Failed to rollback sidecar move from '{rolled_back_new}' to '{rolled_back_old}'"
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 as rollback_error: except OSError:
LOG.exception( LOG.error(f"Failed to rollback main file move from '{moved_main}' to '{old_path}'")
"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 raise
return moved_main, renamed_sidecars return moved_main, renamed_sidecars
except OSError as e: except OSError as e:
LOG.exception( LOG.error(f"Failed to move '{old_path}' to '{new_path}': {e}")
"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 raise
@ -936,22 +679,18 @@ def get_file(download_path: str | Path, file: str | Path) -> tuple[Path, int]:
download_path = Path(download_path) download_path = Path(download_path)
try: try:
realFile: Path = Path(calc_download_path(base_path=download_path, folder=str(file), create_path=False)) realFile: str = Path(calc_download_path(base_path=download_path, folder=str(file), create_path=False))
if realFile.exists(): if realFile.exists():
return (realFile, 200) return (realFile, 200)
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Error calculating download path. {e!s}")
"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) return (Path(file), 404)
possibleFile: bool | Path = check_id(file=realFile) possibleFile: bool | str = check_id(file=realFile)
if not isinstance(possibleFile, Path): if not possibleFile:
return (realFile, 404) return (realFile, 404)
return (possibleFile, 302) return (Path(possibleFile), 302)
def encrypt_data(data: str, key: bytes) -> str: def encrypt_data(data: str, key: bytes) -> str:
@ -973,7 +712,7 @@ def encrypt_data(data: str, key: bytes) -> str:
return base64.urlsafe_b64encode(iv + ciphertext + tag).decode() return base64.urlsafe_b64encode(iv + ciphertext + tag).decode()
def decrypt_data(data: str, key: bytes) -> str | None: def decrypt_data(data: str, key: bytes) -> str:
""" """
Decrypts AES-GCM encrypted data Decrypts AES-GCM encrypted data
@ -986,8 +725,8 @@ def decrypt_data(data: str, key: bytes) -> str | None:
""" """
try: try:
raw: bytes = base64.urlsafe_b64decode(data) data = base64.urlsafe_b64decode(data)
iv, ciphertext, tag = raw[:12], raw[12:-16], raw[-16:] iv, ciphertext, tag = data[:12], data[12:-16], data[-16:]
cipher = AES.new(key, AES.MODE_GCM, nonce=iv) cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
plaintext = cipher.decrypt_and_verify(ciphertext, tag) plaintext = cipher.decrypt_and_verify(ciphertext, tag)
return plaintext.decode() return plaintext.decode()
@ -998,7 +737,7 @@ def decrypt_data(data: str, key: bytes) -> str | None:
def get( def get(
data: dict | list, data: dict | list,
path: str | list | None = None, path: str | list | None = None,
default: Any = None, default: any = None,
separator=".", separator=".",
): ):
""" """
@ -1115,42 +854,22 @@ def get_files(
try: try:
dir_path = dir_path.resolve() dir_path = dir_path.resolve()
except OSError as e: except OSError as e:
LOG.warning( LOG.warning(f"Failed to resolve '{dir}' - {e}")
"Failed to resolve '%s': %s", return []
dir,
e,
extra={"requested_dir": dir, "base_path": str(base_path), "exception_type": type(e).__name__},
exc_info=True,
)
return [], 0
try: try:
dir_path.relative_to(base_path) dir_path.relative_to(base_path)
except ValueError: except ValueError:
LOG.warning( LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.")
"Invalid path '%s': must be inside '%s'.", return []
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)): if not str(dir_path).startswith(str(base_path)):
LOG.warning( LOG.warning(f"Invalid path: '{dir_path}' - must be inside '{base_path}'.")
"Invalid path '%s': must be inside '%s'.", return []
dir_path,
base_path,
extra={"path": str(dir_path), "base_path": str(base_path)},
)
return [], 0
if not dir_path.is_dir(): if not dir_path.is_dir():
LOG.warning( LOG.warning(f"Invalid path: '{dir_path}' - must be a directory.")
"Invalid path '%s': must be a directory.", return []
dir_path,
extra={"path": str(dir_path), "base_path": str(base_path)},
)
return [], 0
contents: list = [] contents: list = []
for file in dir_path.iterdir(): for file in dir_path.iterdir():
@ -1163,28 +882,13 @@ def get_files(
test: Path = file.resolve() test: Path = file.resolve()
test.relative_to(base_path) test.relative_to(base_path)
if not str(test).startswith(str(base_path)): if not str(test).startswith(str(base_path)):
LOG.warning( LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.")
"Invalid symlink '%s': must resolve inside '%s'.",
file,
base_path,
extra={"path": str(file), "resolved_path": str(test), "base_path": str(base_path)},
)
continue continue
except ValueError: except ValueError:
LOG.warning( LOG.warning(f"Invalid symlink: '{file}' - must resolve inside '{base_path}'.")
"Invalid symlink '%s': must resolve inside '%s'.",
file,
base_path,
extra={"path": str(file), "base_path": str(base_path)},
)
continue continue
except OSError as e: except OSError:
LOG.warning( LOG.warning(f"Skipping broken symlink: {file}")
"Skipping broken symlink '%s'.",
file,
extra={"path": str(file), "base_path": str(base_path), "exception_type": type(e).__name__},
exc_info=True,
)
continue continue
content_type = None content_type = None
@ -1309,7 +1013,7 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
try: try:
from http.cookiejar import MozillaCookieJar from http.cookiejar import MozillaCookieJar
cookies = MozillaCookieJar(str(file), delayload=False, policy=None) cookies = MozillaCookieJar(str(file), None, None)
cookies.load() cookies.load()
return (True, cookies) return (True, cookies)
@ -1374,11 +1078,7 @@ def delete_dir(dir: Path) -> bool:
dir.rmdir() dir.rmdir()
return True return True
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to delete directory '{dir}': {e}")
"Failed to delete directory '%s'.",
dir,
extra={"dir": str(dir), "exception_type": type(e).__name__},
)
return False return False
@ -1426,11 +1126,7 @@ def load_modules(root_path: Path, directory: Path):
try: try:
importlib.import_module(full_name) importlib.import_module(full_name)
except ImportError as e: except ImportError as e:
LOG.exception( LOG.error(f"Failed to import module '{full_name}': {e}")
"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]]: def parse_tags(text: str) -> tuple[str, dict[str, str | bool]]:
@ -1488,6 +1184,35 @@ def str_to_dt(time_str: str, now=None) -> datetime:
return dt 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: def get_channel_images(thumbnails: list[dict]) -> dict:
""" """
Extract channel images from a list of thumbnail dictionaries. Extract channel images from a list of thumbnail dictionaries.

View file

@ -1,20 +1,6 @@
import sys
from typing import Any 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: def get_value(value: Any) -> Any:
""" """
Return the result of calling `value` if it is callable, else return `value`. Return the result of calling `value` if it is callable, else return `value`.
@ -105,19 +91,14 @@ def ag(
return val return val
return get_value(default) return get_value(default)
key = path key = path
if isinstance(array, dict): if isinstance(array, dict) and key in array and array[key] is not None:
found, value = _dict_value(array, key) return array[key]
if found:
return value
if not isinstance(key, str) or separator not in key: if not isinstance(key, str) or separator not in key:
return get_value(default) return get_value(default)
current = array current = array
for segment in key.split(separator): for segment in key.split(separator):
if isinstance(current, dict): if isinstance(current, dict) and segment in current and current[segment] is not None:
found, value = _dict_value(current, segment) current = current[segment]
if not found:
return get_value(default)
current = value
elif isinstance(current, list): elif isinstance(current, list):
try: try:
idx = int(segment) idx = int(segment)
@ -169,8 +150,7 @@ def ag_exists(data: dict[Any, Any] | list[Any] | object, path: str | int, separa
except TypeError: except TypeError:
return False return False
if isinstance(data, dict): if isinstance(data, dict):
found, _ = _dict_value(data, path) if path in data and data[path] is not None:
if found:
return True return True
elif isinstance(data, list) and isinstance(path, int): elif isinstance(data, list) and isinstance(path, int):
return 0 <= path < len(data) and data[path] is not None return 0 <= path < len(data) and data[path] is not None
@ -178,11 +158,8 @@ def ag_exists(data: dict[Any, Any] | list[Any] | object, path: str | int, separa
segments = path_str.split(separator) segments = path_str.split(separator)
current = data current = data
for seg in segments: for seg in segments:
if isinstance(current, dict): if isinstance(current, dict) and seg in current and current[seg] is not None:
found, value = _dict_value(current, seg) current = current[seg]
if not found:
return False
current = value
elif isinstance(current, list): elif isinstance(current, list):
try: try:
idx = int(seg) idx = int(seg)
@ -212,16 +189,17 @@ def ag_delete(
The modified structure. 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): if not isinstance(data, dict | list):
try: try:
data = vars(data) data = vars(data)
except TypeError: except TypeError:
return data # type: ignore return data # type: ignore
if isinstance(path, list | tuple): if isinstance(data, dict) and path in data:
for p in path: del data[path]
ag_delete(data, p, separator)
return data
if isinstance(data, dict) and _dict_delete(data, path):
return data return data
if isinstance(data, list) and isinstance(path, int): if isinstance(data, list) and isinstance(path, int):
if 0 <= path < len(data): if 0 <= path < len(data):
@ -232,12 +210,8 @@ def ag_delete(
last = segments.pop() last = segments.pop()
current = data current = data
for seg in segments: for seg in segments:
if isinstance(current, dict): if isinstance(current, dict) and seg in current:
found, value = _dict_value(current, seg) current = current[seg]
if not found:
current = None
break
current = value
elif isinstance(current, list): elif isinstance(current, list):
try: try:
idx = int(seg) idx = int(seg)
@ -254,8 +228,8 @@ def ag_delete(
break break
if current is None: if current is None:
return data return data
if isinstance(current, dict): if isinstance(current, dict) and last in current:
_dict_delete(current, last) del current[last]
elif isinstance(current, list): elif isinstance(current, list):
try: try:
idx = int(last) idx = int(last)
@ -272,12 +246,10 @@ def run_tests():
assert ag_set(d, "a.c.d", 42) == {"a": {"b": 1, "c": {"d": 42}}} assert ag_set(d, "a.c.d", 42) == {"a": {"b": 1, "c": {"d": 42}}}
def test_ag_set_overwrite_error(): def test_ag_set_overwrite_error():
error = ""
try: try:
ag_set({"a": 1}, "a.b", 2) ag_set({"a": 1}, "a.b", 2)
except RuntimeError as e: except RuntimeError as e:
error = str(e) assert "Cannot set value at path" in str(e) # noqa: PT017
assert "Cannot set value at path" in error
def test_ag_get_value_callable_and_value(): def test_ag_get_value_callable_and_value():
assert get_value(5) == 5 assert get_value(5) == 5
@ -348,9 +320,9 @@ def run_tests():
]: ]:
try: try:
test() test()
sys.stdout.write(f"PASS: {test.__name__}\n") print(f"PASS: {test.__name__}") # noqa: T201
except AssertionError: except AssertionError:
sys.stdout.write(f"FAIL {test.__name__}\n") print(f"FAIL {test.__name__}") # noqa: T201
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,17 +1,17 @@
import hashlib import hashlib
import logging
import threading import threading
import time import time
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from app.library.log import get_logger
from app.library.Services import Services from app.library.Services import Services
from .Scheduler import Scheduler from .Scheduler import Scheduler
from .Singleton import ThreadSafe from .Singleton import ThreadSafe
LOG = get_logger() LOG = logging.getLogger("cache")
class Cache(metaclass=ThreadSafe): class Cache(metaclass=ThreadSafe):
@ -38,7 +38,7 @@ class Cache(metaclass=ThreadSafe):
Scheduler.get_instance().add( Scheduler.get_instance().add(
timer="* * * * *", timer="* * * * *",
func=self.cleanup, func=self.cleanup,
id=f"{type(self).__name__}.{type(self).cleanup.__name__}", id=f"{__class__.__name__}.{__class__.cleanup.__name__}",
) )
def set(self, key: str, value: Any, ttl: float | None = None) -> None: def set(self, key: str, value: Any, ttl: float | None = None) -> None:
@ -175,8 +175,4 @@ class Cache(metaclass=ThreadSafe):
del self._cache[key] del self._cache[key]
if expired_keys: if expired_keys:
LOG.debug( LOG.debug(f"Cleaned up {len(expired_keys)} expired cache entries.")
"Cleaned up %s expired cache entries.",
len(expired_keys),
extra={"expired_count": len(expired_keys)},
)

View file

@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import http.cookiejar import http.cookiejar
import logging
from abc import ABC from abc import ABC
from collections.abc import Callable from collections.abc import Callable
from typing import Any, ClassVar, cast from typing import Any, ClassVar
from yt_dlp.networking.common import ( from yt_dlp.networking.common import (
_REQUEST_HANDLERS, _REQUEST_HANDLERS,
@ -19,9 +20,8 @@ from yt_dlp.networking.exceptions import HTTPError
from yt_dlp.utils.networking import clean_headers from yt_dlp.utils.networking import clean_headers
from app.library.cf_solver_shared import CACHE, is_cf_challenge, solver from app.library.cf_solver_shared import CACHE, is_cf_challenge, solver
from app.library.log import get_logger
LOG = get_logger() LOG: logging.Logger = logging.getLogger(__name__)
SolverFn = Callable[[Request, Response, RequestHandler], Request | None] SolverFn = Callable[[Request, Response, RequestHandler], Request | None]
@ -215,7 +215,7 @@ class CFSolverRH(RequestHandler, ABC):
def _send(self, request: Request) -> Response: def _send(self, request: Request) -> Response:
host: str = self._get_host(request.url) host: str = self._get_host(request.url)
if host and (cached := CACHE.get(host)): if host and (cached := CACHE.get(host)):
LOG.info("Injecting cached Cloudflare cookies for '%s'.", host, extra={"host": host}) LOG.info(f"Injecting cached Cloudflare cookies for '{host}'.")
self._apply_solution(cached, request.url, request.headers, self._get_cookiejar(request)) self._apply_solution(cached, request.url, request.headers, self._get_cookiejar(request))
director: RequestDirector = self._build_fallback() director: RequestDirector = self._build_fallback()
@ -223,13 +223,11 @@ class CFSolverRH(RequestHandler, ABC):
try: try:
response: Response = director.send(request) response: Response = director.send(request)
except HTTPError as error: except HTTPError as error:
if error.response and is_cf_challenge( if error.response and is_cf_challenge(getattr(error.response, "status", None), error.response.headers):
getattr(error.response, "status", None), cast("Any", error.response.headers)
):
return self._retry_with_clearance(request, error.response, director) return self._retry_with_clearance(request, error.response, director)
raise raise
if is_cf_challenge(getattr(response, "status", None), cast("Any", response.headers)): if is_cf_challenge(getattr(response, "status", None), response.headers):
return self._retry_with_clearance(request, response, director) return self._retry_with_clearance(request, response, director)
return response return response

View file

@ -1,20 +1,17 @@
# flake8: noqa: S310
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import time import time
import urllib.request import urllib.request
from typing import TYPE_CHECKING, Any from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
from app.library.log import get_logger
from .cache import Cache from .cache import Cache
if TYPE_CHECKING:
from collections.abc import Mapping
CACHE: Cache = Cache() CACHE: Cache = Cache()
LOG = get_logger() LOG: logging.Logger = logging.getLogger("cf_solver")
def solver(url: str, cookies: list[dict[str, Any]], user_agent: str | None) -> dict[str, Any] | None: def solver(url: str, cookies: list[dict[str, Any]], user_agent: str | None) -> dict[str, Any] | None:
@ -59,36 +56,23 @@ def solver(url: str, cookies: list[dict[str, Any]], user_agent: str | None) -> d
if user_agent: if user_agent:
payload.setdefault("headers", {})["User-Agent"] = user_agent payload.setdefault("headers", {})["User-Agent"] = user_agent
req = urllib.request.Request( # noqa: S310 req = urllib.request.Request(
endpoint, endpoint,
data=json.dumps(payload).encode("utf-8"), data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
method="POST", method="POST",
) )
LOG.info( LOG.info(f"Solving Cloudflare challenge for '{host}' via FlareSolverr.")
"Solving Cloudflare challenge for '%s' via FlareSolverr.", host, extra={"host": host, "endpoint": endpoint}
)
start_time = time.time() start_time = time.time()
with urllib.request.urlopen(req, timeout=float(config.flaresolverr_client_timeout)) as resp: # noqa: S310 with urllib.request.urlopen(req, timeout=float(config.flaresolverr_client_timeout)) as resp:
result = json.loads(resp.read().decode("utf-8")) result = json.loads(resp.read().decode("utf-8"))
if "ok" != result.get("status"): if "ok" != result.get("status"):
LOG.error( LOG.error(f"FlareSolverr failed to solve challenge for '{host}': {result.get('message')}")
"FlareSolverr failed to solve challenge for '%s': %s",
host,
result.get("message"),
extra={"host": host, "endpoint": endpoint, "solver_message": result.get("message")},
)
return None return None
elapsed_s: float = time.time() - start_time LOG.info(f"FlareSolverr successfully solved challenge for '{host}'. (Took {time.time() - start_time:.2f} seconds)")
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 {} solution = result.get("solution") or {}
CACHE.set( CACHE.set(
@ -100,7 +84,7 @@ def solver(url: str, cookies: list[dict[str, Any]], user_agent: str | None) -> d
return CACHE.get(host) return CACHE.get(host)
def is_cf_challenge(status: int | None, headers: Mapping[str, Any] | None) -> bool: def is_cf_challenge(status: int | None, headers: dict[str, Any] | None) -> bool:
""" """
Determine whether a response indicates a Cloudflare challenge. Determine whether a response indicates a Cloudflare challenge.
""" """

View file

@ -2,7 +2,6 @@ import logging
import multiprocessing import multiprocessing
import os import os
import re import re
import shutil
import sys import sys
import time import time
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@ -13,25 +12,14 @@ from typing import TYPE_CHECKING, Any
import coloredlogs import coloredlogs
from dotenv import load_dotenv from dotenv import load_dotenv
from app.library.log import get_logger
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import JsonLogFormatter from .Utils import FileLogFormatter
from .version import APP_BRANCH, APP_BUILD_DATE, APP_COMMIT_SHA, APP_VERSION 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: if TYPE_CHECKING:
from subprocess import CompletedProcess 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." "Supported encoder names in order of preference."
@ -48,6 +36,9 @@ class Config(metaclass=Singleton):
download_path: str = "." download_path: str = "."
"""The path to the download directory.""" """The path to the download directory."""
download_path_depth: int = 2
"""How many subdirectories to show in auto complete."""
download_info_expires: int = 10800 download_info_expires: int = 10800
"""How long (in seconds) the download info is valid before it needs to be re-extracted.""" """How long (in seconds) the download info is valid before it needs to be re-extracted."""
@ -84,6 +75,9 @@ class Config(metaclass=Singleton):
log_level: str = "info" log_level: str = "info"
"""The log level to use for the application.""" """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 = "/" base_path: str = "/"
"""The base path to use for the application.""" """The base path to use for the application."""
@ -108,9 +102,6 @@ class Config(metaclass=Singleton):
auth_password: str | None = None auth_password: str | None = None
"""The password to use for basic authentication.""" """The password to use for basic authentication."""
disable_exec: bool = False
"""Strip some dangerous yt-dlp options."""
remove_files: bool = False remove_files: bool = False
"""Remove downloaded files when removing the record.""" """Remove downloaded files when removing the record."""
@ -180,7 +171,7 @@ class Config(metaclass=Singleton):
file_logging: bool = True file_logging: bool = True
"Enable file logging." "Enable file logging."
secret_key: str | bytes secret_key: str
"The secret key to use for the application." "The secret key to use for the application."
tasks_handler_timer: str = "15 */1 * * *" tasks_handler_timer: str = "15 */1 * * *"
@ -228,9 +219,6 @@ class Config(metaclass=Singleton):
default_pagination: int = 50 default_pagination: int = 50
"""The default number of items per page for pagination.""" """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 task_handler_random_delay: float = 60.0
"""The maximum random delay in seconds before starting a task handler.""" """The maximum random delay in seconds before starting a task handler."""
@ -283,10 +271,10 @@ class Config(metaclass=Singleton):
"max_workers_per_extractor", "max_workers_per_extractor",
"extract_info_timeout", "extract_info_timeout",
"debugpy_port", "debugpy_port",
"download_path_depth",
"download_info_expires", "download_info_expires",
"auto_clear_history_days", "auto_clear_history_days",
"default_pagination", "default_pagination",
"queue_display_limit",
"extract_info_concurrency", "extract_info_concurrency",
"thumb_concurrency", "thumb_concurrency",
"flaresolverr_max_timeout", "flaresolverr_max_timeout",
@ -316,7 +304,6 @@ class Config(metaclass=Singleton):
"check_for_updates", "check_for_updates",
"thumb_generate", "thumb_generate",
"thumb_sidecar", "thumb_sidecar",
"disable_exec",
) )
"The variables that are booleans." "The variables that are booleans."
@ -326,7 +313,6 @@ class Config(metaclass=Singleton):
_frontend_vars: tuple = ( _frontend_vars: tuple = (
"download_path", "download_path",
"keep_archive", "keep_archive",
"log_level",
"output_template", "output_template",
"started", "started",
"remove_files", "remove_files",
@ -347,7 +333,6 @@ class Config(metaclass=Singleton):
"app_build_date", "app_build_date",
"app_branch", "app_branch",
"default_pagination", "default_pagination",
"queue_display_limit",
"check_for_updates", "check_for_updates",
"new_version", "new_version",
"yt_new_version", "yt_new_version",
@ -375,13 +360,12 @@ class Config(metaclass=Singleton):
def __init__(self, is_native: bool = False): def __init__(self, is_native: bool = False):
baseDefaultPath: str = str(Path(__file__).parent.parent.parent.absolute()) 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") self.config_path = os.environ.get("YTP_CONFIG_PATH", None) or str(Path(baseDefaultPath) / "var" / "config")
envFile: Path = Path(self.config_path) / ".env" envFile: str = Path(self.config_path) / ".env"
if envFile.exists(): if envFile.exists():
LOG.info("Loading environment variables from '%s'.", envFile) logging.info(f"Loading environment variables from '{envFile}'.")
load_dotenv(envFile) load_dotenv(envFile)
self.is_native = is_native self.is_native = is_native
@ -389,7 +373,7 @@ class Config(metaclass=Singleton):
self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or str( self.download_path = os.environ.get("YTP_DOWNLOAD_PATH", None) or str(
Path(baseDefaultPath) / "var" / "downloads" Path(baseDefaultPath) / "var" / "downloads"
) )
self.app_path = str(Path(__file__).parent.parent.absolute()) self.app_path = Path(__file__).parent.parent.absolute()
for k, v in self._get_attributes().items(): for k, v in self._get_attributes().items():
if k.startswith("_") or k in self._manual_vars: if k.startswith("_") or k in self._manual_vars:
@ -411,7 +395,7 @@ class Config(metaclass=Singleton):
for key in re.findall(r"\{.*?\}", v): for key in re.findall(r"\{.*?\}", v):
localKey: str = key[1:-1] localKey: str = key[1:-1]
if localKey not in self.__dict__: if localKey not in self.__dict__:
LOG.error("Config variable '%s' had non-existing config reference '%s'.", k, key) logging.error(f"Config variable '{k}' had non-existing config reference '{key}'.")
sys.exit(1) sys.exit(1)
v: str = v.replace(key, str(getattr(self, localKey))) v: str = v.replace(key, str(getattr(self, localKey)))
@ -449,23 +433,18 @@ class Config(metaclass=Singleton):
encoding="utf-8", encoding="utf-8",
) )
LOG: logging.Logger = logging.getLogger("config")
if self.debug: if self.debug:
try: try:
import debugpy import debugpy
debugpy.listen(("0.0.0.0", self.debugpy_port), in_process_debug_adapter=True) debugpy.listen(("0.0.0.0", self.debugpy_port), in_process_debug_adapter=True)
LOG.info( LOG.info(f"starting debugpy server on '0.0.0.0:{self.debugpy_port}'.")
"Starting debugpy server on '0.0.0.0:%s'.",
self.debugpy_port,
extra={"host": "0.0.0.0", "port": self.debugpy_port},
)
except ImportError: 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: except Exception as e:
LOG.exception( LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
"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(): if (Path(self.config_path) / "ytdlp.cli").exists():
LOG.error("Support for ./ytdlp.cli file is removed, migrate to presets and remove the file.") LOG.error("Support for ./ytdlp.cli file is removed, migrate to presets and remove the file.")
@ -474,16 +453,12 @@ class Config(metaclass=Singleton):
LOG.warning("Keep temp files option is enabled.") LOG.warning("Keep temp files option is enabled.")
if self.auth_password and self.auth_username: if self.auth_password and self.auth_username:
LOG.info( LOG.info(f"Authentication enabled with username '{self.auth_username}'.")
"Basic authentication is enabled for user '%s'.",
self.auth_username,
extra={"auth_username": self.auth_username},
)
if self.file_logging: if self.file_logging:
file_log_level: int | None = getattr(logging, self.log_level.upper(), None) log_level_file: int | None = getattr(logging, self.log_level_file.upper(), None)
if not isinstance(file_log_level, int): if not isinstance(log_level_file, int):
msg = f"Invalid log level '{self.log_level}' specified." msg = f"Invalid file log level '{self.log_level_file}' specified."
raise TypeError(msg) raise TypeError(msg)
loggingPath: Path = Path(self.config_path) / "logs" loggingPath: Path = Path(self.config_path) / "logs"
@ -491,18 +466,18 @@ class Config(metaclass=Singleton):
loggingPath.mkdir(parents=True, exist_ok=True) loggingPath.mkdir(parents=True, exist_ok=True)
handler = TimedRotatingFileHandler( handler = TimedRotatingFileHandler(
filename=loggingPath / "app.jsonl", filename=loggingPath / "app.log",
when="midnight", when="midnight",
backupCount=3, backupCount=3,
encoding="utf-8", encoding="utf-8",
) )
handler.setLevel(file_log_level) handler.setLevel(log_level_file)
formatter = JsonLogFormatter() formatter = FileLogFormatter("%(asctime)s [%(levelname)s.%(name)s]: %(message)s")
handler.setFormatter(formatter) handler.setFormatter(formatter)
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)
key_file: Path = Path(self.config_path) / "secret.key" key_file: str = Path(self.config_path) / "secret.key"
if key_file.exists() and key_file.stat().st_size > 2: if key_file.exists() and key_file.stat().st_size > 2:
with open(key_file, "rb") as f: with open(key_file, "rb") as f:
@ -512,9 +487,16 @@ class Config(metaclass=Singleton):
with open(key_file, "wb") as f: with open(key_file, "wb") as f:
f.write(self.secret_key) f.write(self.secret_key)
self.started = int(time.time()) self.started = time.time()
for _tool, _level in APP_THIRD_PARTY_LOG_LEVELS: _log_levels = (
("httpx", logging.WARNING),
("urllib3.connectionpool", logging.WARNING),
("apprise", logging.WARNING),
("httpcore", logging.INFO),
("aiosqlite", logging.INFO),
)
for _tool, _level in _log_levels:
logging.getLogger(_tool).setLevel(_level) logging.getLogger(_tool).setLevel(_level)
if self.app_env not in ("production", "development"): if self.app_env not in ("production", "development"):
@ -546,7 +528,7 @@ class Config(metaclass=Singleton):
def _get_attributes(self) -> dict: def _get_attributes(self) -> dict:
attrs: dict = {} attrs: dict = {}
vClass: type = self.__class__ vClass: str = self.__class__
for attribute in vClass.__dict__: for attribute in vClass.__dict__:
if attribute.startswith("_"): if attribute.startswith("_"):
@ -589,9 +571,6 @@ class Config(metaclass=Singleton):
data: dict[str, Any] = {k: getattr(self, k) for k in self._frontend_vars} data: dict[str, Any] = {k: getattr(self, k) for k in self._frontend_vars}
data["ytdlp_version"] = Config._ytdlp_version() 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 return data
def get_replacers(self) -> dict[str, str]: def get_replacers(self) -> dict[str, str]:
@ -602,7 +581,7 @@ class Config(metaclass=Singleton):
dict[str, str]: The replacer variables. 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} return {k: getattr(self, k) for k in keys}
@staticmethod @staticmethod
@ -619,21 +598,15 @@ class Config(metaclass=Singleton):
Updates the version of the application using git tags. Updates the version of the application using git tags.
This is used to set the version to the latest git tag. This is used to set the version to the latest git tag.
""" """
LOG = get_logger() git_path: str = Path(__file__).parent / ".." / ".." / ".git"
git_path: Path = Path(__file__).parent / ".." / ".." / ".git"
if not git_path.exists(): if not git_path.exists():
return return
try: try:
import subprocess import subprocess
git = shutil.which("git")
if not git:
LOG.warning("Git executable was not found.")
return
branch_result: CompletedProcess[str] = subprocess.run( branch_result: CompletedProcess[str] = subprocess.run(
[git, "-c", f"safe.directory={git_path.parent!s}", "rev-parse", "--abbrev-ref", "HEAD"], ["git", "-c", f"safe.directory={git_path.parent!s}", "rev-parse", "--abbrev-ref", "HEAD"], # noqa: S607
cwd=os.path.dirname(git_path), cwd=os.path.dirname(git_path),
capture_output=True, capture_output=True,
text=True, text=True,
@ -642,16 +615,16 @@ class Config(metaclass=Singleton):
) )
if 0 != branch_result.returncode: if 0 != branch_result.returncode:
LOG.error("Git rev-parse failed: %s", branch_result.stderr.strip()) logging.error(f"Git rev-parse failed: {branch_result.stderr.strip()}")
return return
branch_name: str = branch_result.stdout.strip() branch_name: str = branch_result.stdout.strip()
if not branch_name: if not branch_name:
LOG.warning("Git branch name is empty.") logging.warning("Git branch name is empty.")
return return
commit_result: CompletedProcess[str] = subprocess.run( commit_result: CompletedProcess[str] = subprocess.run(
[git, "-c", f"safe.directory={git_path.parent!s}", "log", "-1", "--format=%ct_%H"], ["git", "-c", f"safe.directory={git_path.parent!s}", "log", "-1", "--format=%ct_%H"], # noqa: S607
cwd=os.path.dirname(git_path), cwd=os.path.dirname(git_path),
capture_output=True, capture_output=True,
text=True, text=True,
@ -660,12 +633,12 @@ class Config(metaclass=Singleton):
) )
if 0 != commit_result.returncode: if 0 != commit_result.returncode:
LOG.error("Git log failed: %s", commit_result.stderr.strip()) logging.error(f"Git log failed: {commit_result.stderr.strip()}")
return return
commit_info: str = commit_result.stdout.strip() commit_info: str = commit_result.stdout.strip()
if not commit_info: if not commit_info:
LOG.warning("Git commit info is empty.") logging.warning("Git commit info is empty.")
return return
commit_date, commit_sha = commit_info.split("_", 1) commit_date, commit_sha = commit_info.split("_", 1)
@ -681,6 +654,6 @@ class Config(metaclass=Singleton):
"commit": self.app_commit_sha, "commit": self.app_commit_sha,
"build_date": self.app_build_date, "build_date": self.app_build_date,
} }
LOG.info("Application version info set to '%s'", version_data) logging.info(f"Application version info set to '{version_data}'")
except Exception as e: except Exception as e:
LOG.error("Error while getting git version: %s", e) logging.error(f"Error while getting git version: {e!s}")

View file

@ -1,760 +0,0 @@
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)

View file

@ -19,10 +19,9 @@ from app.features.ytdlp.utils import extract_ytdlp_logs
from app.features.ytdlp.ytdlp import YTDLP from app.features.ytdlp.ytdlp import YTDLP
from app.library.config import Config from app.library.config import Config
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.Utils import create_cookies_file from app.library.Utils import create_cookies_file
from ...features.ytdlp.extractor import REEXTRACT_INFO_KEY, extract_info_sync from ...features.ytdlp.extractor import extract_info_sync
from .hooks import HookHandlers, NestedLogger from .hooks import HookHandlers, NestedLogger
from .process_manager import ProcessManager from .process_manager import ProcessManager
from .status_tracker import StatusTracker from .status_tracker import StatusTracker
@ -39,14 +38,6 @@ if TYPE_CHECKING:
class Download: class Download:
update_task: asyncio.Task | None = None 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): def __init__(self, info: ItemDTO, info_dict: dict | None = None, logs: list[str] | None = None):
""" """
Initialize download task. Initialize download task.
@ -59,7 +50,7 @@ class Download:
""" """
config: Config = Config.get_instance() config: Config = Config.get_instance()
self.download_dir: str = info.download_dir or "" self.download_dir: str = info.download_dir
self.temp_dir: str | None = info.temp_dir self.temp_dir: str | None = info.temp_dir
self.template: str | None = info.template self.template: str | None = info.template
self.template_chapter: str | None = info.template_chapter self.template_chapter: str | None = info.template_chapter
@ -73,7 +64,7 @@ class Download:
self.max_workers = int(config.max_workers) self.max_workers = int(config.max_workers)
self.is_live: bool = bool(info.is_live) or info.live_in is not None self.is_live: bool = bool(info.is_live) or info.live_in is not None
self.info_dict: dict | None = info_dict self.info_dict: dict | None = info_dict
self.logger: logging.Logger = get_logger() self.logger: logging.Logger = logging.getLogger(f"Download.{info.id or info._id}")
self.started_time = 0 self.started_time = 0
self.queue_time: datetime = datetime.now(tz=UTC) self.queue_time: datetime = datetime.now(tz=UTC)
self.logs: list[str] = logs or [] self.logs: list[str] = logs or []
@ -106,16 +97,6 @@ class Download:
params: dict[str, Any] = {} params: dict[str, Any] = {}
download_skipped = False 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: try:
params = ( params = (
self.info.get_ytdlp_opts() self.info.get_ytdlp_opts()
@ -143,9 +124,9 @@ class Download:
params.update( params.update(
{ {
"progress_hooks": [hook_handlers.progress_hook], "progress_hooks": [self._hook_handlers.progress_hook],
"postprocessor_hooks": [hook_handlers.postprocessor_hook], "postprocessor_hooks": [self._hook_handlers.postprocessor_hook],
"post_hooks": [hook_handlers.post_hook], "post_hooks": [self._hook_handlers.post_hook],
} }
) )
@ -155,96 +136,30 @@ class Download:
if self.info.cookies: if self.info.cookies:
try: try:
cookie_file = ( cookie_file = Path(self._temp_manager.temp_path or self.temp_dir) / f"cookie_{self.info._id}.txt"
Path(self._temp_manager.temp_path or self.temp_dir or self.download_dir)
/ f"cookie_{self.info._id}.txt"
)
self.logger.debug( self.logger.debug(
f"Creating cookie file for '{self.info.title}' at '{cookie_file}'.", f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{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)) params["cookiefile"] = str(create_cookies_file(self.info.cookies, cookie_file))
except Exception as e: except Exception as e:
err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'." err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'."
self.logger.error( self.logger.error(err_msg)
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 raise ValueError(err_msg) from e
if self.info_dict and isinstance(self.info_dict, dict): if self.info_dict and isinstance(self.info_dict, dict):
if self.info_dict.get(REEXTRACT_INFO_KEY): if self.info_dict.get("extractor_key") in GENERIC_EXTRACTORS and self.info.get_preset().default:
self.logger.info( self.logger.debug(f"Removing 'download_archive' for generic extractor. url={self.info.url}")
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) params.pop("download_archive", None)
if self.info_dict and self.download_info_expires > 0: if self.download_info_expires > 0:
_ts: int | None = self.info_dict.get("epoch", self.info_dict.get("timestamp", None)) _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 _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: if not _ts_dt or (datetime.now(tz=UTC) - _ts_dt).total_seconds() > self.download_info_expires:
self.info_dict = None self.info_dict = None
self.logger.warning( self.logger.warning(f"Info for '{self.info.url}' has expired, re-extracting info.")
"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): if not self.info_dict or not isinstance(self.info_dict, dict):
self.logger.info( self.logger.info(f"Extracting info for '{self.info.url}'.")
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() ie_params: dict = params.copy()
(info, logs) = extract_info_sync( (info, logs) = extract_info_sync(
@ -268,20 +183,7 @@ class Download:
deletedOpts.append(opt) deletedOpts.append(opt)
if len(deletedOpts) > 0: if len(deletedOpts) > 0:
self.logger.warning( self.logger.warning(f"Live stream detected for '{self.info.title}', deleted opts: {deletedOpts}")
"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 ( if isinstance(self.info_dict, dict) and not (
len(self.info_dict.get("formats", [])) > 0 or self.info_dict.get("url") len(self.info_dict.get("formats", [])) > 0 or self.info_dict.get("url")
@ -290,39 +192,14 @@ class Download:
if filtered_logs := extract_ytdlp_logs(self.logs): if filtered_logs := extract_ytdlp_logs(self.logs):
msg += " " + ", ".join(filtered_logs) msg += " " + ", ".join(filtered_logs)
self._raise_no_formats(msg) raise ValueError(msg) # noqa: TRY301
self.logger.info( self.logger.info(
f"Downloading '{self.info.title}' from '{self.info.url}' to '{self.download_dir}'.", f'Download {self.info.name()}, preset="{self.info.preset}", cookies="{bool(params.get("cookiefile"))}" started.'
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: if self.debug:
self.logger.debug( self.logger.debug(f"Params before passing to yt-dlp. {params}")
"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) params["logger"] = NestedLogger(self.logger)
@ -350,26 +227,14 @@ class Download:
def mark_cancelled(*_) -> None: def mark_cancelled(*_) -> None:
cls._interrupted = True cls._interrupted = True
cls.to_screen("[info] Interrupt received, exiting cleanly...") cls.to_screen("[info] Interrupt received, exiting cleanly...")
self._raise_interrupted() raise SystemExit(130) # noqa: TRY301
signal.signal(signal.SIGUSR1, mark_cancelled) signal.signal(signal.SIGUSR1, mark_cancelled)
status_queue.put({"id": self.id, "status": "downloading", "download_skipped": download_skipped}) self.status_queue.put({"id": self.id, "status": "downloading", "download_skipped": download_skipped})
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1: if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
self.logger.debug( self.logger.debug(f"Downloading '{self.info.url}' using pre-info.")
"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() _dct: dict = self.info_dict.copy()
if isinstance(self.info.extras, dict) and len(self.info.extras) > 0: if isinstance(self.info.extras, dict) and len(self.info.extras) > 0:
_dct.update( _dct.update(
@ -394,22 +259,10 @@ class Download:
cls.process_ie_result(ie_result=_dct, download=True) cls.process_ie_result(ie_result=_dct, download=True)
ret: int = cls._download_retcode ret: int = cls._download_retcode
else: else:
self.logger.debug( self.logger.debug(f"Downloading using url: {self.info.url}")
"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]) ret = cls.download(url_list=[self.info.url])
status_queue.put( self.status_queue.put(
{ {
"id": self.id, "id": self.id,
"status": "finished" if 0 == ret else "error", "status": "finished" if 0 == ret else "error",
@ -417,20 +270,8 @@ class Download:
} }
) )
except yt_dlp.utils.ExistingVideoReached as exc: except yt_dlp.utils.ExistingVideoReached as exc:
self.logger.error( self.logger.error(exc)
f"Skipping already downloaded '{self.info.title}' from '{self.info.url}'. {exc!s}", self.status_queue.put(
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, "id": self.id,
"status": "skip", "status": "skip",
@ -439,23 +280,9 @@ class Download:
} }
) )
except Exception as exc: except Exception as exc:
self.logger.exception( self.logger.exception(exc)
f"Failed to download '{self.info.title}' from '{self.info.url}'.", self.logger.error(exc)
extra={ self.status_queue.put(
"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, "id": self.id,
"status": "error", "status": "error",
@ -465,50 +292,16 @@ class Download:
} }
) )
finally: finally:
status_queue.put(Terminator()) self.status_queue.put(Terminator())
if cookie_file and cookie_file.exists(): if cookie_file and cookie_file.exists():
try: try:
cookie_file.unlink() cookie_file.unlink()
self.logger.debug( self.logger.debug(f"Deleted cookie file: {cookie_file}")
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: except Exception as e:
self.logger.error( self.logger.error(f"Failed to delete cookie file: {cookie_file}. {e}")
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( self.logger.info(
f"Download task finished for '{self.info.title}'.", f'Task {self.info.name()} preset="{self.info.preset}" cookies="{bool(params.get("cookiefile"))}" completed.'
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: async def start(self) -> int | None:
@ -522,24 +315,24 @@ class Download:
Process exit code or None Process exit code or None
""" """
status_queue: Any = Config.get_manager().Queue() self.status_queue = Config.get_manager().Queue()
self.status_queue = status_queue
temp_path = self._temp_manager.create_temp_path() if temp_path := self._temp_manager.create_temp_path():
self.info.temp_path = str(temp_path)
self._status_tracker = StatusTracker( self._status_tracker = StatusTracker(
info=self.info, info=self.info,
download_id=self.id, download_id=self.id,
download_dir=self.download_dir, download_dir=self.download_dir,
temp_path=temp_path, temp_path=temp_path,
status_queue=status_queue, status_queue=self.status_queue,
logger=self.logger, logger=self.logger,
debug=self.debug, debug=self.debug,
) )
self._hook_handlers = HookHandlers( self._hook_handlers = HookHandlers(
download_id=self.id, download_id=self.id,
status_queue=status_queue, status_queue=self.status_queue,
logger=self.logger, logger=self.logger,
debug=self.debug, debug=self.debug,
) )
@ -552,8 +345,7 @@ class Download:
EventBus.get_instance().emit(Events.ITEM_UPDATED, data=self.info) EventBus.get_instance().emit(Events.ITEM_UPDATED, data=self.info)
asyncio.create_task(self._status_tracker.progress_update(), name=f"update-{self.id}") asyncio.create_task(self._status_tracker.progress_update(), name=f"update-{self.id}")
proc = self._process_manager.proc ret = await asyncio.get_running_loop().run_in_executor(None, self._process_manager.proc.join)
ret = await asyncio.get_running_loop().run_in_executor(None, proc.join) if proc else None
if self._status_tracker.final_update: if self._status_tracker.final_update:
return ret return ret
@ -596,19 +388,8 @@ class Download:
return True return True
except Exception as e: except Exception as e:
self.logger.exception( self.logger.error(f"Failed to close process. {e}")
f"Failed to close download process for '{self.info.title}'.", self.logger.exception(e)
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 return False
@ -636,28 +417,12 @@ class Download:
""" """
if self.started_time < 1: if self.started_time < 1:
self.logger.debug( self.logger.debug(f"Download task '{self.info.name()}' not started yet.")
"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 return False
elapsed = int(time.time()) - self.started_time elapsed = int(time.time()) - self.started_time
if elapsed < 300: if elapsed < 300:
self.logger.debug( self.logger.debug(f"Download task '{self.info.title}: {self.info.id}' started for '{elapsed}' seconds.")
"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 return False
status: str = self.info.status or "unknown" status: str = self.info.status or "unknown"

View file

@ -33,19 +33,9 @@ class HookHandlers:
if self.debug: if self.debug:
try: try:
d_safe = create_debug_safe_dict(data) d_safe = create_debug_safe_dict(data)
self.logger.debug( self.logger.debug(f"PG Hook: {d_safe}")
"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: except Exception as e:
self.logger.exception( self.logger.debug(f"PG Hook: Error creating debug info: {e}")
"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( self.status_queue.put(
{ {
@ -72,23 +62,9 @@ class HookHandlers:
try: try:
d_safe = create_debug_safe_dict(data) d_safe = create_debug_safe_dict(data)
d_safe["postprocessor"] = data.get("postprocessor") d_safe["postprocessor"] = data.get("postprocessor")
self.logger.debug( self.logger.debug(f"PP Hook: {d_safe}")
"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: except Exception as e:
self.logger.exception( self.logger.debug(f"PP Hook: Error creating debug info: {e}")
"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) self.status_queue.put(status)

View file

@ -4,7 +4,7 @@ import time
import uuid import uuid
from numbers import Number from numbers import Number
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
import yt_dlp.utils import yt_dlp.utils
@ -14,7 +14,6 @@ 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.features.ytdlp.utils import archive_add, archive_read, arg_converter, get_extras, ytdlp_reject
from app.library.Events import Events from app.library.Events import Events
from app.library.ItemDTO import ItemDTO from app.library.ItemDTO import ItemDTO
from app.library.log import get_logger
from app.library.Utils import create_cookies_file, merge_dict from app.library.Utils import create_cookies_file, merge_dict
from .core import Download from .core import Download
@ -27,7 +26,7 @@ if TYPE_CHECKING:
from .queue_manager import DownloadQueue from .queue_manager import DownloadQueue
LOG = get_logger() LOG: logging.Logger = logging.getLogger("downloads.add")
def _get_ignored_conditions(extras: dict | None) -> list[str]: def _get_ignored_conditions(extras: dict | None) -> list[str]:
@ -67,7 +66,7 @@ async def add_item(
already=None, already=None,
logs: list | None = None, logs: list | None = None,
yt_params: dict | None = None, yt_params: dict | None = None,
) -> dict[str, Any]: ) -> dict[str, str]:
""" """
Route an entry to the appropriate processor based on type. Route an entry to the appropriate processor based on type.
@ -102,7 +101,7 @@ async def add_item(
async def add( async def add(
queue: "DownloadQueue", item: "Item", already: set | None = None, entry: dict | None = None queue: "DownloadQueue", item: "Item", already: set | None = None, entry: dict | None = None
) -> dict[str, Any]: ) -> dict[str, str]:
""" """
Add an item to the download queue. Add an item to the download queue.
@ -123,11 +122,7 @@ async def add(
try: try:
arg_converter(args=item.cli, level=True) arg_converter(args=item.cli, level=True)
except Exception as e: except Exception as e:
LOG.error( LOG.error(f"Invalid command options for yt-dlp '{item.cli}'. {e!s}")
"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}"} return {"status": "error", "msg": f"Invalid command options for yt-dlp '{item.cli}'. {e!s}"}
if _preset: if _preset:
@ -140,27 +135,12 @@ async def add(
yt_conf = {} yt_conf = {}
cookie_file: Path = Path(queue.config.temp_path) / f"c_{uuid.uuid4().hex}.txt" cookie_file: Path = Path(queue.config.temp_path) / f"c_{uuid.uuid4().hex}.txt"
LOG.info( LOG.info(f"Adding '{item!r}'.")
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 already = set() if already is None else already
if item.url in already: if item.url in already:
LOG.warning( LOG.warning(f"Recursion detected with url '{item.url}' skipping.")
"Skipping recursive download URL '%s'.",
item.url,
extra={"url": item.url, "preset": item.preset},
)
return {"status": "ok"} return {"status": "ok"}
already.add(item.url) already.add(item.url)
@ -169,16 +149,7 @@ async def add(
yt_conf: dict = item.get_ytdlp_opts().get_all() yt_conf: dict = item.get_ytdlp_opts().get_all()
if yt_conf.get("external_downloader"): if yt_conf.get("external_downloader"):
LOG.warning( LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.")
"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}) item.extras.update({"external_downloader": True})
archive_id: str | None = item.get_archive_id() archive_id: str | None = item.get_archive_id()
@ -204,7 +175,7 @@ async def add(
) )
if archive_file := dlInfo.info.get_ytdlp_opts().get_all().get("download_archive"): if archive_file := dlInfo.info.get_ytdlp_opts().get_all().get("download_archive"):
dlInfo.info.msg = (dlInfo.info.msg or "") + f" Found in archive '{archive_file}'." dlInfo.info.msg += f" Found in archive '{archive_file}'."
await queue.done.put(dlInfo) await queue.done.put(dlInfo)
@ -230,45 +201,14 @@ async def add(
yt_conf["cookiefile"] = str(create_cookies_file(item.cookies, cookie_file)) yt_conf["cookiefile"] = str(create_cookies_file(item.cookies, cookie_file))
except Exception as e: except Exception as e:
msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'." msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'."
LOG.exception( LOG.error(msg)
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} return {"status": "error", "msg": msg}
if entry: if entry:
LOG.info( LOG.info(f"[P] Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
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: if not entry:
LOG.info( LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
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( (entry, logs) = await fetch_info(
config=yt_conf, config=yt_conf,
url=item.url, url=item.url,
@ -281,17 +221,7 @@ async def add(
) )
if not entry: if not entry:
LOG.error( LOG.error(f"Unable to extract info for '{item.url}'. Logs: {logs}")
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)} 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() # Sometimes playlists or extractor returns different ID than what we get from the make_archive_id()
@ -306,12 +236,9 @@ async def add(
new_archive_id: str | None = None new_archive_id: str | None = None
if entry.get("extractor_key") and entry.get("id"): if entry.get("extractor_key") and entry.get("id"):
_extractor_key = entry.get("extractor_key") new_archive_id: str = f"{entry.get('extractor_key').lower()} {entry.get('id')}"
_entry_id = entry.get("id") if new_archive_id != archive_id:
if isinstance(_extractor_key, str) and _entry_id is not None: extra_ids.append(new_archive_id)
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: if len(extra_ids) > 0:
archive_ids: list[str] = archive_read(_archive_file, extra_ids) archive_ids: list[str] = archive_read(_archive_file, extra_ids)
@ -339,7 +266,7 @@ async def add(
) )
) )
dlInfo.info.msg = (dlInfo.info.msg or "") + f" Found in archive '{_archive_file}'." dlInfo.info.msg += f" Found in archive '{_archive_file}'."
await queue.done.put(dlInfo) await queue.done.put(dlInfo)
queue._notify.emit( queue._notify.emit(
@ -366,9 +293,7 @@ async def add(
): ):
already.pop() already.pop()
display = str(entry.get("title") or entry.get("webpage_url") or entry.get("url") or item.url) message = f"Condition '{condition.name}' matched for '{item!r}'."
message = f"Condition '{condition.name}' matched for '{display}'."
if condition.cli: if condition.cli:
message += f" Re-queuing with '{condition.cli}'." message += f" Re-queuing with '{condition.cli}'."
@ -377,18 +302,18 @@ async def add(
if condition.extras.get("ignore_download", False): if condition.extras.get("ignore_download", False):
extra_msg: str = "" extra_msg: str = ""
if _archive_file and not condition.extras.get("no_archive", False) and archive_id: if _archive_file and not condition.extras.get("no_archive", False):
archive_add(_archive_file, [archive_id]) archive_add(_archive_file, [archive_id])
extra_msg = f" and added to archive file '{_archive_file}'" extra_msg = f" and added to archive file '{_archive_file}'"
_name = str(entry.get("title") or entry.get("id") or item.url) _name = entry.get("title", entry.get("id"))
log_message = f"Ignoring download of '{_name}' as per condition '{condition.name}'{extra_msg}." log_message = f"Ignoring download of '{_name!r}' as per condition '{condition.name}'{extra_msg}."
store_type, dlInfo = await queue.get_item(archive_id=archive_id) store_type, dlInfo = await queue.get_item(archive_id=archive_id)
if not store_type: if not store_type:
dlInfo = Download( dlInfo = Download(
info=ItemDTO( info=ItemDTO(
id=str(entry.get("id") or item.url), id=entry.get("id"),
title=_name, title=_name,
url=item.url, url=item.url,
preset=item.preset, preset=item.preset,
@ -404,9 +329,6 @@ async def add(
LOG.info(log_message) LOG.info(log_message)
queue._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=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( queue._notify.emit(
Events.ITEM_MOVED, Events.ITEM_MOVED,
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info}, data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
@ -417,10 +339,7 @@ async def add(
if condition.extras.get("set_preset") and (target_preset := condition.extras.get("set_preset")): if condition.extras.get("set_preset") and (target_preset := condition.extras.get("set_preset")):
if Presets.get_instance().has(target_preset): if Presets.get_instance().has(target_preset):
log_message: str = ( log_message: str = f"Switching preset from '{item.preset}' to '{target_preset}' for '{item!r}' as per condition '{condition.name}'."
f"Switching preset from '{item.preset}' to '{target_preset}' for '{display}' "
f"as per condition '{condition.name}'."
)
LOG.info(log_message) LOG.info(log_message)
queue._notify.emit(Events.LOG_INFO, data={}, title="Preset Switched", message=log_message) queue._notify.emit(Events.LOG_INFO, data={}, title="Preset Switched", message=log_message)
item = item.new_with(preset=target_preset) item = item.new_with(preset=target_preset)
@ -437,61 +356,15 @@ async def add(
return {"status": "error", "msg": _msg} return {"status": "error", "msg": _msg}
end_time = time.perf_counter() - started end_time = time.perf_counter() - started
LOG.debug( LOG.debug(f"extract_info: for 'URL: {item.url}' is done in '{end_time:.3f}'. Length: '{len(entry)}/keys'.")
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: except yt_dlp.utils.ExistingVideoReached as exc:
LOG.error( LOG.error(f"Video has been downloaded already and recorded in archive.log file. '{exc!s}'.")
"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."} return {"status": "error", "msg": "Video has been downloaded already and recorded in archive.log file."}
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
LOG.exception( LOG.error(f"YoutubeDLError: Unable to extract info. '{exc!s}'.")
"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)} return {"status": "error", "msg": str(exc)}
except asyncio.exceptions.TimeoutError as exc: except asyncio.exceptions.TimeoutError as exc:
LOG.exception( LOG.error(f"TimeoutError: Unable to extract info. '{exc!s}'.")
"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 { return {
"status": "error", "status": "error",
"msg": f"TimeoutError: {queue.config.extract_info_timeout}s reached Unable to extract info.", "msg": f"TimeoutError: {queue.config.extract_info_timeout}s reached Unable to extract info.",
@ -502,19 +375,6 @@ async def add(
cookie_file.unlink(missing_ok=True) cookie_file.unlink(missing_ok=True)
yt_conf.pop("cookiefile", None) yt_conf.pop("cookiefile", None)
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to remove cookie file '{yt_conf['cookiefile']}'. {e!s}")
"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) return await add_item(queue=queue, entry=entry, item=item, already=already, logs=logs, yt_params=yt_conf)

View file

@ -1,5 +1,6 @@
"""Queue monitoring functions.""" """Queue monitoring functions."""
import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -7,13 +8,12 @@ from typing import TYPE_CHECKING
from app.library.ag_utils import ag from app.library.ag_utils import ag
from app.library.Events import Events from app.library.Events import Events
from app.library.ItemDTO import Item, ItemDTO from app.library.ItemDTO import Item, ItemDTO
from app.library.log import get_logger
from app.library.Utils import dt_delta, str_to_dt from app.library.Utils import dt_delta, str_to_dt
if TYPE_CHECKING: if TYPE_CHECKING:
from .queue_manager import DownloadQueue from .queue_manager import DownloadQueue
LOG = get_logger() LOG: logging.Logger = logging.getLogger("downloads.monitors")
async def check_for_stale(queue: "DownloadQueue") -> None: async def check_for_stale(queue: "DownloadQueue") -> None:
@ -31,37 +31,16 @@ async def check_for_stale(queue: "DownloadQueue") -> None:
return return
for _id, item in list(queue.queue.items()): for _id, item in list(queue.queue.items()):
item_ref = f"{_id=} {item.info.id=} {item.info.title=}"
if not item.is_stale(): if not item.is_stale():
continue continue
try: try:
LOG.warning( LOG.warning(f"Cancelling staled item '{item_ref}' from download queue.")
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]) await queue.cancel([_id])
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to cancel staled item '{item_ref}'. {e!s}")
f"Failed to cancel stale download '{item.info.title}'.", LOG.exception(e)
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: async def check_live(queue: "DownloadQueue") -> None:
@ -89,19 +68,9 @@ async def check_live(queue: "DownloadQueue") -> None:
if item.info.status not in status: if item.info.status not in status:
continue continue
item_ref: str = f"{id=} {item.info.id=} {item.info.title=}"
if not item.is_live: if not item.is_live:
LOG.debug( LOG.debug(f"Item '{item_ref}' is not a live stream.")
"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 continue
duration: int | None = item.info.extras.get("duration", None) duration: int | None = item.info.extras.get("duration", None)
@ -110,17 +79,7 @@ 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) live_in: str | None = item.info.live_in or ag(item.info.extras, ["live_in", "release_in"], None)
if not live_in: if not live_in:
LOG.debug( LOG.debug(
"Skipping %s '%s' because no start time is set.", f"Item '{item_ref}' marked as {'premiere video' if is_premiere else 'live stream'}, but no date 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 continue
@ -128,76 +87,24 @@ 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) 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)): if time_now < (starts_in + timedelta(minutes=1)):
starts_in_text = dt_delta(starts_in - time_now) LOG.debug(f"Item '{item_ref}' is not yet live. will start at '{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 continue
if queue.config.prevent_live_premiere and is_premiere and duration: 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 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) premiere_ends: datetime = starts_in + timedelta(minutes=buffer_time, seconds=duration)
if time_now < premiere_ends: if time_now < premiere_ends:
start_after = premiere_ends.astimezone().isoformat()
LOG.debug( LOG.debug(
"Premiere '%s' is still running; download will start after '%s'.", f"Item '{item_ref}' is premiering, download will start in '{(starts_in + timedelta(minutes=buffer_time, seconds=duration)).astimezone().isoformat()}'"
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 continue
LOG.info( LOG.info(f"Retrying item '{item_ref} {item.info.extras=}' for download.")
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: try:
await queue.clear([item.info._id], remove_file=False) await queue.clear([item.info._id], remove_file=False)
except Exception as e: except Exception as e:
LOG.exception( LOG.error(f"Failed to clear item '{item_ref}'. {e!s}")
"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 continue
try: try:
@ -214,21 +121,8 @@ async def check_live(queue: "DownloadQueue") -> None:
) )
except Exception as e: except Exception as e:
await queue.done.put(item) await queue.done.put(item)
LOG.exception( LOG.exception(e)
"Failed to retry live download '%s' from '%s'.", LOG.error(f"Failed to retry item '{item_ref}'. {e!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: async def delete_old_history(queue: "DownloadQueue") -> None:
@ -262,19 +156,7 @@ async def delete_old_history(queue: "DownloadQueue") -> None:
if item_datetime < cutoff_date: if item_datetime < cutoff_date:
items_to_delete.append((key, info)) items_to_delete.append((key, info))
except (OSError, ValueError, OverflowError) as e: except (OSError, ValueError, OverflowError) as e:
LOG.exception( LOG.error(f"Failed to parse timestamp '{info.timestamp}' for item '{info.title}': {e}")
"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] = [] titles: list[str] = []
for key, info in items_to_delete: for key, info in items_to_delete:
@ -289,12 +171,7 @@ async def delete_old_history(queue: "DownloadQueue") -> None:
await queue.done.delete(key) await queue.done.delete(key)
if titles: if titles:
LOG.info( LOG.info(f"Automatically cleared '{', '.join(titles)}' from download history due to age.")
"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: async def cleanup_thumbnails(queue: "DownloadQueue") -> None:
@ -324,11 +201,7 @@ async def cleanup_thumbnails(queue: "DownloadQueue") -> None:
thumb.unlink(missing_ok=True) thumb.unlink(missing_ok=True)
removed += 1 removed += 1
except OSError as exc: except OSError as exc:
LOG.exception( LOG.warning(f"Failed to remove orphaned thumbnail '{thumb}'. {exc!s}")
"Failed to remove orphaned thumbnail '%s'.",
thumb,
extra={"file_path": str(thumb), "exception_type": type(exc).__name__},
)
if removed > 0: if removed > 0:
LOG.info("Removed %s orphaned cached thumbnail(s).", removed, extra={"removed_count": removed}) LOG.info(f"Removed '{removed}' orphaned cached thumbnails.")

View file

@ -1,9 +1,9 @@
"""Playlist processing.""" """Playlist processing."""
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.features.ytdlp.utils import ytdlp_reject from app.features.ytdlp.utils import ytdlp_reject
from app.library.log import get_logger
from app.library.Utils import merge_dict from app.library.Utils import merge_dict
if TYPE_CHECKING: if TYPE_CHECKING:
@ -11,7 +11,7 @@ if TYPE_CHECKING:
from .queue_manager import DownloadQueue from .queue_manager import DownloadQueue
LOG = get_logger() LOG: logging.Logger = logging.getLogger("downloads.playlist")
async def process_playlist( async def process_playlist(
@ -38,17 +38,7 @@ async def process_playlist(
playlist_name: str = f"{entry.get('id')}: {entry.get('title')}" playlist_name: str = f"{entry.get('id')}: {entry.get('title')}"
LOG.info( LOG.info(f"Processing '{playlist_name} ({len(entries)})' Playlist.")
"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 = entry.get("playlist_count")
playlistCount: int = int(playlistCount) if playlistCount else len(entries) playlistCount: int = int(playlistCount) if playlistCount else len(entries)
@ -72,18 +62,7 @@ async def process_playlist(
item_name: str = ( item_name: str = (
f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'" f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'"
) )
LOG.info( LOG.info(f"Processing '{item_name}'.")
"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) _status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params)
if not _status: if not _status:
@ -121,8 +100,8 @@ async def process_playlist(
max_downloads: int = -1 max_downloads: int = -1
ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all() ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all()
if isinstance(ytdlp_opts.get("max_downloads"), int): if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int):
max_downloads = ytdlp_opts["max_downloads"] max_downloads: int = ytdlp_opts.get("max_downloads")
results: list[dict[str, str]] = [] results: list[dict[str, str]] = []
for i, etr in enumerate(entries, start=1): for i, etr in enumerate(entries, start=1):
@ -131,23 +110,12 @@ async def process_playlist(
results.append(await process_item(i, etr)) results.append(await process_item(i, etr))
skipped = 0 log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries."
if max_downloads > 0 and len(entries) > max_downloads: if max_downloads > 0 and len(entries) > max_downloads:
skipped = len(entries) - max_downloads skipped: int = len(entries) - max_downloads
log_msg += f" Limited to '{max_downloads}' items, skipped '{skipped}' remaining items."
LOG.info( LOG.info(log_msg)
"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): if any("error" == res["status"] for res in results):
return { return {

View file

@ -1,10 +1,10 @@
"""Download pool management - worker coordination and execution.""" """Download pool management - worker coordination and execution."""
import asyncio import asyncio
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.log import get_logger
from app.library.Utils import calc_download_path from app.library.Utils import calc_download_path
from .core import Download from .core import Download
@ -15,7 +15,7 @@ if TYPE_CHECKING:
from .queue_manager import DownloadQueue from .queue_manager import DownloadQueue
LOG = get_logger() LOG: logging.Logger = logging.getLogger("downloads.pool")
class PoolManager: class PoolManager:
@ -78,11 +78,7 @@ class PoolManager:
async def shutdown(self) -> None: async def shutdown(self) -> None:
if self._active: if self._active:
LOG.info( LOG.info(f"Cancelling '{len(self._active)}' active downloads.")
"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())) await self.queue.cancel(list(self._active.keys()))
async def _download_pool(self) -> None: async def _download_pool(self) -> None:
@ -91,7 +87,7 @@ class PoolManager:
while True: while True:
while not self.queue.queue.has_downloads(): while not self.queue.queue.has_downloads():
LOG.info("Waiting for queued downloads.") LOG.info("Waiting for item to download.")
await self.event.wait() await self.event.wait()
self.event.clear() self.event.clear()
adaptive_sleep = 0.2 adaptive_sleep = 0.2
@ -144,11 +140,7 @@ class PoolManager:
# No items could be processed, back off a bit to avoid busy-waiting. # No items could be processed, back off a bit to avoid busy-waiting.
if 0 == items_processed: if 0 == items_processed:
adaptive_sleep: float = min(adaptive_sleep * 1.5, max_sleep) adaptive_sleep: float = min(adaptive_sleep * 1.5, max_sleep)
LOG.debug( LOG.debug(f"No download slots available. Backing off for {adaptive_sleep:.2f}s before next attempt.")
"No download slots available; backing off for %.2f seconds.",
adaptive_sleep,
extra={"backoff_s": round(adaptive_sleep, 2), "active_count": len(self._active)},
)
else: else:
adaptive_sleep = 0.2 adaptive_sleep = 0.2
@ -164,22 +156,7 @@ class PoolManager:
""" """
filePath: str = calc_download_path(base_path=self.config.download_path, folder=entry.info.folder) filePath: str = calc_download_path(base_path=self.config.download_path, folder=entry.info.folder)
LOG.info( LOG.info(f"Downloading 'id: {entry.id}', 'Title: {entry.info.title}', 'URL: {entry.info.url}' To '{filePath}'.")
"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: try:
self._active[entry.info._id] = entry self._active[entry.info._id] = entry
@ -199,18 +176,7 @@ class PoolManager:
await entry.close() await entry.close()
if await self.queue.queue.exists(key=id): if await self.queue.queue.exists(key=id):
LOG.debug( LOG.debug(f"Download Task '{id}' is completed. Removing from queue.")
"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) await self.queue.queue.delete(key=id)
nTitle: str | None = None nTitle: str | None = None
@ -240,16 +206,12 @@ class PoolManager:
await self.queue.done.put(entry) await self.queue.done.put(entry)
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, Events.ITEM_MOVED,
data={"from": "queue", "to": "history", "preset": entry.info.preset, "item": entry.info}, data={"to": "history", "preset": entry.info.preset, "item": entry.info},
title=nTitle, title=nTitle,
message=nMessage, message=nMessage,
) )
else: else:
LOG.warning( LOG.warning(f"Download '{id}' not found in queue.")
"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: if self.event:
self.event.set() self.event.set()

View file

@ -99,163 +99,54 @@ class ProcessManager:
if not self.running(): if not self.running():
return False return False
proc = self.proc procId: int | None = self.proc.ident
if proc is None:
return False
procId: int | None = proc.ident
try: try:
self.logger.info( self.logger.info(f"Killing download process: PID={self.proc.pid}, ident={procId}.")
"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: if self.is_live:
self.logger.debug( self.logger.debug(f"Requesting graceful live cancellation for PID={self.proc.pid}.")
"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() self.cancel_event.set()
if wait_for_process_with_timeout(proc, 10): if wait_for_process_with_timeout(self.proc, 10):
self.logger.debug( self.logger.debug(f"Process PID={self.proc.pid} terminated gracefully.")
"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 return True
self.logger.warning( self.logger.warning(
"Download process PID=%s did not respond to live cancellation; forcing termination.", f"Process PID={self.proc.pid} 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 proc.pid and "posix" == os.name: elif self.proc.pid and "posix" == os.name:
signal_name = "SIGUSR1" signal_name = "SIGUSR1"
try: try:
self.logger.debug( self.logger.debug(f"Sending {signal_name} signal to PID={self.proc.pid}.")
"Sending %s signal to download process PID=%s.", os.kill(self.proc.pid, signal.SIGUSR1)
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(proc, 5): if wait_for_process_with_timeout(self.proc, 5):
self.logger.debug( self.logger.debug(f"Process PID={self.proc.pid} terminated gracefully.")
"Download process PID=%s stopped gracefully.",
proc.pid,
extra={
"download": {
"download_id": self.download_id,
"process_id": proc.pid,
"signal": signal_name,
}
},
)
return True return True
self.logger.warning( self.logger.warning(
"Download process PID=%s did not respond to %s; forcing termination.", f"Process PID={self.proc.pid} did not respond to {signal_name} (regular download), "
proc.pid, f"forcing termination."
signal_name,
extra={
"download": {
"download_id": self.download_id,
"process_id": proc.pid,
"signal": signal_name,
"force": True,
}
},
) )
except (OSError, AttributeError) as e: except (OSError, AttributeError) as e:
self.logger.debug( self.logger.debug(f"Failed to send {signal_name} signal: {e}")
"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 proc.is_alive(): if self.proc.is_alive():
self.logger.info( self.logger.info(f"Force-terminating process PID={self.proc.pid}.")
"Terminating download process PID=%s.", self.proc.terminate()
proc.pid,
extra={"download": {"download_id": self.download_id, "process_id": proc.pid, "force": True}},
)
proc.terminate()
if not wait_for_process_with_timeout(proc, 1 if self.is_live else 2): if not wait_for_process_with_timeout(self.proc, 1 if self.is_live else 2):
self.logger.warning( self.logger.warning(f"Process PID={self.proc.pid} did not respond, killing forcefully.")
"Download process PID=%s did not terminate; killing forcefully.", self.proc.kill()
proc.pid, wait_for_process_with_timeout(self.proc, 1)
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( self.logger.info(f"Process PID={self.proc.pid} killed.")
"Download process PID=%s stopped.",
proc.pid,
extra={"download": {"download_id": self.download_id, "process_id": proc.pid}},
)
return True return True
except Exception as e: except Exception as e:
self.logger.exception( self.logger.error(f"Failed to kill process PID={self.proc.pid}, ident={procId}. {e}")
f"Failed to stop download process PID={proc.pid if proc else None}, ident={procId}.", self.logger.exception(e)
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 return False
@ -273,21 +164,16 @@ class ProcessManager:
return False return False
self.cancel_in_progress = True self.cancel_in_progress = True
proc = self.proc procId: int | None = self.proc.ident
procId: int | None = proc.ident if proc else None
if not procId: if not procId:
if proc: if self.proc:
proc.close() self.proc.close()
self.proc = None self.proc = None
self.logger.warning("Attempted to close download process, but it is not running.") self.logger.warning("Attempted to close download process, but it is not running.")
return False return False
self.logger.info( self.logger.info(f"Closing PID='{procId}' download process.")
"Closing download process PID='%s'.",
procId,
extra={"download": {"download_id": self.download_id, "process_ident": procId, "is_live": self.is_live}},
)
try: try:
self.kill() self.kill()
@ -296,43 +182,20 @@ class ProcessManager:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
current = self.proc if self.proc.is_alive():
if current is not None and current.is_alive(): self.logger.debug(f"Waiting for PID='{procId}' to close.")
self.logger.debug( await loop.run_in_executor(None, self.proc.join)
"Waiting for download process PID='%s' to close.", self.logger.debug(f"PID='{procId}' closed.")
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: if self.proc:
self.proc.close() self.proc.close()
self.proc = None self.proc = None
self.logger.debug( self.logger.debug(f"Closed PID='{procId}' download process.")
"Closed download process PID='%s'.",
procId,
extra={"download": {"download_id": self.download_id, "process_ident": procId}},
)
return True return True
except Exception as e: except Exception as e:
self.logger.exception( self.logger.error(f"Failed to close process: '{procId}'. {e}")
f"Failed to close download process PID='{procId}'.", self.logger.exception(e)
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 return False

View file

@ -1,5 +1,6 @@
import functools import functools
import glob import glob
import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -9,7 +10,6 @@ from aiohttp import web
from app.library.config import Config from app.library.config import Config
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item, ItemDTO from app.library.ItemDTO import Item, ItemDTO
from app.library.log import get_logger
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
from app.library.Services import Services from app.library.Services import Services
from app.library.Singleton import Singleton from app.library.Singleton import Singleton
@ -24,7 +24,7 @@ from .pool_manager import PoolManager
if TYPE_CHECKING: if TYPE_CHECKING:
from app.library.DataStore import StoreType from app.library.DataStore import StoreType
LOG = get_logger() LOG: logging.Logger = logging.getLogger("downloads.queue")
class DownloadQueue(metaclass=Singleton): class DownloadQueue(metaclass=Singleton):
@ -53,7 +53,7 @@ class DownloadQueue(metaclass=Singleton):
async def event_handler(_, __): async def event_handler(_, __):
await self.initialize() await self.initialize()
self._notify.subscribe(Events.STARTED, event_handler, f"{DownloadQueue.__name__}.initialize") self._notify.subscribe(Events.STARTED, event_handler, f"{__class__.__name__}.{__class__.initialize.__name__}")
Scheduler.get_instance().add( Scheduler.get_instance().add(
timer="* * * * *", timer="* * * * *",
@ -113,9 +113,7 @@ class DownloadQueue(metaclass=Singleton):
except KeyError as e: except KeyError as e:
status[item_id] = f"not found: {e!s}" status[item_id] = f"not found: {e!s}"
status["status"] = "error" status["status"] = "error"
LOG.warning( LOG.warning(f"Start requested for non-existent item {item_id=}.")
"Start requested for missing queued download '%s'.", item_id, extra={"download_id": item_id}
)
continue continue
if item.info.auto_start: if item.info.auto_start:
@ -158,9 +156,7 @@ class DownloadQueue(metaclass=Singleton):
except KeyError as e: except KeyError as e:
status[item_id] = f"not found: {e!s}" status[item_id] = f"not found: {e!s}"
status["status"] = "error" status["status"] = "error"
LOG.warning( LOG.warning(f"Start requested for non-existent item {item_id=}.")
"Pause requested for missing queued download '%s'.", item_id, extra={"download_id": item_id}
)
continue continue
if item.started() or item.is_cancelled(): if item.started() or item.is_cancelled():
@ -195,8 +191,7 @@ class DownloadQueue(metaclass=Singleton):
if not self.pool.is_paused(): if not self.pool.is_paused():
self.pool.pause() self.pool.pause()
if not shutdown: if not shutdown:
paused_at = datetime.now(tz=UTC).isoformat() LOG.warning(f"Download paused at. {datetime.now(tz=UTC).isoformat()}")
LOG.warning("Paused the download queue.", extra={"paused_at": paused_at})
return True return True
return False return False
@ -211,8 +206,7 @@ class DownloadQueue(metaclass=Singleton):
""" """
if self.pool.is_paused(): if self.pool.is_paused():
self.pool.resume() self.pool.resume()
resumed_at = datetime.now(tz=UTC).isoformat() LOG.warning(f"Downloading resumed at. {datetime.now(tz=UTC).isoformat()}")
LOG.warning("Resumed the download queue.", extra={"resumed_at": resumed_at})
return True return True
return False return False
@ -264,51 +258,20 @@ class DownloadQueue(metaclass=Singleton):
except KeyError as e: except KeyError as e:
status[id] = str(e) status[id] = str(e)
status["status"] = "error" status["status"] = "error"
LOG.warning("Cancel requested for missing queued download '%s'.", id, extra={"download_id": id}) LOG.warning(f"Requested cancel for non-existent download {id=}. {e!s}")
continue continue
item_ref = f"{id=} {item.info.id=} {item.info.title=}"
if item.running(): if item.running():
LOG.debug( LOG.debug(f"Canceling {item_ref}")
"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() item.cancel()
LOG.info( LOG.info(f"Cancelled {item_ref}")
"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: if not item.is_live:
await item.close() await item.close()
else: else:
await item.close() await item.close()
LOG.debug( LOG.debug(f"Deleting from queue {item_ref}")
"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) await self.queue.delete(id)
self._notify.emit( self._notify.emit(
Events.ITEM_CANCELLED, Events.ITEM_CANCELLED,
@ -320,23 +283,11 @@ class DownloadQueue(metaclass=Singleton):
await self.done.put(item) await self.done.put(item)
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, Events.ITEM_MOVED,
data={"from": "queue", "to": "history", "preset": item.info.preset, "item": item.info}, data={"to": "history", "preset": item.info.preset, "item": item.info},
title="Download Cancelled", title="Download Cancelled",
message=f"Download '{item.info.title}' has been cancelled.", message=f"Download '{item.info.title}' has been cancelled.",
) )
LOG.info( LOG.info(f"Deleted from queue {item_ref}")
"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" status[id] = "ok"
@ -363,28 +314,17 @@ class DownloadQueue(metaclass=Singleton):
except KeyError as e: except KeyError as e:
status[id] = str(e) status[id] = str(e)
status["status"] = "error" status["status"] = "error"
LOG.warning("Delete requested for missing history download '%s'.", id, extra={"download_id": id}) LOG.warning(f"Requested delete for non-existent download {id=}. {e!s}")
continue continue
itemRef: str = f"{id=} {item.info.id=} {item.info.title=}"
removed_files = 0 removed_files = 0
filename: str = "" filename: str = ""
if self.config.remove_files is not True: if self.config.remove_files is not True:
remove_file = False remove_file = False
LOG.debug( LOG.debug(f"{remove_file=} {itemRef} - Removing local files: {item.info.status=}")
"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: if remove_file and "finished" == item.info.status and item.info.filename:
filename = str(item.info.filename) filename = str(item.info.filename)
@ -404,66 +344,16 @@ class DownloadQueue(metaclass=Singleton):
for f in rf.parent.glob(f"{glob.escape(rf.stem)}.*"): for f in rf.parent.glob(f"{glob.escape(rf.stem)}.*"):
if f.is_file() and f.exists() and not f.name.startswith("."): if f.is_file() and f.exists() and not f.name.startswith("."):
removed_files += 1 removed_files += 1
LOG.debug( LOG.debug(f"Removing '{itemRef}' local file '{f.name}'.")
"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) f.unlink(missing_ok=True)
else: else:
LOG.debug( LOG.debug(f"Removing '{itemRef}' local file '{rf.name}'.")
"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) rf.unlink(missing_ok=True)
removed_files += 1 removed_files += 1
else: else:
LOG.warning( LOG.warning(f"Failed to remove '{itemRef}' local file '{filename}'. File not found.")
"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: except Exception as e:
LOG.exception( LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}")
"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) await self.done.delete(id)
deleted_ids.append(id) deleted_ids.append(id)
@ -476,19 +366,11 @@ class DownloadQueue(metaclass=Singleton):
message=f"{_status} '{item.info.title}' from history.", message=f"{_status} '{item.info.title}' from history.",
) )
LOG.info( msg = f"Deleted completed download '{itemRef}'."
"Deleted completed download '%s' from history%s.", if removed_files > 0:
item.info.title, msg += f" and removed '{removed_files}' local files."
f" and removed {removed_files} local file(s)" if removed_files > 0 else "",
extra={ LOG.info(msg=msg)
"download": {
"download_id": id,
"item_id": item.info.id,
"title": item.info.title,
"removed_files": removed_files,
}
},
)
status[id] = "ok" status[id] = "ok"
if deleted_ids: if deleted_ids:
@ -500,34 +382,22 @@ class DownloadQueue(metaclass=Singleton):
if not ids: if not ids:
return {"deleted": 0} return {"deleted": 0}
items: list[tuple[str, Download]] = await self.done.get_many_by_ids(ids) items = await self.done.get_many_by_ids(ids)
if not items: if not items:
return {"deleted": 0} return {"deleted": 0}
remove_file = False if self.config.remove_files is not True else remove_file if self.config.remove_files is not True:
remove_file = False
removed_files = 0 removed_files = 0
deleted_ids: list[str] = [] deleted_ids: list[str] = []
item_summaries: list[dict] = [] deleted_titles: list[str] = []
for item_id, item in items: for item_id, item in items:
item_ref: str = f"{item_id=} {item.info.id=} {item.info.title=}"
filename: str = "" filename: str = ""
LOG.debug( LOG.debug(f"{remove_file=} {item_ref} - Removing local files: {item.info.status=}")
"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: if remove_file and "finished" == item.info.status and item.info.filename:
filename = str(item.info.filename) filename = str(item.info.filename)
@ -547,149 +417,65 @@ class DownloadQueue(metaclass=Singleton):
for file_ref in rf.parent.glob(f"{glob.escape(rf.stem)}.*"): 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("."): if file_ref.is_file() and file_ref.exists() and not file_ref.name.startswith("."):
removed_files += 1 removed_files += 1
LOG.debug( LOG.debug(f"Removing '{item_ref}' local file '{file_ref.name}'.")
"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) file_ref.unlink(missing_ok=True)
else: else:
LOG.debug( LOG.debug(f"Removing '{item_ref}' local file '{rf.name}'.")
"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) rf.unlink(missing_ok=True)
removed_files += 1 removed_files += 1
else: else:
LOG.warning( LOG.warning(f"Failed to remove '{item_ref}' local file '{filename}'. File not found.")
"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: except Exception as e:
LOG.exception( LOG.error(f"Unable to remove '{item_ref}' local file '{filename}'. {e!s}")
"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_ids.append(item_id)
item_summaries.append( deleted_titles.append(item.info.title or item.info.id or item_id)
{
"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: int = await self.done.bulk_delete(deleted_ids) deleted_count = await self.done.bulk_delete(deleted_ids)
if deleted_count < 1: if deleted_count < 1:
return {"deleted": 0} return {"deleted": 0}
message: list[str] = [f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history."] title = "History Removed" if removed_files > 0 else "History Cleared"
message = f"Removed {deleted_count} item{'s' if deleted_count != 1 else ''} from history."
if removed_files > 0: if removed_files > 0:
message.append(f"Also removed {removed_files} local file{'s' if removed_files != 1 else ''}.") message += f" Also removed {removed_files} local file{'s' if removed_files != 1 else ''}."
self._notify.emit( self._notify.emit(
Events.ITEM_BULK_DELETED, Events.ITEM_BULK_DELETED,
data={"count": deleted_count, "removed_files": removed_files, "items": item_summaries}, data={"ids": deleted_ids, "count": deleted_count},
title="History Cleared", title=title,
message=" ".join(message), message=message,
) )
LOG.info( summary = ", ".join(deleted_titles[:5])
"Cleared %d history item(s).", if deleted_count > 5:
deleted_count, summary += ", ..."
extra={"deleted_count": deleted_count, "removed_files": removed_files, "items": item_summaries}, LOG.info(f"Bulk cleared {deleted_count} history item(s): {summary}")
)
return {"deleted": deleted_count} return {"deleted": deleted_count}
async def clear_by_status(self, status_filter: str, remove_file: bool = False) -> dict[str, int | str]: async def clear_by_status(self, status_filter: str, remove_file: bool = False) -> dict[str, int | str]:
if not (items := await self.done.get_many_by_status(status_filter)): if self.config.remove_files is not True:
return {"deleted": 0} remove_file = False
remove_file = False if self.config.remove_files is not True else remove_file if not remove_file:
deleted_count = await self.done.bulk_delete_by_status(status_filter)
if deleted_count < 1:
return {"deleted": 0}
if remove_file: self._notify.emit(
return await self.clear_bulk([item_id for item_id, _ in items], remove_file=remove_file) 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}
deleted_count: int = await self.done.bulk_delete_by_status(status_filter) items = await self.done.get_many_by_status(status_filter)
item_summaries: list[dict[str, str | None]] = [ return await self.clear_bulk([item_id for item_id, _ in items], remove_file=remove_file)
{
"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
]
self._notify.emit( async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]:
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. Get the download queue and the download history.
@ -725,32 +511,6 @@ class DownloadQueue(metaclass=Singleton):
return items 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]: async def get_item(self, **kwargs) -> tuple["StoreType", Download] | tuple[None, None]:
""" """
Get a specific item from the download queue or history. Get a specific item from the download queue or history.
@ -759,7 +519,7 @@ class DownloadQueue(metaclass=Singleton):
**kwargs: The key-value pair to search for. Supported keys are 'id', 'url'. **kwargs: The key-value pair to search for. Supported keys are 'id', 'url'.
Returns: Returns:
tuple[StoreType, Download] | tuple[None, None]: The requested item if found, otherwise None. (StoreType, Download) | None: The requested item if found, otherwise None.
""" """
from app.library.DataStore import StoreType from app.library.DataStore import StoreType

View file

@ -55,37 +55,7 @@ class StatusTracker:
self.final_update = False self.final_update = False
self._terminator_sent: bool = False self._terminator_sent: bool = False
self._candidate_filepath: Path | None = None self._candidate_filepath: Path | None = None
self.update_task: asyncio.Future[Any] | None = None self.update_task: asyncio.Task | 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: async def _finalize_file(self, filepath: Path) -> None:
""" """
@ -98,36 +68,12 @@ class StatusTracker:
self.info.datetime = str(formatdate(time.time())) self.info.datetime = str(formatdate(time.time()))
self.info.filename = safe_relative_path(filepath, Path(self.download_dir)) self.info.filename = safe_relative_path(filepath, Path(self.download_dir))
self.final_update = True self.final_update = True
self.logger.debug( self.logger.debug(f"Final file name: '{filepath}'.")
"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: try:
filepath.relative_to(self.download_dir) filepath.relative_to(self.download_dir)
except ValueError: except ValueError:
self.logger.warning( self.logger.warning(
"Final file '%s' for '%s' is outside download directory '%s'.", f"Final file '{filepath}' is outside of the intended download directory '{self.download_dir}'."
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 self.info.filename = None
return return
@ -151,19 +97,8 @@ class StatusTracker:
except Exception as e: except Exception as e:
self.info.extras["is_video"] = True self.info.extras["is_video"] = True
self.info.extras["is_audio"] = True self.info.extras["is_audio"] = True
self.logger.exception( self.logger.exception(e)
"Failed to inspect completed file '%s' with ffprobe.", self.logger.error(f"Failed to run ffprobe. {e}")
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: async def process_status_update(self, status: StatusDict) -> None:
""" """
@ -180,33 +115,11 @@ class StatusTracker:
return return
if status.get("id") != self.id or len(status) < 2: if status.get("id") != self.id or len(status) < 2:
self.logger.warning( self.logger.warning(f"Received invalid status update. {status}")
"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 return
if self.debug: if self.debug:
self.logger.debug( self.logger.debug(f"Status Update: _id={self.info._id} status={status}")
"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): if isinstance(status, str):
self._notify.emit(Events.ITEM_UPDATED, data=self.info) self._notify.emit(Events.ITEM_UPDATED, data=self.info)
@ -214,7 +127,6 @@ class StatusTracker:
self.tmpfilename = status.get("tmpfilename") self.tmpfilename = status.get("tmpfilename")
old_status = self.info.status
self.info.status = status.get("status", self.info.status) self.info.status = status.get("status", self.info.status)
if "download_skipped" in status: if "download_skipped" in status:
self.info.download_skipped = bool(status.get("download_skipped")) self.info.download_skipped = bool(status.get("download_skipped"))
@ -227,19 +139,7 @@ class StatusTracker:
if self.info.status == "error" and "error" in status: if self.info.status == "error" and "error" in status:
self.info.error = status.get("error") self.info.error = status.get("error")
if self._candidate_filepath and self._candidate_filepath.exists(): if self._candidate_filepath and self._candidate_filepath.exists():
self.logger.debug( self.logger.debug(f"Cleaning up partial file: {self._candidate_filepath}")
"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) await self._finalize_file(self._candidate_filepath)
self._notify.emit( self._notify.emit(
@ -256,7 +156,7 @@ class StatusTracker:
self.info.percent = status["downloaded_bytes"] / total * 100 self.info.percent = status["downloaded_bytes"] / total * 100
except ZeroDivisionError: except ZeroDivisionError:
self.info.percent = 0 self.info.percent = 0
self.info.total_bytes = int(total) self.info.total_bytes = total
self.info.speed = status.get("speed") self.info.speed = status.get("speed")
self.info.eta = status.get("eta") self.info.eta = status.get("eta")
@ -266,11 +166,7 @@ class StatusTracker:
await self._finalize_file(Path(final_name)) await self._finalize_file(Path(final_name))
self.info.status = "finished" self.info.status = "finished"
if self.info.status != old_status or self.final_update: self._notify.emit(Events.ITEM_UPDATED, data=self.info)
self._flush_progress()
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
else:
self._emit_progress()
async def progress_update(self) -> None: async def progress_update(self) -> None:
""" """
@ -280,15 +176,12 @@ class StatusTracker:
""" """
while True: while True:
try: try:
update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get) self.update_task = asyncio.get_running_loop().run_in_executor(None, self.status_queue.get)
self.update_task = asyncio.ensure_future(update_task) status = await self.update_task
status = await update_task
if status is None or isinstance(status, Terminator): if status is None or isinstance(status, Terminator):
self._flush_progress()
return return
await self.process_status_update(status) await self.process_status_update(status)
except (asyncio.CancelledError, OSError, FileNotFoundError, EOFError, BrokenPipeError, ConnectionError): except (asyncio.CancelledError, OSError, FileNotFoundError, EOFError, BrokenPipeError, ConnectionError):
self._flush_progress()
return return
async def drain_queue(self, max_iterations: int = 50) -> None: async def drain_queue(self, max_iterations: int = 50) -> None:
@ -310,19 +203,7 @@ class StatusTracker:
for i in range(drain_count): for i in range(drain_count):
if self.final_update: if self.final_update:
self.logger.info( self.logger.info(f"({max_iterations}/{i}) Draining stopped. Final update received.")
"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 break
try: try:
@ -333,27 +214,13 @@ class StatusTracker:
except (queue.Empty, BrokenPipeError, ConnectionRefusedError, EOFError, OSError): except (queue.Empty, BrokenPipeError, ConnectionRefusedError, EOFError, OSError):
continue continue
self._flush_progress()
def cancel_update_task(self) -> None: def cancel_update_task(self) -> None:
"""Cancel the progress update task if it's running.""" """Cancel the progress update task if it's running."""
try: try:
if self.update_task and not self.update_task.done(): if self.update_task and not self.update_task.done():
self.update_task.cancel() self.update_task.cancel()
except Exception as e: except Exception as e:
self.logger.exception( self.logger.error(f"Failed to cancel update task. {e}")
"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: def put_terminator(self) -> None:
"""Put a terminator sentinel in the status queue.""" """Put a terminator sentinel in the status queue."""

View file

@ -80,20 +80,7 @@ class TempManager:
and self.info.downloaded_bytes and self.info.downloaded_bytes
and self.info.downloaded_bytes > 0 and self.info.downloaded_bytes > 0
): ):
self.logger.warning( self.logger.warning(f"Keeping temp folder '{self.temp_path}'. status={self.info.status}")
"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 return
tmp_dir = Path(self.temp_path) tmp_dir = Path(self.temp_path)
@ -102,50 +89,12 @@ class TempManager:
return return
if not self.temp_dir or not is_safe_to_delete_dir(tmp_dir, self.temp_dir): if not self.temp_dir or not is_safe_to_delete_dir(tmp_dir, self.temp_dir):
self.logger.warning( self.logger.warning(f"Refusing to delete video temp folder '{self.temp_path}' as it's temp root.")
"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 return
status: bool = delete_dir(tmp_dir) status: bool = delete_dir(tmp_dir)
if by_pass: if by_pass:
tmp_dir.mkdir(parents=True, exist_ok=True) tmp_dir.mkdir(parents=True, exist_ok=True)
self.logger.info( self.logger.info(f"Temp folder '{self.temp_path}' emptied.")
"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: else:
self.logger.info( self.logger.info(f"Temp folder '{self.temp_path}' deletion is {'success' if status else 'failed'}.")
"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,
}
},
)

View file

@ -128,12 +128,7 @@ def parse_extractor_limit(
limit: int = min(int(env_limit), max_workers) limit: int = min(int(env_limit), max_workers)
else: else:
if env_limit and logger: if env_limit and logger:
logger.warning( logger.warning(f"Invalid extractor limit '{env_limit}' for '{extractor}', using default limit.")
"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 limit = default_limit
return min(limit, max_workers) return min(limit, max_workers)
@ -161,12 +156,7 @@ def get_extractor_limit(
if extractor not in LIMITS: if extractor not in LIMITS:
limit = parse_extractor_limit(extractor, max_workers_per_extractor, max_workers, logger) limit = parse_extractor_limit(extractor, max_workers_per_extractor, max_workers, logger)
LIMITS[extractor] = asyncio.Semaphore(limit) LIMITS[extractor] = asyncio.Semaphore(limit)
logger.info( logger.info(f"Created limits container for extractor '{extractor}': {limit}")
"Created download limit for extractor '%s' with %s worker(s).",
extractor,
limit,
extra={"extractor": extractor, "limit": limit, "max_workers": max_workers},
)
return LIMITS[extractor] return LIMITS[extractor]
@ -257,10 +247,7 @@ def handle_task_exception(task: asyncio.Task, logger: logging.Logger) -> None:
return return
task_name: str = task.get_name() if task.get_name() else "unknown_task" task_name: str = task.get_name() if task.get_name() else "unknown_task"
logger.error( import traceback
"Unhandled exception in background task '%s'. %s",
task_name, tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
exc, logger.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {tb}")
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