Merge pull request #553 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

Fix: sidecar loading for history items
This commit is contained in:
Abdulmohsen 2026-01-28 18:07:57 +03:00 committed by GitHub
commit d17aa6c29e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 2439 additions and 2595 deletions

24
API.md
View file

@ -117,7 +117,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [Connection Events](#connection-events)
- [`config_update`](#config_update)
- [`connected`](#connected)
- [`active_queue`](#active_queue)
- [Logging Events](#logging-events)
- [`log_info`](#log_info)
- [`log_success`](#log_success)
@ -2742,29 +2741,6 @@ Emitted when a client successfully connects to the WebSocket.
---
##### `active_queue`
Emitted periodically with the current active queue status.
**Event**:
```json
{
"event": "active_queue",
"data": {
"queue": [
{
"id": "abc123",
"status": "downloading",
"progress": 45.6,
...
}
]
}
}
```
---
#### Logging Events
##### `log_info`

View file

@ -87,8 +87,8 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
preset: str = params.get("preset", config.default_preset)
key: str = cache.hash(url + str(preset))
if not cache.has(key):
from app.library.downloads.extractor import fetch_info
from app.library.YTDLPOpts import YTDLPOpts
from app.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
(data, _) = await fetch_info(
config=YTDLPOpts.get_instance().preset(name=preset).get_all(),
@ -115,7 +115,7 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
)
try:
from app.library.mini_filter import match_str
from app.features.ytdlp.mini_filter import match_str
status: bool = match_str(cond, data)
except Exception as e:

View file

@ -6,8 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
from app.features.core.schemas import Pagination
from app.features.core.utils import parse_int
from app.library.mini_filter import match_str
from app.library.Utils import arg_converter
from app.features.ytdlp.mini_filter import match_str
class Condition(BaseModel):
@ -38,6 +37,8 @@ class Condition(BaseModel):
if not value:
return ""
try:
from app.features.ytdlp.utils import arg_converter
arg_converter(args=value)
except ModuleNotFoundError:
return value

View file

@ -4,8 +4,8 @@ from aiohttp import web
from app.features.conditions.models import ConditionModel
from app.features.conditions.repository import ConditionsRepository
from app.features.ytdlp.mini_filter import match_str
from app.library.Events import EventBus, Events
from app.library.mini_filter import match_str
from app.library.Singleton import Singleton
LOG: logging.Logger = logging.getLogger("feature.conditions")

View file

@ -79,5 +79,20 @@ def format_validation_errors(exc: ValidationError) -> list[dict[str, Any]]:
def gen_random(length: int = 16) -> str:
import secrets
import string
return "".join(secrets.token_urlsafe(length)[:length])
if length < 1:
msg = "length must be >= 1"
raise ValueError(msg)
middle_alphabet = string.ascii_letters + string.digits + "-_"
edge_alphabet = string.ascii_letters + string.digits
if 1 == length:
return secrets.choice(edge_alphabet)
return (
secrets.choice(edge_alphabet)
+ "".join(secrets.choice(middle_alphabet) for _ in range(length - 2))
+ secrets.choice(edge_alphabet)
)

View file

@ -9,7 +9,6 @@ from app.features.core.migration import Migration as FeatureMigration
from app.features.presets.schemas import Preset
from app.features.presets.utils import preset_name
from app.library.config import Config
from app.library.Utils import arg_converter
if TYPE_CHECKING:
from app.features.presets.repository import PresetsRepository
@ -87,6 +86,8 @@ class Migration(FeatureMigration):
if cli:
try:
from app.features.ytdlp.utils import arg_converter
arg_converter(args=cli, level=True)
except Exception as exc:
LOG.warning("Skipping preset '%s' due to invalid CLI: %s", name, exc)

View file

@ -9,7 +9,7 @@ from app.features.core.schemas import Pagination
from app.features.core.utils import parse_int
from app.features.presets.utils import preset_name
from app.library.config import Config
from app.library.Utils import arg_converter, create_cookies_file
from app.library.Utils import create_cookies_file
class Preset(BaseModel):
@ -58,6 +58,8 @@ class Preset(BaseModel):
return ""
try:
from app.features.ytdlp.utils import arg_converter
arg_converter(args=value, level=True)
except Exception as e:
msg = f"Invalid command options for yt-dlp: {e!s}"

View file

View file

@ -13,13 +13,10 @@ from pathlib import Path
import anyio
from app.features.streaming.types import FFProbeError
from app.library.Utils import timed_lru_cache
LOG: logging.Logger = logging.getLogger(__name__)
class FFProbeError(Exception):
pass
LOG: logging.Logger = logging.getLogger("streaming.ffprobe")
class FFStream:

View file

@ -2,8 +2,8 @@ import math
from pathlib import Path
from urllib.parse import quote
from .ffprobe import ffprobe
from .Utils import StreamingError
from app.features.streaming.library.ffprobe import ffprobe
from app.features.streaming.types import StreamingError
class M3u8:

View file

@ -1,8 +1,9 @@
from pathlib import Path
from urllib.parse import quote
from .ffprobe import ffprobe
from .Utils import StreamingError, get_file_sidecar
from app.features.streaming.library.ffprobe import ffprobe
from app.features.streaming.types import StreamingError
from app.library.Utils import get_file_sidecar
class Playlist:

View file

@ -85,7 +85,7 @@ def ffmpeg_encoders() -> set[str]:
set[str]: A set of available ffmpeg encoder names.
"""
from .config import SUPPORTED_CODECS
from app.library.config import SUPPORTED_CODECS
try:
result: subprocess.CompletedProcess[str] = subprocess.run(
@ -122,7 +122,7 @@ def select_encoder(configured: str) -> str:
str: The selected concrete encoder name.
"""
from .config import SUPPORTED_CODECS
from app.library.config import SUPPORTED_CODECS
configured = (configured or "").strip()

View file

@ -10,21 +10,21 @@ from typing import TYPE_CHECKING, ClassVar
from aiohttp import web
from .config import SUPPORTED_CODECS, Config
from .ffprobe import ffprobe
from .SegmentEncoders import (
from app.features.streaming.library.ffprobe import ffprobe
from app.features.streaming.library.segment_encoders import (
detect_qsv_capabilities,
encoder_fallback_chain,
get_builder_for_codec,
has_dri_devices,
select_encoder,
)
from app.library.config import SUPPORTED_CODECS, Config
if TYPE_CHECKING:
from asyncio.subprocess import Process
from .ffprobe import FFProbeResult
from .SegmentEncoders import EncoderBuilder
from .segment_encoders import EncoderBuilder
LOG: logging.Logger = logging.getLogger("player.segments")

View file

@ -6,7 +6,7 @@ import pysubs2
from pysubs2.formats.substation import SubstationFormat
from pysubs2.time import ms_to_times
from .Utils import ALLOWED_SUBS_EXTENSIONS
from app.library.Utils import ALLOWED_SUBS_EXTENSIONS
LOG: logging.Logger = logging.getLogger("player.subtitle")

View file

@ -0,0 +1,279 @@
import logging
import time
from datetime import UTC, datetime
from pathlib import Path
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.streaming.library.m3u8 import M3u8
from app.features.streaming.library.playlist import Playlist
from app.features.streaming.library.segments import Segments
from app.features.streaming.library.subtitle import Subtitle
from app.features.streaming.types import StreamingError
from app.library.config import Config
from app.library.router import route
from app.library.Utils import get_file
LOG: logging.Logger = logging.getLogger("streaming")
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
async def playlist_create(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the playlist.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
base_path: str = config.base_path.rstrip("/")
try:
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=web.HTTPFound.status_code,
headers={
"Location": str(
app.router["playlist_create"].url_for(
file=str(realFile).replace(config.download_path, "").strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
return web.Response(
text=await Playlist(download_path=Path(config.download_path), url=f"{base_path}/").make(file=realFile),
headers={
"Content-Type": "application/x-mpegURL",
"Cache-Control": "no-cache",
"Access-Control-Max-Age": "300",
},
status=web.HTTPOk.status_code,
)
except StreamingError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8_create")
async def m3u8_create(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the m3u8 file.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
mode: str = request.match_info.get("mode")
if mode not in ["video", "subtitle"]:
return web.json_response(
data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code
)
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
duration = request.query.get("duration", None)
if "subtitle" in mode:
if not duration:
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
duration = float(duration)
base_path: str = config.base_path.rstrip("/")
try:
cls = M3u8(download_path=Path(config.download_path), url=f"{base_path}/")
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["m3u8_create"].url_for(
mode=mode, file=str(realFile).replace(config.download_path, "").strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
if "subtitle" in mode:
text = await cls.make_subtitle(file=realFile, duration=duration)
else:
text = await cls.make_stream(file=realFile)
except StreamingError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
return web.Response(
text=text,
headers={
"Content-Type": "application/x-mpegURL",
"Cache-Control": "no-cache",
"Access-Control-Max-Age": "300",
},
status=web.HTTPOk.status_code,
)
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
async def segments_stream(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the segments.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
segment: int = request.match_info.get("segment")
sd: int = request.query.get("sd")
vc: int = int(request.query.get("vc", 0))
ac: int = int(request.query.get("ac", 0))
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
if not segment:
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["segments_stream"].url_for(
segment=segment,
file=str(realFile).replace(config.download_path, "").strip("/"),
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
mtime = realFile.stat().st_mtime
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
resp = web.StreamResponse(
status=web.HTTPOk.status_code,
headers={
"Content-Type": "video/mpegts",
"X-Accel-Buffering": "no",
"Access-Control-Allow-Origin": "*",
"Pragma": "public",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Last-Modified": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
),
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
),
},
)
await resp.prepare(request)
await Segments(
download_path=config.download_path,
index=int(segment),
duration=float(f"{float(sd if sd else M3u8.duration):.6f}"),
vconvert=vc == 1,
aconvert=ac == 1,
).stream(realFile, resp)
return resp
@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles_get")
async def subtitles_get(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the subtitles.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["subtitles_get"].url_for(file=str(realFile).replace(config.download_path, "").strip("/"))
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
mtime = realFile.stat().st_mtime
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
return web.Response(
body=await Subtitle().make(file=realFile),
headers={
"Content-Type": "text/vtt; charset=UTF-8",
"X-Accel-Buffering": "no",
"Access-Control-Allow-Origin": "*",
"Pragma": "public",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Last-Modified": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
),
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
),
},
status=web.HTTPOk.status_code,
)

View file

@ -23,7 +23,7 @@ class TestFFProbe:
@pytest.mark.asyncio
async def test_ffprobe_with_existing_file(self):
"""Test ffprobe with an existing file."""
from app.library.ffprobe import FFProbeResult, ffprobe
from app.features.streaming.library.ffprobe import FFProbeResult, ffprobe
# Mock subprocess to avoid actual ffprobe execution
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
@ -43,7 +43,7 @@ class TestFFProbe:
@pytest.mark.asyncio
async def test_ffprobe_with_nonexistent_file(self):
"""Test ffprobe with a non-existent file."""
from app.library.ffprobe import ffprobe
from app.features.streaming.library.ffprobe import ffprobe
nonexistent_file = Path(self.temp_dir) / "does_not_exist.mp4"
@ -53,7 +53,7 @@ class TestFFProbe:
@pytest.mark.asyncio
async def test_ffprobe_caching_behavior(self):
"""Test that ffprobe results are cached with enhanced async timed_lru_cache."""
from app.library.ffprobe import ffprobe
from app.features.streaming.library.ffprobe import ffprobe
assert hasattr(ffprobe, "cache_clear"), (
"Test that the function has been decorated with caching - ffprobe should have cache_clear method from timed_lru_cache"
@ -103,7 +103,7 @@ class TestFFProbe:
@pytest.mark.asyncio
async def test_ffprobe_with_path_object(self):
"""Test ffprobe with Path object input."""
from app.library.ffprobe import ffprobe
from app.features.streaming.library.ffprobe import ffprobe
# Mock subprocess to avoid actual ffprobe execution
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
@ -122,7 +122,7 @@ class TestFFProbe:
def test_ffprobe_result_properties(self):
"""Test FFProbeResult object properties."""
from app.library.ffprobe import FFProbeResult, FFStream
from app.features.streaming.library.ffprobe import FFStream, FFProbeResult
result = FFProbeResult()
@ -149,7 +149,7 @@ class TestFFProbe:
def test_stream_object_methods(self):
"""Test Stream object methods."""
from app.library.ffprobe import FFStream
from app.features.streaming.library.ffprobe import FFStream
# Test video stream
video_stream = FFStream(

View file

@ -4,8 +4,8 @@ from urllib.parse import quote
import pytest
from app.library.M3u8 import M3u8
from app.library.Utils import StreamingError
from app.features.streaming.library.m3u8 import M3u8
from app.features.streaming.types import StreamingError
class _Stream:
@ -38,7 +38,7 @@ async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.M
],
)
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
m3 = M3u8(download_path=base, url="http://host/")
out = await m3.make_stream(media)
@ -79,7 +79,7 @@ async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeyp
],
)
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
m3 = M3u8(download_path=base, url="https://s/")
out = await m3.make_stream(media)
@ -116,7 +116,7 @@ async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch:
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={})
monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe)
m3 = M3u8(download_path=base, url="http://s/")
with pytest.raises(StreamingError, match="Unable to get"):

View file

@ -4,8 +4,8 @@ from urllib.parse import quote
import pytest
from app.library.Playlist import Playlist
from app.library.Utils import StreamingError
from app.features.streaming.library.playlist import Playlist
from app.features.streaming.types import StreamingError
@pytest.mark.asyncio
@ -21,14 +21,14 @@ async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.Mo
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={"duration": "60"})
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe)
# No sidecar subtitles
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []})
monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: {"subtitle": []})
# Patch module-level quote to be robust for Path objects
from urllib.parse import quote as _std_quote
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
monkeypatch.setattr("app.features.streaming.library.playlist.quote", lambda v: _std_quote(str(v)))
pl = Playlist(download_path=base, url="http://localhost/")
out = await pl.make(media)
@ -54,7 +54,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={"duration": "12.5"})
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe)
# Build two subtitle sidecars with names and langs; ensure quoting/relative name used
sub1 = media.with_suffix(".en.srt")
@ -65,11 +65,11 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.
{"lang": "fr", "file": sub2, "name": "French"},
]
}
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar)
monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: sidecar)
from urllib.parse import quote as _std_quote
monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v)))
monkeypatch.setattr("app.features.streaming.library.playlist.quote", lambda v: _std_quote(str(v)))
pl = Playlist(download_path=base, url="https://server/")
out = await pl.make(media)
@ -109,8 +109,8 @@ async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch
async def fake_ffprobe(_file: Path):
return SimpleNamespace(metadata={})
monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []})
monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: {"subtitle": []})
pl = Playlist(download_path=base, url="http://localhost/")

View file

@ -7,7 +7,7 @@ from typing import Any
import pytest
from app.library.Segments import Segments
from app.features.streaming.library.segments import Segments
class DummyFF:
@ -32,7 +32,7 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
seg = Segments(download_path=str(tmp_path), index=2, duration=5.5, vconvert=False, aconvert=False)
@ -84,7 +84,7 @@ async def test_build_ffmpeg_args_audio_only(tmp_path: Path, monkeypatch: pytest.
async def fake_ffprobe(_file: Path):
return DummyFF(v=False, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
seg = Segments(download_path=str(tmp_path), index=0, duration=9.0, vconvert=False, aconvert=False)
@ -175,13 +175,16 @@ async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, m
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
# Simulate no /dev/dri present but GPU encoders otherwise available
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: False)
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"})
monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: False)
monkeypatch.setattr(
"app.features.streaming.library.segment_encoders.ffmpeg_encoders",
lambda: {"h264_nvenc", "h264_qsv", "h264_amf"},
)
# reset encoder cache to ensure clean selection in this test
from app.library.Segments import Segments as _Seg
from app.features.streaming.library.segments import Segments as _Seg
_Seg._cached_vcodec = None
_Seg._cache_initialized = False
@ -218,10 +221,10 @@ async def test_stream_gpu_failure_falls_back_to_software(
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
# Allow GPU usage and advertise an NVENC encoder so first pick is GPU
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True)
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc"})
monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: True)
monkeypatch.setattr("app.features.streaming.library.segment_encoders.ffmpeg_encoders", lambda: {"h264_nvenc"})
# First process fails (no data, rc=1), second succeeds and outputs bytes
proc_fail = _FakeProcFail(err=b"nvenc failure: encoder not available")
@ -239,7 +242,7 @@ async def test_stream_gpu_failure_falls_back_to_software(
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
# reset encoder cache to ensure we try GPU first
from app.library.Segments import Segments as _Seg
from app.features.streaming.library.segments import Segments as _Seg
_Seg._cached_vcodec = None
_Seg._cache_initialized = False
@ -270,12 +273,12 @@ async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatc
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
# Only QSV advertised so initial build sets QSV
# Patch both where it's defined AND where it's imported/used
monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True)
monkeypatch.setattr("app.library.Segments.has_dri_devices", lambda: True)
monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_qsv"})
monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: True)
monkeypatch.setattr("app.features.streaming.library.segments.has_dri_devices", lambda: True)
monkeypatch.setattr("app.features.streaming.library.segment_encoders.ffmpeg_encoders", lambda: {"h264_qsv"})
# Fail first, succeed second
proc_fail = _FakeProcFail(err=b"qsv failure")
@ -290,7 +293,7 @@ async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatc
monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec)
# reset cache
from app.library.Segments import Segments as _Seg
from app.features.streaming.library.segments import Segments as _Seg
_Seg._cached_vcodec = None
_Seg._cache_initialized = False
@ -338,7 +341,7 @@ async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Pat
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
# Process that yields two chunks and then EOF
proc = _FakeProc([b"abc", b"def", b""])
@ -361,7 +364,7 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail
@ -384,7 +387,7 @@ async def test_stream_cancelled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
async def fake_ffprobe(_file: Path):
return DummyFF(v=True, a=True)
monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe)
monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe)
proc = _FakeProc([b"abc"]) # only one chunk

View file

@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from app.library.Subtitle import Subtitle, ms_to_timestamp
from app.features.streaming.library.subtitle import Subtitle, ms_to_timestamp
class TestMsToTimestamp:
@ -59,7 +59,7 @@ async def test_make_no_events_raises(tmp_path: Path) -> None:
srt = tmp_path / "sub.srt"
srt.write_text("dummy")
with patch("app.library.Subtitle.pysubs2.load") as mock_load:
with patch("app.features.streaming.library.subtitle.pysubs2.load") as mock_load:
mock_load.return_value = _DummySubs(events=[])
sub = Subtitle()
with pytest.raises(Exception, match="No subtitle events were found"):
@ -74,7 +74,7 @@ async def test_make_single_event_returns_vtt(tmp_path: Path) -> None:
single = SimpleNamespace(end=1000)
d = _DummySubs(events=[single])
with patch("app.library.Subtitle.pysubs2.load", return_value=d):
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle()
out = await sub.make(srt)
assert out == "OUT"
@ -90,7 +90,7 @@ async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None
e2 = SimpleNamespace(end=5000)
d = _DummySubs(events=[e1, e2])
with patch("app.library.Subtitle.pysubs2.load", return_value=d):
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle()
out = await sub.make(srt)
assert out == "OUT"
@ -106,7 +106,7 @@ async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None:
e2 = SimpleNamespace(end=6000)
d = _DummySubs(events=[e1, e2])
with patch("app.library.Subtitle.pysubs2.load", return_value=d):
with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d):
sub = Subtitle()
out = await sub.make(srt)
assert out == "OUT"

View file

@ -0,0 +1,6 @@
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
class FFProbeError(Exception):
"""Raised when an error occurs during FFProbe processing."""

View file

@ -20,11 +20,11 @@ from app.features.tasks.definitions.schemas import (
ExtractionRule,
TaskDefinition,
)
from app.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.utils import get_archive_id
from app.library.cache import Cache
from app.library.config import Config
from app.library.downloads.extractor import fetch_info
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler

View file

@ -5,9 +5,9 @@ from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.utils import get_archive_id
from app.library.cache import Cache
from app.library.downloads.extractor import fetch_info
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler

View file

@ -2,7 +2,7 @@ import logging
import re
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from app.features.ytdlp.utils import get_archive_id
from ._base_handler import BaseHandler

View file

@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
from xml.etree.ElementTree import Element
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from app.features.ytdlp.utils import get_archive_id
from ._base_handler import BaseHandler

View file

@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from app.features.ytdlp.utils import get_archive_id
from ._base_handler import BaseHandler

View file

@ -5,7 +5,7 @@ from pathlib import Path
from typing import Any
from app.features.tasks.schemas import Task as TaskSchema
from app.library.YTDLPOpts import YTDLPOpts
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
from .utils import split_inspect_metadata
@ -19,7 +19,7 @@ class HandleTask(TaskSchema):
YTDLPOpts: The yt-dlp options.
"""
from app.library.YTDLPOpts import YTDLPOpts
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
params: YTDLPOpts = YTDLPOpts.get_instance()
@ -42,7 +42,7 @@ class HandleTask(TaskSchema):
tuple[bool, str]: A tuple indicating success and a message.
"""
from app.library.Utils import archive_add
from app.features.ytdlp.utils import archive_add
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
if isinstance(ret, tuple):
@ -64,7 +64,7 @@ class HandleTask(TaskSchema):
tuple[bool, str]: A tuple indicating success and a message.
"""
from app.library.Utils import archive_delete
from app.features.ytdlp.utils import archive_delete
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
if isinstance(ret, tuple):
@ -90,7 +90,7 @@ class HandleTask(TaskSchema):
indicating if the operation was successful, and a message.
"""
from app.library.ytdlp import fetch_info
from app.features.ytdlp.ytdlp import fetch_info
if not self.url:
return ({}, False, "No URL found in task parameters.")
@ -122,7 +122,7 @@ class HandleTask(TaskSchema):
tuple[bool, str] | dict[str, Any]: Either an error tuple or a dict with 'file' and 'items' keys.
"""
from app.library.ytdlp import fetch_info
from app.features.ytdlp.ytdlp import fetch_info
if not self.url:
return (False, "No URL found in task parameters.")

View file

@ -11,11 +11,11 @@ from typing import TYPE_CHECKING, Any
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.features.tasks.models import TaskModel
from app.features.ytdlp.utils import archive_read
from app.library.downloads.queue_manager import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item, ItemDTO
from app.library.Services import Services
from app.library.Utils import archive_read
if TYPE_CHECKING:
from app.features.tasks.repository import TasksRepository

View file

@ -12,12 +12,13 @@ from app.features.tasks.definitions.results import TaskFailure, TaskResult
from app.features.tasks.definitions.service import TaskHandle
from app.features.tasks.repository import TasksRepository
from app.features.tasks.schemas import Task, TaskList, TaskPatch
from app.features.ytdlp.utils import parse_outtmpl
from app.library.ag_utils import ag
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.router import route
from app.library.Utils import get_channel_images, get_file, parse_outtmpl, validate_url
from app.library.Utils import get_channel_images, get_file, validate_url
if TYPE_CHECKING:
from pathlib import Path

View file

@ -98,9 +98,9 @@ class Task(BaseModel):
if not value:
return ""
from app.library.Utils import arg_converter
try:
from app.features.ytdlp.utils import arg_converter
arg_converter(args=value)
except Exception as e:
msg = f"Invalid command options for yt-dlp: {e!s}"

View file

View file

@ -6,10 +6,12 @@ from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Any
from app.library.LogWrapper import LogWrapper
from app.library.mini_filter import match_str
from aiohttp import web
from app.features.ytdlp.utils import _DATA, LogWrapper, get_archive_id
from app.features.ytdlp.ytdlp import YTDLP
from app.library.Services import Services
from app.library.Singleton import Singleton
from app.library.ytdlp import YTDLP
LOG: logging.Logger = logging.getLogger("downloads.extractor")
@ -61,6 +63,11 @@ class ExtractorPool(metaclass=Singleton):
"""
return cls()
def attach(self, app: web.Application) -> None:
"""Attach the extractor pool to the application (no-op for now)."""
app.on_shutdown.append(self.shutdown)
Services.get_instance().add(__class__.__name__, self)
def _ensure_initialized(self, config: ExtractorConfig) -> None:
"""
Ensure pool and semaphore are initialized.
@ -190,22 +197,6 @@ def _sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
return sanitized
def _get_archive_id_dict(url: str) -> dict[str, str | None]:
"""
Get archive ID for logging purposes.
Args:
url: URL to get archive ID for
Returns:
Dictionary with id, ie_key, and archive_id
"""
from app.library.Utils import get_archive_id
return get_archive_id(url=url)
def extract_info_sync(
config: dict[str, Any],
url: str,
@ -233,23 +224,14 @@ def extract_info_sync(
tuple[dict | None, list[dict]]: Extracted information and captured logs.
"""
params: dict[str, Any] = {
**config,
"simulate": True,
"color": "no_color",
"extract_flat": True,
"skip_download": True,
"ignoreerrors": True,
"ignore_no_formats_error": True,
}
params: dict[str, Any] = {**config, **_DATA.YTDLP_PARAMS, "simulate": True}
if debug:
params["verbose"] = True
else:
params["quiet"] = True
params.pop("quiet", None)
log_wrapper = LogWrapper()
id_dict: dict[str, str | None] = _get_archive_id_dict(url=url)
id_dict: dict[str, str | None] = get_archive_id(url=url)
archive_id: str | None = f".{id_dict['id']}" if id_dict.get("id") else None
log_wrapper.add_target(
@ -297,9 +279,14 @@ def extract_info_sync(
if not data:
return (data, captured_logs)
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
if not data["is_premiere"]:
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
try:
from app.features.ytdlp.mini_filter import match_str
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
if not data["is_premiere"]:
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
except ImportError:
pass
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
@ -399,7 +386,3 @@ async def fetch_info(
)
finally:
semaphore.release()
async def shutdown_extractor() -> None:
await ExtractorPool.get_instance().shutdown()

View file

@ -0,0 +1,507 @@
import json
import logging
import time
from collections import OrderedDict
from collections.abc import Iterable
from typing import Any
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.presets.service import Presets
from app.features.ytdlp.archiver import Archiver
from app.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.utils import archive_read, arg_converter, get_archive_id
from app.features.ytdlp.ytdlp_opts import YTDLPCli, YTDLPOpts
from app.library.cache import Cache
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.ItemDTO import Item
from app.library.router import route
from app.library.Utils import validate_url
LOG: logging.Logger = logging.getLogger(__name__)
def _get_preset_archive(preset: str) -> str | None:
"""
Resolve the archive file path for a given preset.
Validates that the preset exists and that applying the preset results
in yt-dlp options that contain a 'download_archive' path.
"""
if not preset or not Presets.get_instance().has(preset):
return None
try:
opts: dict = YTDLPOpts.get_instance().preset(preset).get_all()
except Exception as e:
LOG.error(f"Failed to build yt-dlp opts for preset '{preset}'. {e!s}")
return None
if not (archive_file := opts.get("download_archive")):
return None
if not isinstance(archive_file, str) or len(archive_file.strip()) < 1:
return None
return archive_file.strip()
def _normalize_ids(items: Iterable[str] | None) -> tuple[list[str], list[str]]:
"""
Validate and normalize archive IDs.
- Trims whitespace
- Enforces that each ID has at least two whitespace-separated tokens
(e.g., "youtube ABC123") as required by yt-dlp's archive format
- De-duplicates while preserving order
Returns a tuple: (valid_ids, invalid_inputs)
"""
if not items:
return ([], [])
seen: set[str] = set()
valid: list[str] = []
invalid: list[str] = []
for raw in items:
if raw is None:
continue
s = str(raw).strip()
if not s:
continue
if len(s.split()) < 2:
invalid.append(s)
continue
if s in seen:
continue
seen.add(s)
valid.append(s)
return (valid, invalid)
@route("GET", "api/archiver/", "archiver")
async def archiver_get(request: Request) -> Response:
"""
Read IDs from the download archive for a given preset.
Query params:
- preset: required preset name/id
- ids: optional comma-separated list to filter; when omitted, returns all
"""
preset: str | None = request.query.get("preset")
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_preset_archive(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
ids_param: str | None = request.query.get("ids")
ids: list[str] = []
if ids_param:
ids_list = [s.strip() for s in ids_param.split(",") if s and s.strip()]
ids, invalid = _normalize_ids(ids_list)
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
try:
data: list[str] = Archiver.get_instance().read(archive_file, ids or None)
return web.json_response(
data={"file": archive_file, "items": data, "count": len(data)}, status=web.HTTPOk.status_code
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to read archive file for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
@route("POST", "api/archiver/", "archiver_add")
async def archiver_add(request: Request) -> Response:
"""
Append IDs to the download archive for a given preset.
Body: { "preset": string, "items": [string, ...], "skip_check": bool? }
"""
post = await request.json()
preset: str | None = post.get("preset") if isinstance(post, dict) else None
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_preset_archive(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], [])
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
if len(items) < 1:
return web.json_response(
data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code
)
skip_check: bool = bool(post.get("skip_check", False)) if isinstance(post, dict) else False
try:
status: bool = Archiver.get_instance().add(archive_file, items, skip_check=skip_check)
return web.json_response(
data={"file": archive_file, "status": status, "items": items},
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to add items to archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
@route("DELETE", "api/archiver/", "archiver_delete")
async def archiver_delete(request: Request) -> Response:
"""
Remove IDs from the download archive for a given preset.
Body: { "preset": string, "items": [string, ...] }
"""
post = await request.json()
preset: str | None = post.get("preset") if isinstance(post, dict) else None
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_preset_archive(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], [])
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
if len(items) < 1:
return web.json_response(
data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code
)
try:
status: bool = Archiver.get_instance().delete(archive_file, items)
return web.json_response(
data={"file": archive_file, "status": status, "items": items},
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to delete items from archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
@route("POST", "api/yt-dlp/convert/", "convert")
async def convert(request: Request) -> Response:
"""
Convert the yt-dlp args to a dict.
Args:
request (Request): The request object.
Returns:
Response: The response object.
"""
post = await request.json()
args: str | None = post.get("args")
if not args:
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
try:
response = {"opts": {}, "output_template": None, "download_path": None}
data = arg_converter(args, dumps=True)
if "outtmpl" in data and "default" in data["outtmpl"]:
response["output_template"] = data["outtmpl"]["default"]
if "paths" in data and "home" in data["paths"]:
response["download_path"] = data["paths"]["home"]
if "format" in data:
response["format"] = data["format"]
from app.features.ytdlp.utils import _DATA
bad_options = {k: v for d in _DATA.REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key in data:
if key in bad_options.items():
removed_options.append(bad_options[key])
continue
if not key.startswith("_"):
response["opts"][key] = data[key]
if len(removed_options) > 0:
response["removed_options"] = removed_options
return web.json_response(data=response, status=web.HTTPOk.status_code)
except Exception as e:
err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err
err = err.replace("main.py: error: ", "").strip().capitalize()
return web.json_response(
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
status=web.HTTPBadRequest.status_code,
)
@route("GET", "api/yt-dlp/url/info/", "get_info")
async def get_info(request: Request, cache: Cache, config: Config) -> Response:
"""
Get the video info.
Args:
request (Request): The request object.
cache (Cache): The cache instance.
config (Config): The config instance.
Returns:
Response: The response object
"""
url: str | None = request.query.get("url")
if not url:
return web.json_response(
data={"status": False, "message": "URL is required.", "error": "URL is required."},
status=web.HTTPBadRequest.status_code,
)
try:
validate_url(url, allow_internal=config.allow_internal_urls)
except ValueError as e:
return web.json_response(
data={"status": False, "message": str(e), "error": str(e)},
status=web.HTTPBadRequest.status_code,
)
opts: YTDLPOpts = YTDLPOpts.get_instance()
preset: str = request.query.get("preset", config.default_preset)
if not Presets.get_instance().get(preset):
msg: str = f"Preset '{preset}' does not exist."
return web.json_response(
data={"status": False, "message": msg, "error": msg},
status=web.HTTPBadRequest.status_code,
)
opts = opts.preset(preset)
if cli_args := request.query.get("args", None):
try:
arg_converter(cli_args, dumps=True)
opts = opts.add_cli(cli_args, from_user=True)
except Exception as e:
err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err
err = err.replace("main.py: error: ", "").strip().capitalize()
return web.json_response(
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
status=web.HTTPBadRequest.status_code,
)
try:
key: str = cache.hash(f"{preset}:{url}:{cli_args or ''}")
if cache.has(key) and not request.query.get("force", False):
data: Any | None = cache.get(key)
data["_cached"] = {
"status": "hit",
"preset": preset,
"cli_args": cli_args,
"key": key,
"ttl": data.get("_cached", {}).get("ttl", 300),
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
"expires": data.get("_cached", {}).get("expires", time.time() + 300),
}
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
ytdlp_opts: dict = opts.get_all()
(data, logs) = await fetch_info(
config=ytdlp_opts,
url=url,
debug=False,
no_archive=True,
follow_redirect=True,
sanitize_info=True,
capture_logs=logging.WARNING,
)
if not data or not isinstance(data, dict):
return web.json_response(
data={
"status": False,
"error": f"Failed to extract video info. {'. '.join(logs)}",
"message": "Failed to extract video info.",
},
status=web.HTTPInternalServerError.status_code,
)
if data and "formats" in data:
from yt_dlp.cookies import LenientSimpleCookie
for index, item in enumerate(data["formats"]):
if "cookies" in item and len(item["cookies"]) > 0:
cookies: list[str] = [f"{c.key}={c.value}" for c in LenientSimpleCookie(item["cookies"]).values()]
if len(cookies) > 0:
data["formats"][index]["h_cookies"] = "; ".join(cookies)
data["formats"][index]["h_cookies"] = data["formats"][index]["h_cookies"].strip()
data["_cached"] = {
"status": "miss",
"preset": preset,
"cli_args": cli_args,
"key": key,
"ttl": 300,
"ttl_left": 300,
"expires": time.time() + 300,
}
data["is_archived"] = False
archive_file: str | None = ytdlp_opts.get("download_archive")
data["archive_file"] = archive_file if archive_file else None
if archive_file and (archive_id := get_archive_id(url=url).get("archive_id")):
data["archive_id"] = archive_id
data["is_archived"] = len(archive_read(archive_file, [archive_id])) > 0
data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))
cache.set(key=key, value=data, ttl=300)
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
except Exception as e:
LOG.exception(e)
LOG.error(f"Error encountered while getting video info for '{url}'. '{e!s}'.")
return web.json_response(
data={
"error": "failed to get video info.",
"message": str(e),
"formats": [],
},
status=web.HTTPInternalServerError.status_code,
)
@route("GET", "api/yt-dlp/options/", "get_options")
async def get_options() -> Response:
"""
Get the yt-dlp CLI options.
Returns:
Response: The response object with the yt-dlp CLI options.
"""
from app.features.ytdlp.ytdlp import ytdlp_options
return web.json_response(body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code)
@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids")
async def get_archive_ids(request: Request, config: Config) -> Response:
"""
Get the archive IDs for the given URLs.
Returns:
Response: The response object with the yt-dlp CLI options.
"""
data = (await request.json()) if request.body_exists else None
if not data or not isinstance(data, list):
return web.json_response(
data={"error": "Invalid request. expecting list with URLs."},
status=web.HTTPBadRequest.status_code,
)
response = []
for i, url in enumerate(data):
dct = {"index": i, "url": url}
try:
validate_url(url, allow_internal=config.allow_internal_urls)
dct.update(get_archive_id(url))
except ValueError as e:
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)})
response.append(dct)
return web.json_response(data=response, status=web.HTTPOk.status_code)
@route("POST", "api/yt-dlp/command/", "make_command")
async def make_command(request: Request, config: Config, encoder: Encoder) -> Response:
"""
Build yt-dlp CLI command.
Args:
request (Request): The request object.
config (Config): The config instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object with the merged fields and final yt-dlp CLI command string.
"""
if not config.console_enabled:
return web.json_response(data={"error": "Console is disabled."}, status=web.HTTPForbidden.status_code)
data = (await request.json()) if request.body_exists else None
if not data or not isinstance(data, dict):
return web.json_response(
data={"error": "Invalid request. expecting JSON body."},
status=web.HTTPBadRequest.status_code,
)
try:
it = Item.format(data)
except ValueError as e:
return web.json_response(data={"error": str(e), "data": data}, status=web.HTTPBadRequest.status_code)
try:
command, info = YTDLPCli(item=it, config=config).build()
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": "Failed to build CLI command"},
status=web.HTTPBadRequest.status_code,
)
if request.query.get("full", False):
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
return web.json_response(data={"command": command}, status=web.HTTPOk.status_code)

View file

@ -0,0 +1,36 @@
from unittest.mock import MagicMock, patch
from app.features.ytdlp.extractor import extract_info_sync
class TestExtractInfo:
"""Test the extract_info function."""
@patch("app.features.ytdlp.extractor.YTDLP")
def test_extract_info_basic(self, mock_ytdlp_class):
"""Test basic extract_info functionality."""
mock_ytdlp = MagicMock()
mock_ytdlp.extract_info.return_value = {"title": "Test Video", "id": "test123"}
mock_ytdlp_class.return_value = mock_ytdlp
config = {"quiet": True}
url = "https://example.com/video"
(result, logs) = extract_info_sync(config, url)
assert isinstance(result, dict), "Result should be a dictionary"
assert isinstance(logs, list), "Logs should be a list"
mock_ytdlp.extract_info.assert_called_once()
@patch("app.features.ytdlp.extractor.YTDLP")
def test_extract_info_with_debug(self, mock_ytdlp_class):
"""Test extract_info with debug enabled."""
mock_ytdlp = MagicMock()
mock_ytdlp.extract_info.return_value = {"title": "Test Video"}
mock_ytdlp_class.return_value = mock_ytdlp
config = {}
url = "https://example.com/video"
(result, logs) = extract_info_sync(config, url, debug=True)
assert isinstance(result, dict), "Result should be a dictionary"
assert isinstance(logs, list), "Logs should be a list"

View file

@ -1,7 +1,7 @@
import unittest
from typing import Any
from app.library.mini_filter import MiniFilter
from app.features.ytdlp.mini_filter import MiniFilter
class TestMiniFilter(unittest.TestCase):

View file

@ -1,7 +1,7 @@
from unittest.mock import MagicMock, Mock, patch
from app.library.Utils import REMOVE_KEYS
from app.library.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
from app.features.ytdlp.utils import _DATA
from app.features.ytdlp.ytdlp import YTDLP, _ArchiveProxy, ytdlp_options
class TestArchiveProxy:
@ -18,7 +18,7 @@ class TestArchiveProxy:
assert ("" in p2) is False
assert p2.add("") is False
@patch("app.library.Archiver.Archiver.get_instance")
@patch("app.features.ytdlp.archiver.Archiver.get_instance")
def test_contains_and_add_delegate_to_archiver(self, mock_get_instance) -> None:
arch = MagicMock()
mock_get_instance.return_value = arch
@ -61,7 +61,7 @@ class TestYtDlpOptions:
def test_ignored_flags_match_remove_keys(self) -> None:
# Collect the flags that should be ignored from REMOVE_KEYS
ignored_flags: set[str] = {
f.strip() for group in REMOVE_KEYS for v in group.values() for f in v.split(",") if f.strip()
f.strip() for group in _DATA.REMOVE_KEYS for v in group.values() for f in v.split(",") if f.strip()
}
opts = ytdlp_options()
@ -84,12 +84,12 @@ class TestYTDLP:
def _create_ytdlp(self, params=None):
"""Helper to create a YTDLP instance with mocked parent __init__."""
with patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
with patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__", return_value=None):
ytdlp = YTDLP(params=params)
ytdlp.params = params or {}
return ytdlp
@patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__")
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_patches_download_archive_param(self, mock_super_init) -> None:
"""Test that __init__ removes download_archive before calling super, then restores it."""
mock_super_init.return_value = None
@ -116,7 +116,7 @@ class TestYTDLP:
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert ytdlp.archive._file == "/tmp/archive.txt"
@patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__")
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_handles_no_download_archive(self, mock_super_init) -> None:
"""Test __init__ works correctly when download_archive is not in params."""
mock_super_init.return_value = None
@ -134,7 +134,7 @@ class TestYTDLP:
assert isinstance(ytdlp.archive, _ArchiveProxy), "Verify archive proxy is falsey"
assert not ytdlp.archive
@patch("app.library.ytdlp.yt_dlp.YoutubeDL.__init__")
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL.__init__")
def test_init_handles_none_params(self, mock_super_init) -> None:
"""Test __init__ handles None params gracefully."""
mock_super_init.return_value = None
@ -145,10 +145,10 @@ class TestYTDLP:
assert isinstance(ytdlp.archive, _ArchiveProxy)
assert not ytdlp.archive
@patch("app.library.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_downloaded_files_skips_when_interrupted(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files skips cleanup when _interrupted is True."""
with patch("app.library.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.to_screen = Mock()
@ -164,12 +164,12 @@ class TestYTDLP:
# Should return None
assert result is None
@patch("app.library.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
@patch("app.features.ytdlp.ytdlp.yt_dlp.YoutubeDL._delete_downloaded_files")
def test_delete_downloaded_files_calls_super_when_not_interrupted(self, mock_super_delete) -> None:
"""Test _delete_downloaded_files calls super when not interrupted."""
mock_super_delete.return_value = "cleanup_result"
with patch("app.library.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._interrupted = False

View file

@ -5,7 +5,7 @@ from unittest.mock import Mock, patch
import pytest
from app.features.presets.schemas import Preset
from app.library.YTDLPOpts import YTDLPOpts
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
class TestYTDLPOpts:
@ -13,7 +13,7 @@ class TestYTDLPOpts:
def test_constructor_initializes_correctly(self):
"""Test that YTDLPOpts constructor initializes all attributes."""
with patch("app.library.YTDLPOpts.Config") as mock_config:
with patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config:
mock_config_instance = Mock()
mock_config.get_instance.return_value = mock_config_instance
@ -27,7 +27,7 @@ class TestYTDLPOpts:
def test_get_instance_returns_reset_instance(self):
"""Test that get_instance returns a reset YTDLPOpts instance."""
with patch("app.library.YTDLPOpts.Config"):
with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts.get_instance()
assert isinstance(opts, YTDLPOpts)
@ -38,7 +38,10 @@ class TestYTDLPOpts:
def test_add_cli_with_valid_args(self):
"""Test adding valid CLI arguments."""
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter:
with (
patch("app.features.ytdlp.ytdlp_opts.Config"),
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
):
mock_converter.return_value = {"format": "best"}
opts = YTDLPOpts()
@ -50,7 +53,10 @@ class TestYTDLPOpts:
def test_add_cli_with_invalid_args_raises_error(self):
"""Test that invalid CLI arguments raise ValueError."""
with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter") as mock_converter:
with (
patch("app.features.ytdlp.ytdlp_opts.Config"),
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
):
mock_converter.side_effect = Exception("Invalid argument")
opts = YTDLPOpts()
@ -59,7 +65,7 @@ class TestYTDLPOpts:
def test_add_cli_with_empty_args_returns_self(self):
"""Test that empty or invalid args return self without processing."""
with patch("app.library.YTDLPOpts.Config"):
with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts()
# Test empty string
@ -79,7 +85,7 @@ class TestYTDLPOpts:
def test_add_with_valid_config(self):
"""Test adding configuration options."""
with patch("app.library.YTDLPOpts.Config"):
with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts()
config = {"format": "best", "quality": "720p"}
@ -91,10 +97,7 @@ class TestYTDLPOpts:
def test_add_with_user_config_filters_bad_options(self):
"""Test that user config filters out dangerous options."""
with (
patch("app.library.YTDLPOpts.Config"),
patch("app.library.YTDLPOpts.REMOVE_KEYS", [{"paths": "-P, --paths", "outtmpl": "-o, --output"}]),
):
with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts()
config = {
@ -115,9 +118,9 @@ class TestYTDLPOpts:
def test_preset_with_valid_preset(self):
"""Test applying a valid preset."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.presets.service.Presets") as mock_presets,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
):
# Mock config
mock_config_instance = Mock()
@ -142,7 +145,7 @@ class TestYTDLPOpts:
mock_converter.return_value = {"format": "best"}
with patch("app.library.YTDLPOpts.calc_download_path") as mock_calc_path:
with patch("app.features.ytdlp.ytdlp_opts.calc_download_path") as mock_calc_path:
mock_calc_path.return_value = "/test/downloads/custom_folder"
opts = YTDLPOpts()
@ -155,7 +158,10 @@ class TestYTDLPOpts:
def test_preset_with_nonexistent_preset(self):
"""Test applying a nonexistent preset returns self."""
with patch("app.library.YTDLPOpts.Config"), patch("app.features.presets.service.Presets") as mock_presets:
with (
patch("app.features.ytdlp.ytdlp_opts.Config"),
patch("app.features.presets.service.Presets") as mock_presets,
):
mock_presets_instance = Mock()
mock_presets_instance.get.return_value = None
mock_presets.get_instance.return_value = mock_presets_instance
@ -170,7 +176,7 @@ class TestYTDLPOpts:
def test_preset_with_cookies_creates_file(self):
"""Test that preset with cookies creates cookie file."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.presets.service.Presets") as mock_presets,
):
# Mock config
@ -208,9 +214,9 @@ class TestYTDLPOpts:
def test_preset_with_invalid_cli_raises_error(self):
"""Test that preset with invalid CLI raises ValueError."""
with (
patch("app.library.YTDLPOpts.Config"),
patch("app.features.ytdlp.ytdlp_opts.Config"),
patch("app.features.presets.service.Presets") as mock_presets,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
):
mock_preset = Mock(spec=Preset)
mock_preset.id = "bad_preset"
@ -233,7 +239,7 @@ class TestYTDLPOpts:
def test_get_all_with_default_options(self):
"""Test get_all returns correct default options."""
with patch("app.library.YTDLPOpts.Config") as mock_config:
with patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config:
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
mock_config_instance.temp_path = "/temp"
@ -242,7 +248,7 @@ class TestYTDLPOpts:
mock_config_instance.debug = False
mock_config.get_instance.return_value = mock_config_instance
with patch("app.library.YTDLPOpts.merge_dict") as mock_merge:
with patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge:
mock_merge.return_value = {
"paths": {"home": "/downloads", "temp": "/temp"},
"outtmpl": {"default": "default_template", "chapter": "chapter_template"},
@ -257,9 +263,9 @@ class TestYTDLPOpts:
def test_get_all_processes_cli_arguments(self):
"""Test get_all processes CLI arguments correctly."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -286,8 +292,8 @@ class TestYTDLPOpts:
def test_get_all_handles_format_special_cases(self):
"""Test get_all handles special format values correctly."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -322,8 +328,8 @@ class TestYTDLPOpts:
def test_get_all_with_invalid_cli_raises_error(self):
"""Test get_all raises error for invalid CLI arguments."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -345,8 +351,8 @@ class TestYTDLPOpts:
def test_get_all_resets_unless_keep_true(self):
"""Test get_all resets instance unless keep=True."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -379,7 +385,7 @@ class TestYTDLPOpts:
def test_reset_clears_all_options(self):
"""Test reset clears all internal state."""
with patch("app.library.YTDLPOpts.Config"):
with patch("app.features.ytdlp.ytdlp_opts.Config"):
opts = YTDLPOpts()
# Set some state
@ -399,8 +405,8 @@ class TestYTDLPOpts:
def test_method_chaining(self):
"""Test that methods support chaining."""
with (
patch("app.library.YTDLPOpts.Config"),
patch("app.library.YTDLPOpts.arg_converter"),
patch("app.features.ytdlp.ytdlp_opts.Config"),
patch("app.features.ytdlp.ytdlp_opts.arg_converter"),
patch("app.features.presets.service.Presets") as mock_presets,
):
mock_presets_instance = Mock()
@ -417,9 +423,9 @@ class TestYTDLPOpts:
def test_debug_logging(self):
"""Test debug logging when enabled."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
patch("app.library.YTDLPOpts.LOG") as mock_log,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
patch("app.features.ytdlp.ytdlp_opts.LOG") as mock_log,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -440,9 +446,9 @@ class TestYTDLPOpts:
def test_cookie_loading_error_handling(self):
"""Test error handling when cookie loading fails."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.presets.service.Presets") as mock_presets,
patch("app.library.YTDLPOpts.LOG") as mock_log,
patch("app.features.ytdlp.ytdlp_opts.LOG") as mock_log,
):
# Mock config
mock_config_instance = Mock()
@ -479,9 +485,9 @@ class TestYTDLPOpts:
def test_replacer_substitution_in_cli(self):
"""Test that CLI arguments get replacer substitution."""
with (
patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.arg_converter") as mock_converter,
patch("app.library.YTDLPOpts.merge_dict") as mock_merge,
patch("app.features.ytdlp.ytdlp_opts.Config") as mock_config,
patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter,
patch("app.features.ytdlp.ytdlp_opts.merge_dict") as mock_merge,
):
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -510,7 +516,7 @@ class TestARGSMerger:
def test_constructor_initializes_empty_args(self):
"""Test that ARGSMerger constructor initializes with empty args list."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
@ -518,7 +524,7 @@ class TestARGSMerger:
def test_add_valid_args(self):
"""Test adding valid command-line arguments."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
result = merger.add("--format best")
@ -528,7 +534,7 @@ class TestARGSMerger:
def test_add_filters_comment_lines(self):
"""Test that comment lines (starting with #) are filtered out."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
cli_with_comments = """--format best
@ -551,7 +557,7 @@ class TestARGSMerger:
def test_add_filters_indented_comment_lines(self):
"""Test that indented comment lines are filtered out."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
cli_with_indented_comments = """--format best
@ -574,7 +580,7 @@ class TestARGSMerger:
def test_add_filters_complex_commented_extractor_args(self):
"""Test filtering of complex real-world commented extractor-args."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
cli_with_complex_comments = """--extractor-args "youtube:player-client=default,tv,mweb,-web_safari;formats=incomplete"
@ -599,7 +605,7 @@ class TestARGSMerger:
def test_add_multiple_args_with_chaining(self):
"""Test adding multiple arguments using method chaining."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add("--format best").add("--output test.mp4").add("--no-playlist")
@ -608,7 +614,7 @@ class TestARGSMerger:
def test_add_args_with_quotes(self):
"""Test adding arguments containing quoted strings."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add('--output "%(title)s.%(ext)s"')
@ -617,7 +623,7 @@ class TestARGSMerger:
def test_add_complex_args(self):
"""Test adding complex arguments with multiple values."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add("--format 'bestvideo[height<=1080]+bestaudio/best'")
@ -627,7 +633,7 @@ class TestARGSMerger:
def test_add_empty_string_returns_self(self):
"""Test that adding empty string returns self without modifying args."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
result = merger.add("")
@ -637,7 +643,7 @@ class TestARGSMerger:
def test_add_short_string_returns_self(self):
"""Test that adding short string (len < 2) returns self without modifying args."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
result = merger.add("a")
@ -647,7 +653,7 @@ class TestARGSMerger:
def test_add_non_string_returns_self(self):
"""Test that adding non-string returns self without modifying args."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
result = merger.add(123) # type: ignore
@ -657,7 +663,7 @@ class TestARGSMerger:
def test_as_string(self):
"""Test converting args to string."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add("--format best").add("--output test.mp4")
@ -668,7 +674,7 @@ class TestARGSMerger:
def test_str_method(self):
"""Test __str__ method."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add("--format best").add("--output test.mp4")
@ -679,7 +685,7 @@ class TestARGSMerger:
def test_as_dict(self):
"""Test converting args to dict."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add("--format best").add("--output test.mp4")
@ -691,12 +697,12 @@ class TestARGSMerger:
def test_as_ytdlp(self):
"""Test converting args to yt-dlp JSON options."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add("--format best")
with patch("app.library.YTDLPOpts.arg_converter") as mock_converter:
with patch("app.features.ytdlp.ytdlp_opts.arg_converter") as mock_converter:
mock_converter.return_value = {"format": "best"}
result = merger.as_ytdlp()
@ -706,7 +712,7 @@ class TestARGSMerger:
def test_get_instance(self):
"""Test get_instance static method."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger.get_instance()
@ -715,7 +721,7 @@ class TestARGSMerger:
def test_reset(self):
"""Test reset method clears args."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add("--format best").add("--output test.mp4")
@ -729,7 +735,7 @@ class TestARGSMerger:
def test_args_with_special_characters(self):
"""Test handling arguments with special characters."""
from app.library.YTDLPOpts import ARGSMerger
from app.features.ytdlp.ytdlp_opts import ARGSMerger
merger = ARGSMerger()
merger.add('--postprocessor-args "-movflags +faststart"')
@ -742,11 +748,11 @@ class TestYTDLPCli:
"""Test the YTDLPCli class."""
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_constructor_with_valid_item(self, mock_config, mock_presets):
"""Test YTDLPCli constructor with valid Item."""
from app.library.ItemDTO import Item
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -765,17 +771,17 @@ class TestYTDLPCli:
def test_constructor_with_invalid_type_raises_error(self):
"""Test YTDLPCli constructor raises error with non-Item type."""
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
with pytest.raises(ValueError, match="Expected Item instance"):
YTDLPCli(item="not an item") # type: ignore
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_with_user_fields_only(self, mock_config, mock_presets):
"""Test build with only user-provided fields."""
from app.library.ItemDTO import Item
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -806,12 +812,12 @@ class TestYTDLPCli:
assert "--format best" in command
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_with_preset_fields_fallback(self, mock_config, mock_presets):
"""Test build falls back to preset fields when user doesn't provide them."""
from app.library.ItemDTO import Item
from app.features.presets.schemas import Preset
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -840,12 +846,12 @@ class TestYTDLPCli:
assert "--format 720p" in command
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_user_fields_override_preset(self, mock_config, mock_presets):
"""Test that user fields override preset fields."""
from app.library.ItemDTO import Item
from app.features.presets.schemas import Preset
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -879,11 +885,11 @@ class TestYTDLPCli:
assert "--format best" in command, "User CLI should appear after preset CLI in command"
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_with_default_fallback(self, mock_config, mock_presets):
"""Test build falls back to defaults when neither user nor preset provide fields."""
from app.library.ItemDTO import Item
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/default/downloads"
@ -904,12 +910,12 @@ class TestYTDLPCli:
assert info["merged"]["save_path"] == "/default/downloads"
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.create_cookies_file")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.create_cookies_file")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_with_cookies_from_user(self, mock_config, mock_create_cookies, mock_presets):
"""Test build with cookies from user."""
from app.library.ItemDTO import Item
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -934,12 +940,12 @@ class TestYTDLPCli:
assert info["merged"]["cookie_file"] == "/tmp/cookies.txt"
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_with_cookies_from_preset(self, mock_config, mock_presets):
"""Test build with cookies from preset when user doesn't provide."""
from app.library.ItemDTO import Item
from app.features.presets.schemas import Preset
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -970,11 +976,11 @@ class TestYTDLPCli:
assert "/preset/cookies.txt" in command
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_with_absolute_folder_path(self, mock_config, mock_presets):
"""Test build with absolute folder path from user."""
from app.library.ItemDTO import Item
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -997,11 +1003,11 @@ class TestYTDLPCli:
assert "--paths" in command
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_includes_url_in_command(self, mock_config, mock_presets):
"""Test that build includes the URL in the final command."""
from app.library.ItemDTO import Item
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"
@ -1019,12 +1025,12 @@ class TestYTDLPCli:
assert "https://youtube.com/watch?v=test123" in command
@patch("app.features.presets.service.Presets")
@patch("app.library.YTDLPOpts.Config")
@patch("app.features.ytdlp.ytdlp_opts.Config")
def test_build_cli_args_priority_order(self, mock_config, mock_presets):
"""Test that CLI args are added in correct priority order (preset first, user last)."""
from app.library.ItemDTO import Item
from app.features.presets.schemas import Preset
from app.library.YTDLPOpts import YTDLPCli
from app.features.ytdlp.ytdlp_opts import YTDLPCli
mock_config_instance = Mock()
mock_config_instance.download_path = "/downloads"

View file

@ -0,0 +1,584 @@
import logging
from pathlib import Path
import re
import tempfile
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from app.features.ytdlp.utils import (
LogWrapper,
extract_ytdlp_logs,
get_archive_id,
ytdlp_reject,
parse_outtmpl,
get_extras,
get_thumbnail,
get_ytdlp,
archive_delete,
archive_add,
archive_read,
)
class CaptureHandler(logging.Handler):
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
def make_logger(name: str = "lw_test") -> tuple[logging.Logger, CaptureHandler]:
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
handler = CaptureHandler()
# Avoid duplicate handlers when tests run multiple times
for h in list(logger.handlers):
logger.removeHandler(h)
logger.addHandler(handler)
return logger, handler
class TestLogWrapper:
def test_add_target_type_validation(self) -> None:
lw = LogWrapper()
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
lw.add_target(123) # type: ignore[arg-type]
def test_add_target_name_inference_and_custom(self) -> None:
lw = LogWrapper()
logger, _ = make_logger("one")
# Name inferred from logger
lw.add_target(logger)
assert lw.targets[-1].name == "one"
assert lw.has_targets() is True
# Name inferred from callable
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001
return None
lw.add_target(sink)
assert lw.targets[-1].name == "sink"
# Custom name overrides
lw.add_target(logger, name="custom")
assert lw.targets[-1].name == "custom"
def test_level_filtering_and_dispatch(self) -> None:
lw = LogWrapper()
logger, cap = make_logger("cap")
calls: list[tuple[int, str, tuple, dict]] = []
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
calls.append((level, msg, args, kwargs))
# Logger target at INFO, callable target at WARNING
lw.add_target(logger, level=logging.INFO)
lw.add_target(sink, level=logging.WARNING)
# DEBUG should hit none
lw.debug("d1")
assert len(cap.records) == 0
assert len(calls) == 0
# INFO hits logger only
lw.info("hello %s", "X")
assert len(cap.records) == 1
assert cap.records[0].levelno == logging.INFO
assert cap.records[0].getMessage() == "hello X"
assert len(calls) == 0
# WARNING hits both
lw.warning("warn %s", "Y", extra={"k": 1})
assert len(cap.records) == 2
assert cap.records[1].levelno == logging.WARNING
assert cap.records[1].getMessage() == "warn Y"
assert len(calls) == 1
lvl, msg, args, kwargs = calls[0]
assert lvl == logging.WARNING
assert msg == "warn %s"
assert args == ("Y",)
assert "extra" in kwargs
assert kwargs["extra"] == {"k": 1}
# ERROR still hits both; CRITICAL too
lw.error("err")
lw.critical("boom")
assert any(r.levelno == logging.ERROR for r in cap.records)
assert any(r.levelno == logging.CRITICAL for r in cap.records)
assert any(c[0] == logging.ERROR for c in calls)
assert any(c[0] == logging.CRITICAL for c in calls)
class TestExtractYtdlpLogs:
"""Test YTDLP log extraction function."""
def test_extract_ytdlp_logs_basic(self):
"""Test basic log extraction."""
logs = ["This live event will begin soon", "ERROR: Failed", "WARNING: Deprecated"]
result = extract_ytdlp_logs(logs)
assert isinstance(result, list)
assert len(result) >= 1 # Should match "This live event will begin"
def test_extract_ytdlp_logs_with_filters(self):
"""Test log extraction with filters."""
logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"]
filters = [re.compile(r"ERROR")]
result = extract_ytdlp_logs(logs, filters)
assert len(result) >= 0 # Should filter based on patterns
def test_extract_ytdlp_logs_empty(self):
"""Test with empty logs."""
result = extract_ytdlp_logs([])
assert result == []
class TestYtdlpReject:
"""Test the ytdlp_reject function."""
def test_ytdlp_reject_basic(self):
"""Test basic rejection logic."""
entry = {"title": "Test Video", "view_count": 1000}
yt_params = {}
passed, message = ytdlp_reject(entry, yt_params)
assert isinstance(passed, bool)
assert isinstance(message, str)
def test_ytdlp_reject_with_filters(self):
"""Test rejection with filters."""
entry = {"title": "Test Video", "upload_date": "20230101"}
yt_params = {"daterange": MagicMock()}
# Mock daterange to simulate rejection
yt_params["daterange"].__contains__ = MagicMock(return_value=False)
passed, message = ytdlp_reject(entry, yt_params)
assert isinstance(passed, bool)
assert isinstance(message, str)
class TestParseOuttmpl:
"""Test the parse_outtmpl function."""
def setup_method(self):
"""Reset YTDLP singleton state before each test."""
from app.features.ytdlp.utils import _DATA
_DATA.YTDLP_INFO_CLS = None
def test_parse_outtmpl_basic(self):
"""Test basic template parsing with simple placeholders."""
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video.mp4"
def test_parse_outtmpl_with_id(self):
"""Test template parsing with video ID."""
template = "[%(id)s] %(title)s.%(ext)s"
info_dict = {
"id": "dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "[dQw4w9WgXcQ] Never Gonna Give You Up.webm"
def test_parse_outtmpl_with_uploader(self):
"""Test template parsing with uploader information."""
template = "%(uploader)s - %(title)s.%(ext)s"
info_dict = {
"uploader": "Rick Astley",
"title": "Never Gonna Give You Up",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Rick Astley - Never Gonna Give You Up.mp4"
def test_parse_outtmpl_with_nested_path(self):
"""Test template parsing with nested directory structure."""
template = "%(uploader)s/%(title)s.%(ext)s"
info_dict = {
"uploader": "Test Channel",
"title": "Test Video",
"ext": "mkv",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Test Video.mkv"
def test_parse_outtmpl_with_missing_field(self):
"""Test template parsing with missing field defaults to NA."""
template = "%(title)s - %(upload_date)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video - NA.mp4", "Missing field upload_date should default to NA"
def test_parse_outtmpl_complex(self):
"""Test complex template with multiple fields."""
template = "%(uploader)s/%(playlist_title)s/%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"
info_dict = {
"uploader": "Test Channel",
"playlist_title": "Best Videos",
"playlist_index": 5,
"title": "Amazing Content",
"id": "abc123xyz",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Best Videos/005 - Amazing Content [abc123xyz].mp4"
def test_parse_outtmpl_with_special_characters(self):
"""Test template parsing handles special characters in values."""
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test: Video / With \\ Special | Characters",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert ".mp4" in result, "yt-dlp should sanitize special characters but preserve extension"
assert "Test" in result, "yt-dlp should preserve safe parts of title"
def test_parse_outtmpl_with_playlist_info(self):
"""Test template parsing with playlist information."""
template = "%(playlist)s/%(title)s.%(ext)s"
info_dict = {
"playlist": "My Playlist",
"title": "Video Title",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "My Playlist/Video Title.webm"
def test_parse_outtmpl_with_restrict_filename(self):
"""Test template parsing with restrict_filename parameter."""
template = "%(uploader)s/%(title)s.%(ext)s"
info_dict = {
"uploader": "Foobar's Workshop",
"title": "Test Video",
"ext": "mp4",
}
result_unrestricted: str = parse_outtmpl(template, info_dict)
assert result_unrestricted == "Foobar's Workshop/Test Video.mp4"
result_restricted: str = parse_outtmpl(template, info_dict, params={"restrictfilenames": True})
assert result_restricted == "Foobar_s_Workshop/Test_Video.mp4"
class TestGetThumbnail:
def test_returns_none_for_empty_list(self):
"""Test that None is returned for an empty thumbnail list."""
assert get_thumbnail([]) is None
def test_returns_none_for_non_list(self):
"""Test that None is returned for non-list input."""
assert get_thumbnail(None) is None
assert get_thumbnail("not a list") is None
assert get_thumbnail({"not": "list"}) is None
def test_returns_highest_preference_thumbnail(self):
"""Test that the thumbnail with highest preference is returned."""
thumbnails = [
{"url": "low.jpg", "preference": 1, "width": 100, "height": 100},
{"url": "high.jpg", "preference": 10, "width": 200, "height": 200},
{"url": "medium.jpg", "preference": 5, "width": 150, "height": 150},
]
result = get_thumbnail(thumbnails)
assert result == {"url": "high.jpg", "preference": 10, "width": 200, "height": 200}
def test_returns_highest_width_when_preference_equal(self):
"""Test that the thumbnail with highest width is returned when preference is equal."""
thumbnails = [
{"url": "small.jpg", "preference": 1, "width": 100, "height": 100},
{"url": "large.jpg", "preference": 1, "width": 200, "height": 200},
{"url": "medium.jpg", "preference": 1, "width": 150, "height": 150},
]
result = get_thumbnail(thumbnails)
assert result == {"url": "large.jpg", "preference": 1, "width": 200, "height": 200}
def test_handles_missing_attributes(self):
"""Test that thumbnails with missing attributes are handled correctly."""
thumbnails = [
{"url": "no_pref.jpg", "width": 100},
{"url": "with_pref.jpg", "preference": 5, "width": 50},
]
result = get_thumbnail(thumbnails)
assert result["url"] == "with_pref.jpg"
def test_returns_first_when_all_equal(self):
"""Test that any thumbnail is returned when all attributes are equal."""
thumbnails = [
{"url": "first.jpg"},
{"url": "second.jpg"},
]
result = get_thumbnail(thumbnails)
assert result is not None
assert result["url"] in ["first.jpg", "second.jpg"]
class TestGetExtras:
def test_returns_empty_dict_for_none(self):
"""Test that empty dict is returned for None input."""
assert get_extras(None) == {}
def test_returns_empty_dict_for_non_dict(self):
"""Test that empty dict is returned for non-dict input."""
assert get_extras("not a dict") == {}
assert get_extras([]) == {}
def test_extracts_video_information(self):
"""Test extracting information from a video entry."""
entry = {
"id": "test123",
"title": "Test Video",
"uploader": "Test Uploader",
"channel": "Test Channel",
"thumbnails": [{"url": "thumb.jpg", "preference": 1}],
"duration": 120,
}
result = get_extras(entry, kind="video")
assert result["uploader"] == "Test Uploader"
assert result["channel"] == "Test Channel"
assert result["thumbnail"] == "thumb.jpg"
assert result["duration"] == 120
assert result["is_premiere"] is False
def test_extracts_playlist_information(self):
"""Test extracting information from a playlist entry."""
entry = {
"id": "playlist123",
"title": "Test Playlist",
"uploader": "Playlist Owner",
"uploader_id": "owner123",
}
result = get_extras(entry, kind="playlist")
assert result["playlist_id"] == "playlist123"
assert result["playlist_title"] == "Test Playlist"
assert result["playlist_uploader"] == "Playlist Owner"
assert result["playlist_uploader_id"] == "owner123"
def test_handles_release_timestamp(self):
"""Test handling of release_timestamp for upcoming content."""
entry = {
"release_timestamp": 1234567890,
}
result = get_extras(entry)
assert "release_in" in result
assert result["release_in"] == "Fri, 13 Feb 2009 23:31:30 GMT"
def test_handles_upcoming_live_stream(self):
"""Test handling of upcoming live stream."""
entry = {
"release_timestamp": 1234567890,
"live_status": "is_upcoming",
}
result = get_extras(entry)
assert result["is_live"] == 1234567890
assert "release_in" in result
def test_handles_premiere_flag(self):
"""Test handling of is_premiere flag."""
entry = {
"is_premiere": True,
}
result = get_extras(entry)
assert result["is_premiere"] is True
entry2 = {"is_premiere": False}
result2 = get_extras(entry2)
assert result2["is_premiere"] is False
def test_youtube_fallback_thumbnail(self):
"""Test fallback thumbnail generation for YouTube videos."""
entry = {
"id": "dQw4w9WgXcQ",
"ie_key": "Youtube",
}
result = get_extras(entry)
assert result["thumbnail"] == "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
def test_thumbnail_string_fallback(self):
"""Test fallback to thumbnail string when thumbnails list not available."""
entry = {
"thumbnail": "https://example.com/thumb.jpg",
}
result = get_extras(entry)
assert result["thumbnail"] == "https://example.com/thumb.jpg"
class TestGetStaticYtdlp:
"""Test the get_static_ytdlp function."""
def setup_method(self):
"""Reset YTDLP singleton state before each test."""
from app.features.ytdlp.utils import _DATA
_DATA.YTDLP_INFO_CLS = None
def test_get_static_ytdlp_returns_instance(self):
"""Test that get_static_ytdlp returns a YTDLP instance."""
from app.features.ytdlp.ytdlp import YTDLP
# Get the cached instance
instance = get_ytdlp()
assert instance is not None
assert isinstance(instance, YTDLP)
def test_get_static_ytdlp_returns_same_instance(self):
"""Test that get_static_ytdlp returns the same cached instance."""
instance1 = get_ytdlp()
instance2 = get_ytdlp()
assert instance1 is instance2
def test_get_static_ytdlp_with_params(self):
"""Test that get_static_ytdlp returns a new instance when params are provided."""
instance1 = get_ytdlp()
instance2 = get_ytdlp(params={"quiet": False})
assert instance1 is not instance2
assert instance2 is not None
def test_get_static_ytdlp_has_correct_params(self):
"""Test that get_static_ytdlp initializes with correct parameters."""
instance = get_ytdlp()
# Access the internal params
params = instance.params
assert params.get("color") == "no_color"
assert params.get("extract_flat") is True
assert params.get("skip_download") is True
assert params.get("ignoreerrors") is True
assert params.get("ignore_no_formats_error") is True
assert params.get("quiet") is True
class TestGetArchiveId:
"""Test the get_archive_id function."""
@patch("app.features.ytdlp.utils._DATA.YTDLP_INFO_CLS")
def test_get_archive_id_basic(self, mock_ytdlp):
"""Test basic archive ID extraction."""
mock_ytdlp._ies = {}
result = get_archive_id("https://youtube.com/watch?v=test123")
assert isinstance(result, dict)
assert "id" in result
assert "ie_key" in result
assert "archive_id" in result
def test_get_archive_id_invalid_url(self):
"""Test with invalid URL."""
result = get_archive_id("invalid-url")
assert isinstance(result, dict)
class TestArchiveFunctions:
"""Test archive-related functions."""
def setup_method(self):
"""Set up test archive file."""
self.temp_dir = tempfile.mkdtemp()
self.archive_file = Path(self.temp_dir) / "archive.txt"
def teardown_method(self):
"""Clean up after tests."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_archive_add_and_read(self):
"""Test adding and reading archive entries."""
ids = ["id1", "id2", "id3"]
# Add entries - just test it returns a boolean
result = archive_add(self.archive_file, ids)
assert isinstance(result, bool)
# Read entries - just test it returns a list
read_ids = archive_read(self.archive_file)
assert isinstance(read_ids, list)
def test_archive_delete(self):
"""Test deleting archive entries."""
# Delete some entries - just test it returns a boolean
delete_ids = ["id2"]
result = archive_delete(self.archive_file, delete_ids)
assert isinstance(result, bool)
def test_archive_read_nonexistent(self):
"""Test reading from non-existent archive."""
nonexistent = Path(self.temp_dir) / "nonexistent.txt"
result = archive_read(nonexistent)
assert result == []

561
app/features/ytdlp/utils.py Normal file
View file

@ -0,0 +1,561 @@
import json
import logging
import os
import re
import shlex
from collections.abc import Callable
from dataclasses import dataclass
from email.utils import formatdate
from pathlib import Path
from typing import Any
from app.features.ytdlp.ytdlp import YTDLP
from app.library.Utils import merge_dict, timed_lru_cache
LOG: logging.Logger = logging.getLogger("ytdlp.utils")
class _DATA:
YTDLP_INFO_CLS: Any = None
YTDLP_PARAMS: dict[str, Any] = {
"simulate": True,
"color": "no_color",
"extract_flat": True,
"skip_download": True,
"ignoreerrors": True,
"ignore_no_formats_error": True,
"quiet": True,
}
REMOVE_KEYS: list = [
{
"paths": "-P, --paths",
"outtmpl": "-o, --output",
"progress_hooks": "--progress_hooks",
"postprocessor_hooks": "--postprocessor_hooks",
"post_hooks": "--post_hooks",
},
{
"quiet": "-q, --quiet",
"no_warnings": "--no-warnings",
"skip_download": "--skip-download",
"forceprint": "-O, --print",
"simulate": "--simulate",
"noprogress": "--no-progress",
"wait_for_video": "--wait-for-video",
"progress_delta": " --progress-delta",
"progress_template": "--progress-template",
"consoletitle": "--console-title",
"progress_with_newline": "--newline",
"forcejson": "-j, --dump-single-json",
"opt_update_to": "--update-to",
"opt_ap_list_mso": "--ap-list-mso",
"opt_batch_file": "-a, --batch-file",
"opt_alias": "--alias",
"opt_list_extractors": "--list-extractors",
"opt_version": "--version",
"opt_help": "-h, --help",
"opt_update": "-U, --update",
"opt_list_subtitles": "--list-subs",
"opt_list_thumbnails": "--list-thumbnails",
"opt_list_format": "-F, --list-formats",
"opt_dump_agent": "--dump-user-agent",
"opt_extractor_descriptions": "--extractor-descriptions",
"opt_list_impersonate_targets": "--list-impersonate-targets",
},
]
"Keys to remove from yt-dlp options at various levels."
@dataclass(kw_only=True)
class LogTarget:
"""
A data class that represents a logging target with its level and type.
Attributes:
name (str): The name of the logging target.
target: The logging target, which can be a logging.Logger instance or a callable.
level (int): The logging level for the target.
logger (bool): True if the target is a logging.Logger instance, False otherwise.
type: The type of the target.
"""
name: str | None = None
target: logging.Logger | Callable
level: int
logger: bool
class LogWrapper:
def __init__(self):
self.targets: list[LogTarget] = []
def add_target(self, target: logging.Logger | Callable, level: int = logging.DEBUG, name: str | None = None):
"""
Adds a new logging target with the specified logging level.
Args:
target (logging.Logger|Callable): The logging target, which can be a logging.Logger instance or a Callable.
level (int): The logging level for the target. Defaults to logging.DEBUG.
name (str|None): The name of the logging target. Defaults to None.
"""
if not isinstance(target, logging.Logger | Callable):
msg = "Target must be a logging.Logger instance or a callable."
raise TypeError(msg)
if name is None:
name = target.name if isinstance(target, logging.Logger) else target.__name__
self.targets.append(
LogTarget(
name=name,
target=target,
level=level,
logger=isinstance(target, logging.Logger),
)
)
def has_targets(self):
return len(self.targets) > 0
def _log(self, level, msg, *args, **kwargs):
for target in self.targets:
if level < target.level:
continue
if target.logger:
target.target.log(level, msg, *args, **kwargs)
else:
target.target(level, msg, *args, **kwargs)
def debug(self, msg, *args, **kwargs):
self._log(logging.DEBUG, msg, *args, **kwargs)
def info(self, msg, *args, **kwargs):
self._log(logging.INFO, msg, *args, **kwargs)
def warning(self, msg, *args, **kwargs):
self._log(logging.WARNING, msg, *args, **kwargs)
def error(self, msg, *args, **kwargs):
self._log(logging.ERROR, msg, *args, **kwargs)
def critical(self, msg, *args, **kwargs):
self._log(logging.CRITICAL, msg, *args, **kwargs)
def patch_metadataparser() -> None:
"""
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
"""
try:
from yt_dlp.postprocessor.metadataparser import MetadataParserPP
from yt_dlp.utils import Namespace
except Exception as exc:
LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
return
if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
return
class _ActionNS(Namespace):
_ACTIONS_STR: list[str] = []
@staticmethod
def _get_name(func) -> str | None:
if not callable(func):
return None
target = getattr(func, "__func__", func)
module_name = getattr(target, "__module__", None)
qual_name = getattr(target, "__qualname__", getattr(target, "__name__", None))
return f"{module_name}.{qual_name}" if module_name and qual_name else None
def __contains__(self, candidate: object) -> bool:
if candidate in self.__dict__.values():
return True
if func_name := _ActionNS._get_name(candidate):
if len(_ActionNS._ACTIONS_STR) < 1:
_ActionNS._ACTIONS_STR.extend([_ActionNS._get_name(value) for value in self.__dict__.values()])
return func_name in _ActionNS._ACTIONS_STR
return False
actions_dict: dict[str, Any] = dict(MetadataParserPP.Actions.items_)
MetadataParserPP.Actions = _ActionNS(**actions_dict)
MetadataParserPP.Actions._ytptube_patched = True
LOG.debug("MetadataParserPP action namespace patch applied successfully.")
def arg_converter(
args: str,
level: int | bool | None = None,
dumps: bool = False,
removed_options: list | None = None,
keep_defaults: bool = False,
) -> dict:
"""
Convert yt-dlp options to a dictionary.
Args:
args (str): yt-dlp options string.
level (int|bool|None): Level of options to remove, True for all.
dumps (bool): Dump options as JSON.
removed_options (list|None): List of removed options.
keep_defaults (bool): Keep default options.
Returns:
dict: yt-dlp options dictionary.
"""
import yt_dlp.options
create_parser = yt_dlp.options.create_parser
def _default_opts(args: str):
patched_parser = create_parser()
try:
yt_dlp.options.create_parser = lambda: patched_parser
return yt_dlp.parse_options(args)
finally:
yt_dlp.options.create_parser = create_parser
try:
patch_metadataparser()
except Exception as exc:
LOG.debug("Metadata parser patch failed to apply: %s", exc)
default_opts = _default_opts([]).ydl_opts
if args:
# important to ignore external config files.
args = "--ignore-config " + args
opts = yt_dlp.parse_options(shlex.split(args, posix=os.name != "nt")).ydl_opts
diff = {k: v for k, v in opts.items() if default_opts[k] != v} if not keep_defaults else opts.items()
if "postprocessors" in diff:
diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]]
if "_warnings" in diff:
diff.pop("_warnings", None)
if level is True or isinstance(level, int):
bad_options = {}
if isinstance(level, bool) or not isinstance(level, int):
level = len(_DATA.REMOVE_KEYS)
for i, item in enumerate(_DATA.REMOVE_KEYS):
if i > level:
break
bad_options.update(item.items())
for key in diff.copy():
if key not in bad_options:
continue
if isinstance(removed_options, list):
removed_options.append(bad_options[key])
diff.pop(key, None)
if dumps is True:
from app.library.encoder import Encoder
if "match_filter" in diff:
import inspect
matchFilter = inspect.getclosurevars(diff["match_filter"].func).nonlocals["filters"]
if isinstance(matchFilter, set):
diff["match_filter"] = {"filters": list(matchFilter)}
return json.loads(json.dumps(diff, cls=Encoder, default=str))
return diff
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.
Args:
logs (list): log strings.
filters (list[str|Re.Pattern]): Optional extra filters of strings and/or regex.
Returns:
(list): List of matching log lines.
"""
all_patterns: list[str | re.Pattern] = [
"This live event will begin",
"Video unavailable. This video is private",
"This video is available to this channel",
"Private video. Sign in if you've been granted access to this video",
"[youtube] Premieres in",
"Falling back on generic information extractor",
"URL could be a direct video link, returning it as such",
] + (filters or [])
compiled: list[re.Pattern] = [
p if isinstance(p, re.Pattern) else re.compile(re.escape(p), re.IGNORECASE) for p in all_patterns
]
matched: list[str] = []
matched.extend(line for line in logs if line and any(p.search(line) for p in compiled))
return list(dict.fromkeys(matched))
def ytdlp_reject(entry: dict, yt_params: dict) -> tuple[bool, str]:
"""
Implement yt-dlp reject filter logic.
Args:
entry (dict): The entry to check.
yt_params (dict): The yt-dlp parameters containing filters.
Returns:
tuple[bool, str]: A tuple where the first element is True if the entry passes the filters, or False if it does not,
and the second element is a message explaining the reason for rejection or an empty string if it passes.
"""
if title := entry.get("title"):
if (matchtitle := yt_params.get("matchtitle")) and not re.search(matchtitle, title, re.IGNORECASE):
return (False, f'"{title}" title did not match pattern "{matchtitle}". Skipping download.')
if (rejecttitle := yt_params.get("rejecttitle")) and re.search(rejecttitle, title, re.IGNORECASE):
return (False, f'"{title}" title matched reject pattern "{rejecttitle}". Skipping download.')
date = entry.get("upload_date")
date_range = yt_params.get("daterange")
if (date and date_range) and date not in date_range:
return (False, f"Upload date '{date}' is not in range '{date_range}'.")
view_count = entry.get("view_count")
if view_count is not None:
min_views = yt_params.get("min_views")
if min_views is not None and view_count < min_views:
return (
False,
f"Skipping {entry.get('title', 'video')}, because it has not reached minimum view count ({view_count}/{min_views}).",
)
max_views = yt_params.get("max_views")
if max_views is not None and view_count > max_views:
return (
False,
f"Skipping {entry.get('title', 'video')}, because it has exceeded maximum view count ({view_count}/{max_views}).",
)
try:
from yt_dlp.utils import age_restricted
if entry.get("age_limit") and age_restricted(entry.get("age_limit"), yt_params.get("age_limit")):
return (False, f'Video "{entry.get("title", "unknown")}" is age restricted.')
except ImportError:
pass
return (True, "")
def get_ytdlp(params: dict | None = None) -> YTDLP:
if params:
return YTDLP(params=merge_dict(params, _DATA.YTDLP_PARAMS))
if _DATA.YTDLP_INFO_CLS is None:
_DATA.YTDLP_INFO_CLS = YTDLP(params=_DATA.YTDLP_PARAMS)
return _DATA.YTDLP_INFO_CLS
def get_thumbnail(thumbnails: list) -> str | None:
"""
Extract thumbnail URL from a yt-dlp entry.
Args:
thumbnails (list): The list of thumbnail dictionaries from yt-dlp entry.
Returns:
str | None: The thumbnail URL if available, otherwise None.
"""
if not thumbnails or not isinstance(thumbnails, list):
return None
def _thumb_sort_key(thumb: dict) -> tuple:
return (
thumb.get("preference") if thumb.get("preference") is not None else -1,
thumb.get("width") if thumb.get("width") is not None else -1,
thumb.get("height") if thumb.get("height") is not None else -1,
thumb.get("id") if thumb.get("id") is not None else "",
thumb.get("url"),
)
return max(thumbnails, key=_thumb_sort_key, default=None)
def get_extras(entry: dict, kind: str = "video") -> dict:
"""
Extract useful information from a yt-dlp entry.
Args:
entry (dict): The entry data from yt-dlp.
kind (str): The type of the item (e.g., "video", "playlist").
Returns:
dict: The extracted data.
"""
extras = {}
if not entry or not isinstance(entry, dict):
return extras
if "playlist" == kind:
for property in ("id", "title", "uploader", "uploader_id"):
if val := entry.get(property):
extras[f"playlist_{property}"] = val
if thumbnail := get_thumbnail(entry.get("thumbnails", [])):
extras["thumbnail"] = thumbnail.get("url")
elif thumbnail := entry.get("thumbnail"):
extras["thumbnail"] = thumbnail
for property in ("uploader", "channel"):
if val := entry.get(property):
extras[property] = val
if release_in := entry.get("release_timestamp"):
extras["release_in"] = formatdate(release_in, usegmt=True)
if release_in and "is_upcoming" == entry.get("live_status"):
extras["is_live"] = release_in
if duration := entry.get("duration"):
extras["duration"] = duration
extras["is_premiere"] = bool(entry.get("is_premiere", False))
extractor_key = entry.get("ie_key") or entry.get("extractor_key") or entry.get("extractor") or ""
if "thumbnail" not in extras and "youtube" in str(extractor_key).lower():
extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**entry)
return extras
def parse_outtmpl(output_template: str, info_dict: dict, params: dict | None = None) -> str:
"""
Parse yt-dlp output template with given info_dict.
Args:
output_template (str): The output template string.
info_dict (dict): The info dictionary from yt-dlp.
params (dict|None): Additional parameters for yt-dlp.
Returns:
str: The parsed output string.
"""
return get_ytdlp(params=params).prepare_filename(info_dict=info_dict, outtmpl=output_template)
@timed_lru_cache(ttl_seconds=300, max_size=256)
def get_archive_id(url: str) -> dict[str, str | None]:
"""
Get the archive ID for a given URL.
Args:
url (str): URL to check.
Returns:
dict: {
"id": str | None,
"ie_key": str | None,
"archive_id": str | None
}
"""
idDict: dict[str, None] = {
"id": None,
"ie_key": None,
"archive_id": None,
}
from yt_dlp.utils import make_archive_id
for key, _ie in get_ytdlp()._ies.items():
try:
if not _ie.suitable(url):
continue
if not _ie.working():
continue
temp_id = _ie.get_temp_id(url)
if not temp_id:
continue
idDict["id"] = temp_id
idDict["ie_key"] = key
idDict["archive_id"] = make_archive_id(_ie, temp_id)
break
except Exception as e:
LOG.exception(e)
LOG.error(f"Error getting archive ID: {e}")
return idDict
def archive_add(file: str | Path, ids: list[str], skip_check: bool = False) -> bool:
"""
Add IDs to an archive file (delegates to the global Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to add.
skip_check (bool): If True, skip checking for existing IDs.
Returns:
bool: True if any new IDs were appended, False otherwise.
"""
from app.features.ytdlp.archiver import Archiver
return Archiver.get_instance().add(file, ids, skip_check)
def archive_read(file: str | Path, ids: list[str] | None = None) -> list[str]:
"""
Read IDs from an archive file with optional filtering (delegates to Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]|None): Optional list of IDs to query; None/empty returns all.
Returns:
list[str]: IDs present in the archive, optionally filtered.
"""
from app.features.ytdlp.archiver import Archiver
return Archiver.get_instance().read(file, ids)
def archive_delete(file: str | Path, ids: list[str]) -> bool:
"""
Delete IDs from an archive file (delegates to Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to remove.
Returns:
bool: True on success (including no-op), False on error.
"""
from app.features.ytdlp.archiver import Archiver
return Archiver.get_instance().delete(file, ids)

View file

@ -24,7 +24,7 @@ class _ArchiveProxy:
return False
try:
from app.library.Archiver import Archiver
from app.features.ytdlp.archiver import Archiver
status: bool = item in Archiver.get_instance().read(self._file, [item])
return status
@ -36,7 +36,7 @@ class _ArchiveProxy:
return False
try:
from app.library.Archiver import Archiver
from app.features.ytdlp.archiver import Archiver
status: bool = Archiver.get_instance().add(self._file, [item])
return status
@ -112,12 +112,12 @@ def ytdlp_options() -> list[dict[str, Any]]:
"""
from yt_dlp.options import create_parser
from app.library.Utils import REMOVE_KEYS
from app.features.ytdlp.utils import _DATA
parser = create_parser()
ignored_flags: set[str] = {
f.strip() for group in REMOVE_KEYS for v in group.values() for f in v.split(",") if f.strip()
f.strip() for group in _DATA.REMOVE_KEYS for v in group.values() for f in v.split(",") if f.strip()
}
def collect(opts, group: str) -> list[dict[str, Any]]:

View file

@ -4,11 +4,11 @@ from pathlib import Path
from typing import Any
from app.features.presets.schemas import Preset
from app.features.ytdlp.utils import arg_converter
from app.library.config import Config
from app.library.Utils import calc_download_path, create_cookies_file, merge_dict
from .config import Config
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, create_cookies_file, merge_dict
LOG: logging.Logger = logging.getLogger("YTDLPOpts")
LOG: logging.Logger = logging.getLogger("ytdlp.ytdlp_opts")
class ARGSMerger:
@ -123,7 +123,7 @@ class YTDLPCli:
config (Config|None): The Config instance (optional)
"""
from .ItemDTO import Item
from app.library.ItemDTO import Item
if not isinstance(item, Item):
msg = f"Expected Item instance, got {type(item).__name__}"
@ -293,7 +293,9 @@ class YTDLPOpts:
"""
bad_options: dict = {}
if from_user:
bad_options: dict[str, str] = {k: v for d in REMOVE_KEYS for k, v in d.items()}
from app.features.ytdlp.utils import _DATA
bad_options: dict[str, str] = {k: v for d in _DATA.REMOVE_KEYS for k, v in d.items()}
for key, value in config.items():
if from_user and key in bad_options:

View file

@ -25,9 +25,7 @@ class Events:
CONNECTED: str = "connected"
CONFIGURATION: str = "configuration"
CONFIG_UPDATE: str = "config_update"
ACTIVE_QUEUE: str = "active_queue"
LOG_INFO: str = "log_info"
LOG_WARNING: str = "log_warning"
@ -79,10 +77,8 @@ class Events:
"""
return [
Events.CONFIGURATION,
Events.CONFIG_UPDATE,
Events.CONNECTED,
Events.ACTIVE_QUEUE,
Events.LOG_INFO,
Events.LOG_WARNING,
Events.LOG_ERROR,

View file

@ -7,17 +7,10 @@ from email.utils import formatdate
from pathlib import Path
from typing import TYPE_CHECKING, Any
from app.features.ytdlp.utils import archive_add, archive_delete, archive_read, get_archive_id
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
from app.library.encoder import Encoder
from app.library.Utils import (
archive_add,
archive_delete,
archive_read,
clean_item,
get_archive_id,
get_file,
get_file_sidecar,
)
from app.library.YTDLPOpts import YTDLPOpts
from app.library.Utils import clean_item, get_file, get_file_sidecar
if TYPE_CHECKING:
from app.features.presets.schemas import Preset
@ -213,9 +206,9 @@ class Item:
cli: str | None = item.get("cli")
if cli and len(cli) > 2:
from .Utils import arg_converter
try:
from app.features.ytdlp.utils import arg_converter
arg_converter(args=cli, level=True)
data["cli"] = cli
except Exception as e:

View file

@ -1,180 +0,0 @@
import logging
from collections.abc import Callable
from dataclasses import dataclass
LOG = logging.getLogger(__name__)
@dataclass(kw_only=True)
class LogTarget:
"""
A data class that represents a logging target with its level and type.
Attributes:
name (str): The name of the logging target.
target: The logging target, which can be a logging.Logger instance or a callable.
level (int): The logging level for the target.
logger (bool): True if the target is a logging.Logger instance, False otherwise.
type: The type of the target.
"""
name: str | None = None
target: logging.Logger | Callable
level: int
logger: bool
class LogWrapper:
"""
A wrapper class for logging that allows adding multiple logging targets with different logging levels.
Attributes:
targets (list[dict]): A list of dictionaries where each dictionary represents a logging target with its level and type.
Methods:
add_target(target, level=logging.DEBUG):
Adds a new logging target with the specified logging level.
has_targets():
Checks if there are any logging targets added.
_log(level, msg, *args, **kwargs):
Logs a message to all targets that have a logging level less than or equal to the specified level.
debug(msg, *args, **kwargs):
Logs a debug message to all targets.
info(msg, *args, **kwargs):
Logs an info message to all targets.
warning(msg, *args, **kwargs):
Logs a warning message to all targets.
error(msg, *args, **kwargs):
Logs an error message to all targets.
critical(msg, *args, **kwargs):
Logs a critical message to all targets.
"""
def __init__(self):
self.targets: list[LogTarget] = []
"""A list of dictionaries where each dictionary represents a logging target with its level and type."""
def add_target(self, target: logging.Logger | Callable, level: int = logging.DEBUG, name: str | None = None):
"""
Adds a new logging target with the specified logging level.
Args:
target (logging.Logger|Callable): The logging target, which can be a logging.Logger instance or a Callable.
level (int): The logging level for the target. Defaults to logging.DEBUG.
name (str|None): The name of the logging target. Defaults to None.
"""
if not isinstance(target, logging.Logger | Callable):
msg = "Target must be a logging.Logger instance or a callable."
raise TypeError(msg)
if name is None:
name = target.name if isinstance(target, logging.Logger) else target.__name__
self.targets.append(
LogTarget(
name=name,
target=target,
level=level,
logger=isinstance(target, logging.Logger),
)
)
def has_targets(self):
"""
Checks if there are any logging targets added.
Returns:
bool: True if there are targets, False otherwise.
"""
return len(self.targets) > 0
def _log(self, level, msg, *args, **kwargs):
"""
Logs a message to all targets that have a logging level less than or equal to the specified level.
Args:
level (int): The logging level of the message.
msg (str): The message to log.
*args: Additional positional arguments to pass to the logging method.
**kwargs: Additional keyword arguments to pass to the logging method.
"""
for target in self.targets:
if level < target.level:
continue
if target.logger:
target.target.log(level, msg, *args, **kwargs)
else:
target.target(level, msg, *args, **kwargs)
def debug(self, msg, *args, **kwargs):
"""
Logs a debug message to all targets.
Args:
msg (str): The message to log.
*args: Additional positional arguments to pass to the logging method.
**kwargs: Additional keyword arguments to pass to the logging method.
"""
self._log(logging.DEBUG, msg, *args, **kwargs)
def info(self, msg, *args, **kwargs):
"""
Logs an info message to all targets.
Args:
msg (str): The message to log.
*args: Additional positional arguments to pass to the logging method.
**kwargs: Additional keyword arguments to pass to the logging method.
"""
self._log(logging.INFO, msg, *args, **kwargs)
def warning(self, msg, *args, **kwargs):
"""
Logs a warning message to all targets.
Args:
msg (str): The message to log.
*args: Additional positional arguments to pass to the logging method.
**kwargs: Additional keyword arguments to pass to the logging method.
"""
self._log(logging.WARNING, msg, *args, **kwargs)
def error(self, msg, *args, **kwargs):
"""
Logs an error message to all targets.
Args:
msg (str): The message to log.
*args: Additional positional arguments to pass to the logging method.
**kwargs: Additional keyword arguments to pass to the logging method.
"""
self._log(logging.ERROR, msg, *args, **kwargs)
def critical(self, msg, *args, **kwargs):
"""
Logs a critical message to all targets.
Args:
msg (str): The message to log.
*args: Additional positional arguments to pass to the logging method.
**kwargs: Additional keyword arguments to pass to the logging method.
"""
self._log(logging.CRITICAL, msg, *args, **kwargs)

View file

@ -2,70 +2,22 @@ import base64
import copy
import glob
import ipaddress
import json
import logging
import os
import re
import shlex
import socket
import time
import uuid
from datetime import UTC, datetime, timedelta
from email.utils import formatdate
from functools import lru_cache, wraps
from http.cookiejar import MozillaCookieJar
from pathlib import Path
from typing import Any
from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted
from .ytdlp import YTDLP, make_archive_id
LOG: logging.Logger = logging.getLogger("Utils")
REMOVE_KEYS: list = [
{
"paths": "-P, --paths",
"outtmpl": "-o, --output",
"progress_hooks": "--progress_hooks",
"postprocessor_hooks": "--postprocessor_hooks",
"post_hooks": "--post_hooks",
},
{
"quiet": "-q, --quiet",
"no_warnings": "--no-warnings",
"skip_download": "--skip-download",
"forceprint": "-O, --print",
"simulate": "--simulate",
"noprogress": "--no-progress",
"wait_for_video": "--wait-for-video",
"progress_delta": " --progress-delta",
"progress_template": "--progress-template",
"consoletitle": "--console-title",
"progress_with_newline": "--newline",
"forcejson": "-j, --dump-single-json",
"opt_update_to": "--update-to",
"opt_ap_list_mso": "--ap-list-mso",
"opt_batch_file": "-a, --batch-file",
"opt_alias": "--alias",
"opt_list_extractors": "--list-extractors",
"opt_version": "--version",
"opt_help": "-h, --help",
"opt_update": "-U, --update",
"opt_list_subtitles": "--list-subs",
"opt_list_thumbnails": "--list-thumbnails",
"opt_list_format": "-F, --list-formats",
"opt_dump_agent": "--dump-user-agent",
"opt_extractor_descriptions": "--extractor-descriptions",
"opt_list_impersonate_targets": "--list-impersonate-targets",
},
]
"Keys to remove from yt-dlp options at various levels."
YTDLP_INFO_CLS: YTDLP | None = None
"Cached YTDLP info class."
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
"Allowed subtitle file extensions."
@ -81,12 +33,6 @@ FILES_TYPE: list = [
TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
"Regex to find tags in templates."
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Regex to match ISO 8601 datetime strings."
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
class FileLogFormatter(logging.Formatter):
@ -94,83 +40,6 @@ class FileLogFormatter(logging.Formatter):
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
def get_ytdlp(params: dict | None = None) -> YTDLP:
"""
Get a static YTDLP instance for info extraction.
Args:
params (dict|None): YTDLP parameters.
Returns:
YTDLP: A static YTDLP instance.
"""
global YTDLP_INFO_CLS # noqa: PLW0603
default_params: dict[str, Any] = {
"color": "no_color",
"extract_flat": True,
"skip_download": True,
"ignoreerrors": True,
"ignore_no_formats_error": True,
"quiet": True,
}
if params:
return YTDLP(params=merge_dict(params, default_params))
if YTDLP_INFO_CLS is None:
YTDLP_INFO_CLS = YTDLP(params=default_params)
return YTDLP_INFO_CLS
def patch_metadataparser() -> None:
"""
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
"""
try:
from yt_dlp.postprocessor.metadataparser import MetadataParserPP
from yt_dlp.utils import Namespace
except Exception as exc:
LOG.warning(f"Unable to import yt_dlp metadata parser for patching: {exc!s}")
return
if getattr(MetadataParserPP.Actions, "_ytptube_patched", False):
return
class _ActionNS(Namespace):
_ACTIONS_STR: list[str] = []
@staticmethod
def _get_name(func) -> str | None:
if not callable(func):
return None
target = getattr(func, "__func__", func)
module_name = getattr(target, "__module__", None)
qual_name = getattr(target, "__qualname__", getattr(target, "__name__", None))
return f"{module_name}.{qual_name}" if module_name and qual_name else None
def __contains__(self, candidate: object) -> bool:
if candidate in self.__dict__.values():
return True
if func_name := _ActionNS._get_name(candidate):
if len(_ActionNS._ACTIONS_STR) < 1:
_ActionNS._ACTIONS_STR.extend([_ActionNS._get_name(value) for value in self.__dict__.values()])
return func_name in _ActionNS._ACTIONS_STR
return False
actions_dict: dict[str, Any] = dict(MetadataParserPP.Actions.items_)
MetadataParserPP.Actions = _ActionNS(**actions_dict)
MetadataParserPP.Actions._ytptube_patched = True
LOG.debug("MetadataParserPP action namespace patch applied successfully.")
def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
"""
Decorator that applies an LRU cache with a time-to-live (TTL) to a function.
@ -529,93 +398,6 @@ def validate_url(url: str, allow_internal: bool = False) -> bool:
return True
def arg_converter(
args: str,
level: int | bool | None = None,
dumps: bool = False,
removed_options: list | None = None,
keep_defaults: bool = False,
) -> dict:
"""
Convert yt-dlp options to a dictionary.
Args:
args (str): yt-dlp options string.
level (int|bool|None): Level of options to remove, True for all.
dumps (bool): Dump options as JSON.
removed_options (list|None): List of removed options.
keep_defaults (bool): Keep default options.
Returns:
dict: yt-dlp options dictionary.
"""
import yt_dlp.options
create_parser = yt_dlp.options.create_parser
def _default_opts(args: str):
patched_parser = create_parser()
try:
yt_dlp.options.create_parser = lambda: patched_parser
return yt_dlp.parse_options(args)
finally:
yt_dlp.options.create_parser = create_parser
try:
patch_metadataparser()
except Exception as exc:
LOG.debug("Metadata parser patch failed to apply: %s", exc)
default_opts = _default_opts([]).ydl_opts
if args:
# important to ignore external config files.
args = "--ignore-config " + args
opts = yt_dlp.parse_options(shlex.split(args, posix=os.name != "nt")).ydl_opts
diff = {k: v for k, v in opts.items() if default_opts[k] != v} if not keep_defaults else opts.items()
if "postprocessors" in diff:
diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]]
if "_warnings" in diff:
diff.pop("_warnings", None)
if level is True or isinstance(level, int):
bad_options = {}
if isinstance(level, bool) or not isinstance(level, int):
level = len(REMOVE_KEYS)
for i, item in enumerate(REMOVE_KEYS):
if i > level:
break
bad_options.update(item.items())
for key in diff.copy():
if key not in bad_options:
continue
if isinstance(removed_options, list):
removed_options.append(bad_options[key])
diff.pop(key, None)
if dumps is True:
from .encoder import Encoder
if "match_filter" in diff:
import inspect
matchFilter = inspect.getclosurevars(diff["match_filter"].func).nonlocals["filters"]
if isinstance(matchFilter, set):
diff["match_filter"] = {"filters": list(matchFilter)}
return json.loads(json.dumps(diff, cls=Encoder, default=str))
return diff
def validate_uuid(uuid_str: str, version: int = 4) -> bool:
"""
Validate if the UUID is valid.
@ -851,10 +633,8 @@ def get_mime_type(metadata: dict, file_path: Path) -> str:
str: MIME type compatible with HTML5 <video> tag.
"""
# Extract format name from ffprobe
format_name = metadata.get("format_name", "")
# Define mappings for HTML5-compatible video types
format_to_mime: dict[str, str] = {
"matroska": "video/x-matroska", # Default for MKV
"webm": "video/webm", # MKV can also be WebM
@ -862,7 +642,6 @@ def get_mime_type(metadata: dict, file_path: Path) -> str:
"mpegts": "video/mp2t",
}
# Check format_name against known formats
if format_name:
selected = None
for fmt in format_name.split(","):
@ -873,7 +652,6 @@ def get_mime_type(metadata: dict, file_path: Path) -> str:
if selected:
return selected
# Fallback: Use Python's mimetypes module
import mimetypes
mime_type, _ = mimetypes.guess_type(str(file_path))
@ -1189,116 +967,6 @@ def strip_newline(string: str) -> str:
return res.strip() if res else ""
async def read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
"""
Read a log file and return a set of log lines along with pagination metadata.
Args:
file (Path): The log file path.
offset (int): Number of lines to skip from the end (newer entries).
limit (int): Number of lines to return.
Returns:
dict: A dictionary containing:
- logs: List of log entries.
- next_offset: Offset for the next page or None.
- end_is_reached: True if there are no older logs.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return {"logs": [], "next_offset": None, "end_is_reached": True}
result = []
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
file_size: int = await f.tell()
block_size = 1024
block_end: int = file_size
buffer: bytes = b""
lines: list = []
required_count: int = offset + limit + 1
while len(lines) < required_count and block_end > 0:
block_start: int = max(0, block_end - block_size)
await f.seek(block_start)
chunk: bytes = await f.read(block_end - block_start)
buffer: bytes = chunk + buffer # prepend the chunk
lines = buffer.splitlines()
block_end = block_start
if len(lines) > offset + limit:
next_offset: int = offset + limit
end_is_reached = False
else:
next_offset = None
end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception:
return {"logs": [], "next_offset": None, "end_is_reached": True}
async def tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
"""
Continuously read a log file and emit new lines.
Args:
file (str): The log file path.
emitter (callable): A callable to emit new lines.
sleep_time (float): The time to sleep between reads.
"""
from asyncio import sleep as asyncio_sleep
from hashlib import sha256
from anyio import open_file
if not file.exists():
return
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line: bytes = await f.readline()
if not line:
await asyncio_sleep(sleep_time)
continue
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return
def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
"""
Validate and load a cookie file.
@ -1322,51 +990,6 @@ def load_cookies(file: str | Path) -> tuple[bool, MozillaCookieJar]:
raise ValueError(msg) from e
@timed_lru_cache(ttl_seconds=300, max_size=256)
def get_archive_id(url: str) -> dict[str, str | None]:
"""
Get the archive ID for a given URL.
Args:
url (str): URL to check.
Returns:
dict: {
"id": str | None,
"ie_key": str | None,
"archive_id": str | None
}
"""
idDict: dict[str, None] = {
"id": None,
"ie_key": None,
"archive_id": None,
}
for key, _ie in get_ytdlp()._ies.items():
try:
if not _ie.suitable(url):
continue
if not _ie.working():
continue
temp_id = _ie.get_temp_id(url)
if not temp_id:
continue
idDict["id"] = temp_id
idDict["ie_key"] = key
idDict["archive_id"] = make_archive_id(_ie, temp_id)
break
except Exception as e:
LOG.exception(e)
LOG.error(f"Error getting archive ID: {e}")
return idDict
def dt_delta(delta: timedelta) -> str:
"""
Convert a timedelta object to a human-readable string.
@ -1400,38 +1023,6 @@ def dt_delta(delta: timedelta) -> str:
return " ".join(parts)
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.
Args:
logs (list): log strings.
filters (list[str|Re.Pattern]): Optional extra filters of strings and/or regex.
Returns:
(list): List of matching log lines.
"""
all_patterns: list[str | re.Pattern] = [
"This live event will begin",
"Video unavailable. This video is private",
"This video is available to this channel",
"Private video. Sign in if you've been granted access to this video",
"[youtube] Premieres in",
"Falling back on generic information extractor",
"URL could be a direct video link, returning it as such",
] + (filters or [])
compiled: list[re.Pattern] = [
p if isinstance(p, re.Pattern) else re.compile(re.escape(p), re.IGNORECASE) for p in all_patterns
]
matched: list[str] = []
matched.extend(line for line in logs if line and any(p.search(line) for p in compiled))
return list(dict.fromkeys(matched))
def delete_dir(dir: Path) -> bool:
"""
Delete a directory and all its contents.
@ -1561,53 +1152,6 @@ def str_to_dt(time_str: str, now=None) -> datetime:
return dt
def ytdlp_reject(entry: dict, yt_params: dict) -> tuple[bool, str]:
"""
Implement yt-dlp reject filter logic.
Args:
entry (dict): The entry to check.
yt_params (dict): The yt-dlp parameters containing filters.
Returns:
tuple[bool, str]: A tuple where the first element is True if the entry passes the filters, or False if it does not,
and the second element is a message explaining the reason for rejection or an empty string if it passes.
"""
if title := entry.get("title"):
if (matchtitle := yt_params.get("matchtitle")) and not re.search(matchtitle, title, re.IGNORECASE):
return (False, f'"{title}" title did not match pattern "{matchtitle}". Skipping download.')
if (rejecttitle := yt_params.get("rejecttitle")) and re.search(rejecttitle, title, re.IGNORECASE):
return (False, f'"{title}" title matched reject pattern "{rejecttitle}". Skipping download.')
date = entry.get("upload_date")
date_range = yt_params.get("daterange")
if (date and date_range) and date not in date_range:
return (False, f"Upload date '{date}' is not in range '{date_range}'.")
view_count = entry.get("view_count")
if view_count is not None:
min_views = yt_params.get("min_views")
if min_views is not None and view_count < min_views:
return (
False,
f"Skipping {entry.get('title', 'video')}, because it has not reached minimum view count ({view_count}/{min_views}).",
)
max_views = yt_params.get("max_views")
if max_views is not None and view_count > max_views:
return (
False,
f"Skipping {entry.get('title', 'video')}, because it has exceeded maximum view count ({view_count}/{max_views}).",
)
if entry.get("age_limit") and age_restricted(entry.get("age_limit"), yt_params.get("age_limit")):
return (False, f'Video "{entry.get("title", "unknown")}" is age restricted.')
return (True, "")
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.
@ -1637,58 +1181,6 @@ def list_folders(path: Path, base: Path, depth_limit: int) -> list[str]:
return folders
def archive_add(file: str | Path, ids: list[str], skip_check: bool = False) -> bool:
"""
Add IDs to an archive file (delegates to the global Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to add.
skip_check (bool): If True, skip checking for existing IDs.
Returns:
bool: True if any new IDs were appended, False otherwise.
"""
from app.library.Archiver import Archiver
return Archiver.get_instance().add(file, ids, skip_check)
def archive_read(file: str | Path, ids: list[str] | None = None) -> list[str]:
"""
Read IDs from an archive file with optional filtering (delegates to Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]|None): Optional list of IDs to query; None/empty returns all.
Returns:
list[str]: IDs present in the archive, optionally filtered.
"""
from app.library.Archiver import Archiver
return Archiver.get_instance().read(file, ids)
def archive_delete(file: str | Path, ids: list[str]) -> bool:
"""
Delete IDs from an archive file (delegates to Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to remove.
Returns:
bool: True on success (including no-op), False on error.
"""
from app.library.Archiver import Archiver
return Archiver.get_instance().delete(file, ids)
def get_channel_images(thumbnails: list[dict]) -> dict:
"""
Extract channel images from a list of thumbnail dictionaries.
@ -1769,94 +1261,3 @@ def create_cookies_file(cookies: str, file: Path | None = None) -> Path:
load_cookies(file)
return file
def get_thumbnail(thumbnails: list) -> str | None:
"""
Extract thumbnail URL from a yt-dlp entry.
Args:
thumbnails (list): The list of thumbnail dictionaries from yt-dlp entry.
Returns:
str | None: The thumbnail URL if available, otherwise None.
"""
if not thumbnails or not isinstance(thumbnails, list):
return None
def _thumb_sort_key(thumb: dict) -> tuple:
return (
thumb.get("preference") if thumb.get("preference") is not None else -1,
thumb.get("width") if thumb.get("width") is not None else -1,
thumb.get("height") if thumb.get("height") is not None else -1,
thumb.get("id") if thumb.get("id") is not None else "",
thumb.get("url"),
)
return max(thumbnails, key=_thumb_sort_key, default=None)
def get_extras(entry: dict, kind: str = "video") -> dict:
"""
Extract useful information from a yt-dlp entry.
Args:
entry (dict): The entry data from yt-dlp.
kind (str): The type of the item (e.g., "video", "playlist").
Returns:
dict: The extracted data.
"""
extras = {}
if not entry or not isinstance(entry, dict):
return extras
if "playlist" == kind:
for property in ("id", "title", "uploader", "uploader_id"):
if val := entry.get(property):
extras[f"playlist_{property}"] = val
if thumbnail := get_thumbnail(entry.get("thumbnails", [])):
extras["thumbnail"] = thumbnail.get("url")
elif thumbnail := entry.get("thumbnail"):
extras["thumbnail"] = thumbnail
for property in ("uploader", "channel"):
if val := entry.get(property):
extras[property] = val
if release_in := entry.get("release_timestamp"):
extras["release_in"] = formatdate(release_in, usegmt=True)
if release_in and "is_upcoming" == entry.get("live_status"):
extras["is_live"] = release_in
if duration := entry.get("duration"):
extras["duration"] = duration
extras["is_premiere"] = bool(entry.get("is_premiere", False))
extractor_key = entry.get("ie_key") or entry.get("extractor_key") or entry.get("extractor") or ""
if "thumbnail" not in extras and "youtube" in str(extractor_key).lower():
extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**entry)
return extras
def parse_outtmpl(output_template: str, info_dict: dict, params: dict | None = None) -> str:
"""
Parse yt-dlp output template with given info_dict.
Args:
output_template (str): The output template string.
info_dict (dict): The info dictionary from yt-dlp.
params (dict|None): Additional parameters for yt-dlp.
Returns:
str: The parsed output string.
"""
return get_ytdlp(params=params).prepare_filename(info_dict=info_dict, outtmpl=output_template)

View file

@ -13,12 +13,13 @@ from typing import TYPE_CHECKING, Any
import yt_dlp.utils
from app.features.ytdlp.utils import extract_ytdlp_logs
from app.features.ytdlp.ytdlp import YTDLP
from app.library.config import Config
from app.library.Events import EventBus, Events
from app.library.Utils import create_cookies_file, extract_ytdlp_logs
from app.library.ytdlp import YTDLP
from app.library.Utils import create_cookies_file
from .extractor import extract_info_sync
from ...features.ytdlp.extractor import extract_info_sync
from .hooks import HookHandlers, NestedLogger
from .process_manager import ProcessManager
from .status_tracker import StatusTracker

View file

@ -9,20 +9,13 @@ import yt_dlp.utils
from app.features.conditions.service import Conditions
from app.features.presets.service import Presets
from app.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.utils import archive_add, archive_read, arg_converter, get_extras, ytdlp_reject
from app.library.Events import Events
from app.library.ItemDTO import ItemDTO
from app.library.Utils import (
archive_add,
archive_read,
arg_converter,
create_cookies_file,
get_extras,
merge_dict,
ytdlp_reject,
)
from app.library.Utils import create_cookies_file, merge_dict
from .core import Download
from .extractor import fetch_info
from .playlist_processor import process_playlist
from .video_processor import add_video

View file

@ -3,7 +3,8 @@
import logging
from typing import TYPE_CHECKING, Any
from app.library.Utils import merge_dict, ytdlp_reject
from app.features.ytdlp.utils import ytdlp_reject
from app.library.Utils import merge_dict
if TYPE_CHECKING:
from app.library.ItemDTO import Item

View file

@ -8,7 +8,6 @@ from typing import TYPE_CHECKING
from aiohttp import web
from app.library.config import Config
from app.library.downloads.extractor import shutdown_extractor
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item, ItemDTO
from app.library.Scheduler import Scheduler
@ -48,7 +47,7 @@ class DownloadQueue(metaclass=Singleton):
def get_instance(config: Config | None = None) -> "DownloadQueue":
return DownloadQueue(config=config)
def attach(self, app: web.Application) -> None:
def attach(self, _: web.Application) -> None:
Services.get_instance().add("queue", self)
async def event_handler(_, __):
@ -75,7 +74,7 @@ class DownloadQueue(metaclass=Singleton):
id=delete_old_history.__name__,
)
app.on_shutdown.append(self.on_shutdown)
# app.on_shutdown.append(self.on_shutdown)
async def test(self) -> bool:
await self.done.test()
@ -217,8 +216,7 @@ class DownloadQueue(metaclass=Singleton):
return self.pool.is_paused()
async def on_shutdown(self, _: web.Application):
# await self.pool.shutdown()
await shutdown_extractor()
await self.pool.shutdown()
async def add(self, item: Item, already: set | None = None, entry: dict | None = None) -> dict[str, str]:
"""

View file

@ -8,7 +8,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from app.library.Events import EventBus, Events
from app.library.ffprobe import ffprobe
from .types import StatusDict, Terminator
from .utils import safe_relative_path
@ -123,6 +122,8 @@ class StatusTracker:
self.info.file_size = 0
try:
from app.features.streaming.library.ffprobe import ffprobe
ff = await ffprobe(final_name)
self.info.extras["is_video"] = ff.has_video()
self.info.extras["is_audio"] = ff.has_audio()

View file

@ -6,11 +6,11 @@ from datetime import UTC, datetime, timedelta
from email.utils import formatdate
from typing import TYPE_CHECKING
from app.features.ytdlp.utils import extract_ytdlp_logs, get_extras
from app.library.downloads import Download
from app.library.Events import Events
from app.library.ItemDTO import ItemDTO
from app.library.Utils import calc_download_path, extract_ytdlp_logs, get_extras, merge_dict, str_to_dt
from .core import Download
from app.library.Utils import calc_download_path, merge_dict, str_to_dt
if TYPE_CHECKING:
from app.library.ItemDTO import Item

View file

@ -1,210 +0,0 @@
import logging
from collections.abc import Iterable
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.presets.service import Presets
from app.library.Archiver import Archiver
from app.library.router import route
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)
def _get_archive_file_from_preset(preset: str) -> str | None:
"""
Resolve the archive file path for a given preset.
Validates that the preset exists and that applying the preset results
in yt-dlp options that contain a 'download_archive' path.
"""
if not preset or not Presets.get_instance().has(preset):
return None
try:
opts: dict = YTDLPOpts.get_instance().preset(preset).get_all()
except Exception as e:
LOG.error(f"Failed to build yt-dlp opts for preset '{preset}'. {e!s}")
return None
if not (archive_file := opts.get("download_archive")):
return None
if not isinstance(archive_file, str) or len(archive_file.strip()) < 1:
return None
return archive_file.strip()
def _normalize_ids(items: Iterable[str] | None) -> tuple[list[str], list[str]]:
"""
Validate and normalize archive IDs.
- Trims whitespace
- Enforces that each ID has at least two whitespace-separated tokens
(e.g., "youtube ABC123") as required by yt-dlp's archive format
- De-duplicates while preserving order
Returns a tuple: (valid_ids, invalid_inputs)
"""
if not items:
return ([], [])
seen: set[str] = set()
valid: list[str] = []
invalid: list[str] = []
for raw in items:
if raw is None:
continue
s = str(raw).strip()
if not s:
continue
if len(s.split()) < 2:
invalid.append(s)
continue
if s in seen:
continue
seen.add(s)
valid.append(s)
return (valid, invalid)
@route("GET", "api/archiver/", "archiver")
async def archiver_get(request: Request) -> Response:
"""
Read IDs from the download archive for a given preset.
Query params:
- preset: required preset name/id
- ids: optional comma-separated list to filter; when omitted, returns all
"""
preset: str | None = request.query.get("preset")
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_archive_file_from_preset(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
ids_param: str | None = request.query.get("ids")
ids: list[str] = []
if ids_param:
ids_list = [s.strip() for s in ids_param.split(",") if s and s.strip()]
ids, invalid = _normalize_ids(ids_list)
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
try:
data: list[str] = Archiver.get_instance().read(archive_file, ids or None)
return web.json_response(
data={"file": archive_file, "items": data, "count": len(data)}, status=web.HTTPOk.status_code
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to read archive file for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
@route("POST", "api/archiver/", "archiver_add")
async def archiver_add(request: Request) -> Response:
"""
Append IDs to the download archive for a given preset.
Body: { "preset": string, "items": [string, ...], "skip_check": bool? }
"""
post = await request.json()
preset: str | None = post.get("preset") if isinstance(post, dict) else None
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_archive_file_from_preset(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], [])
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
if len(items) < 1:
return web.json_response(
data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code
)
skip_check: bool = bool(post.get("skip_check", False)) if isinstance(post, dict) else False
try:
status: bool = Archiver.get_instance().add(archive_file, items, skip_check=skip_check)
return web.json_response(
data={"file": archive_file, "status": status, "items": items},
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to add items to archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)
@route("DELETE", "api/archiver/", "archiver_delete")
async def archiver_delete(request: Request) -> Response:
"""
Remove IDs from the download archive for a given preset.
Body: { "preset": string, "items": [string, ...] }
"""
post = await request.json()
preset: str | None = post.get("preset") if isinstance(post, dict) else None
if not preset:
return web.json_response(data={"error": "preset is required."}, status=web.HTTPBadRequest.status_code)
archive_file: str | None = _get_archive_file_from_preset(preset)
if not archive_file:
return web.json_response(
data={"error": f"Preset '{preset}' does not provide a download_archive."},
status=web.HTTPBadRequest.status_code,
)
items, invalid = _normalize_ids((post or {}).get("items", [])) if isinstance(post, dict) else ([], [])
if invalid:
return web.json_response(
data={"error": "invalid ids provided.", "invalid_items": invalid},
status=web.HTTPBadRequest.status_code,
)
if len(items) < 1:
return web.json_response(
data={"error": "items is required and must be a non-empty list."}, status=web.HTTPBadRequest.status_code
)
try:
status: bool = Archiver.get_instance().delete(archive_file, items)
return web.json_response(
data={"file": archive_file, "status": status, "items": items},
status=web.HTTPOk.status_code if status else web.HTTPNotModified.status_code,
)
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": f"Failed to delete items from archive for preset '{preset}'.", "message": str(e)},
status=web.HTTPInternalServerError.status_code,
)

View file

@ -7,12 +7,12 @@ from urllib.parse import unquote_plus
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.streaming.library.ffprobe import ffprobe
from app.library.cache import Cache
from app.library.config import Config
from app.library.downloads import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.ffprobe import ffprobe
from app.library.router import route
from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type, move_file, rename_file

View file

@ -5,11 +5,11 @@ from datetime import UTC, datetime
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
from app.library.cache import Cache
from app.library.config import Config
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.router import add_route, route
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)

View file

@ -95,7 +95,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
continue
try:
download.info.sidecar = download.get_file_sidecar()
download.info.sidecar = download.info.get_file_sidecar()
except Exception:
download.info.sidecar = {}
@ -264,7 +264,7 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
}
if "finished" == item.info.status and (filename := item.info.get_file()):
from app.library.ffprobe import ffprobe
from app.features.streaming.library.ffprobe import ffprobe
try:
info["ffprobe"] = await ffprobe(filename)

View file

@ -8,13 +8,13 @@ from urllib.parse import urlparse
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
from app.library.ag_utils import ag
from app.library.cache import Cache
from app.library.config import Config
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.router import route
from app.library.Utils import validate_url
from app.library.YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)

View file

@ -1,5 +1,7 @@
import asyncio
import logging
import os
import re
from pathlib import Path
from aiohttp import web
@ -8,10 +10,121 @@ from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.router import route
from app.library.Utils import read_logfile, tail_log
LOG: logging.Logger = logging.getLogger(__name__)
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Match ISO8601."
async def _read_logfile(file: Path, offset: int = 0, limit: int = 50) -> dict:
"""
Read a log file and return a set of log lines along with pagination metadata.
Args:
file (Path): The log file path.
offset (int): Number of lines to skip from the end (newer entries).
limit (int): Number of lines to return.
Returns:
dict: A dictionary containing:
- logs: List of log entries.
- next_offset: Offset for the next page or None.
- end_is_reached: True if there are no older logs.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return {"logs": [], "next_offset": None, "end_is_reached": True}
result = []
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
file_size: int = await f.tell()
block_size = 1024
block_end: int = file_size
buffer: bytes = b""
lines: list = []
required_count: int = offset + limit + 1
while len(lines) < required_count and block_end > 0:
block_start: int = max(0, block_end - block_size)
await f.seek(block_start)
chunk: bytes = await f.read(block_end - block_start)
buffer: bytes = chunk + buffer # prepend the chunk
lines = buffer.splitlines()
block_end = block_start
if len(lines) > offset + limit:
next_offset: int = offset + limit
end_is_reached = False
else:
next_offset = None
end_is_reached = True
for line in lines[-(offset + limit) : -offset] if offset else lines[-limit:]:
line_bytes: bytes | str = line if isinstance(line, bytes) else line.encode()
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
result.append(
{
"id": sha256(line_bytes).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
return {"logs": result, "next_offset": next_offset, "end_is_reached": end_is_reached}
except Exception:
return {"logs": [], "next_offset": None, "end_is_reached": True}
async def _tail_log(file: Path, emitter: callable, sleep_time: float = 0.5):
"""
Live tail.
Args:
file (str): The log file path.
emitter (callable): A callable to emit new lines.
sleep_time (float): The time to sleep between reads.
"""
from hashlib import sha256
from anyio import open_file
if not file.exists():
return
try:
async with await open_file(file, "rb") as f:
await f.seek(0, os.SEEK_END)
while True:
line: bytes = await f.readline()
if not line:
await asyncio.sleep(sleep_time)
continue
msg: str = line.decode(errors="replace")
dt_match: re.Match[str] | None = DT_PATTERN.match(msg)
await emitter(
{
"id": sha256(line if isinstance(line, bytes) else line.encode()).hexdigest(),
"line": msg[dt_match.end() :] if dt_match else msg,
"datetime": dt_match.group(1) if dt_match else None,
}
)
except Exception as e:
LOG.error(f"Error while tailing log file '{file!s}': {e!s}")
return
@route("GET", "api/logs/", "logs")
async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
@ -35,7 +148,7 @@ async def logs(request: Request, config: Config, encoder: Encoder) -> Response:
if limit < 1 or limit > 150:
limit = 50
logs_data = await read_logfile(
logs_data = await _read_logfile(
file=Path(config.config_path) / "logs" / "app.log",
offset=offset,
limit=limit,
@ -85,12 +198,17 @@ async def stream_logs(request: Request, config: Config, encoder: Encoder) -> Res
payload = f"event: log_lines\ndata: {encoder.encode(data)}\n\n"
await response.write(payload.encode("utf-8"))
log_task: asyncio.Task[None] = asyncio.create_task(tail_log(file=log_file, emitter=emit_log), name="log_stream")
from app.features.core.utils import gen_random
log_task: asyncio.Task[None] = asyncio.create_task(
_tail_log(file=log_file, emitter=emit_log),
name=f"log_stream_{gen_random(8)}",
)
try:
LOG.debug("Log streaming connected.")
while not log_task.done():
await asyncio.sleep(0.5)
await asyncio.sleep(1.0)
if request.transport is None or request.transport.is_closing():
log_task.cancel()
break

View file

@ -1,278 +1,3 @@
import logging
import time
from datetime import UTC, datetime
from pathlib import Path
"""Migrated API routes for feature submodule."""
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.M3u8 import M3u8
from app.library.Playlist import Playlist
from app.library.router import route
from app.library.Segments import Segments
from app.library.Subtitle import Subtitle
from app.library.Utils import StreamingError, get_file
LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create")
async def playlist_create(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the playlist.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
base_path: str = config.base_path.rstrip("/")
try:
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=web.HTTPFound.status_code,
headers={
"Location": str(
app.router["playlist_create"].url_for(
file=str(realFile).replace(config.download_path, "").strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
return web.Response(
text=await Playlist(download_path=Path(config.download_path), url=f"{base_path}/").make(file=realFile),
headers={
"Content-Type": "application/x-mpegURL",
"Cache-Control": "no-cache",
"Access-Control-Max-Age": "300",
},
status=web.HTTPOk.status_code,
)
except StreamingError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8_create")
async def m3u8_create(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the m3u8 file.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
mode: str = request.match_info.get("mode")
if mode not in ["video", "subtitle"]:
return web.json_response(
data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code
)
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
duration = request.query.get("duration", None)
if "subtitle" in mode:
if not duration:
return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code)
duration = float(duration)
base_path: str = config.base_path.rstrip("/")
try:
cls = M3u8(download_path=Path(config.download_path), url=f"{base_path}/")
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["m3u8_create"].url_for(
mode=mode, file=str(realFile).replace(config.download_path, "").strip("/")
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
if "subtitle" in mode:
text = await cls.make_subtitle(file=realFile, duration=duration)
else:
text = await cls.make_stream(file=realFile)
except StreamingError as e:
LOG.exception(e)
return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code)
return web.Response(
text=text,
headers={
"Content-Type": "application/x-mpegURL",
"Cache-Control": "no-cache",
"Access-Control-Max-Age": "300",
},
status=web.HTTPOk.status_code,
)
@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream")
async def segments_stream(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the segments.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
segment: int = request.match_info.get("segment")
sd: int = request.query.get("sd")
vc: int = int(request.query.get("vc", 0))
ac: int = int(request.query.get("ac", 0))
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
if not segment:
return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["segments_stream"].url_for(
segment=segment,
file=str(realFile).replace(config.download_path, "").strip("/"),
)
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
mtime = realFile.stat().st_mtime
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
resp = web.StreamResponse(
status=web.HTTPOk.status_code,
headers={
"Content-Type": "video/mpegts",
"X-Accel-Buffering": "no",
"Access-Control-Allow-Origin": "*",
"Pragma": "public",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Last-Modified": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
),
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
),
},
)
await resp.prepare(request)
await Segments(
download_path=config.download_path,
index=int(segment),
duration=float(f"{float(sd if sd else M3u8.duration):.6f}"),
vconvert=vc == 1,
aconvert=ac == 1,
).stream(realFile, resp)
return resp
@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles_get")
async def subtitles_get(request: Request, config: Config, app: web.Application) -> Response:
"""
Get the subtitles.
Args:
request (Request): The request object.
config (Config): The configuration instance.
app (web.Application): The aiohttp application instance.
Returns:
Response: The response object.
"""
file: str = request.match_info.get("file")
if not file:
return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code)
realFile, status = get_file(download_path=config.download_path, file=file)
if web.HTTPFound.status_code == status:
return Response(
status=status,
headers={
"Location": str(
app.router["subtitles_get"].url_for(file=str(realFile).replace(config.download_path, "").strip("/"))
),
},
)
if web.HTTPNotFound.status_code == status:
return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status)
mtime = realFile.stat().st_mtime
if request.if_modified_since and request.if_modified_since.timestamp() == mtime:
lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple())
return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod})
return web.Response(
body=await Subtitle().make(file=realFile),
headers={
"Content-Type": "text/vtt; charset=UTF-8",
"X-Accel-Buffering": "no",
"Access-Control-Allow-Origin": "*",
"Pragma": "public",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Last-Modified": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()
),
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple()
),
},
status=web.HTTPOk.status_code,
)
import app.features.streaming.router # noqa: F401

View file

@ -1,312 +1,3 @@
import json
import logging
import time
from collections import OrderedDict
from typing import Any
"""Migrated API routes for feature submodule."""
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.presets.service import Presets
from app.library.cache import Cache
from app.library.config import Config
from app.library.downloads.extractor import fetch_info
from app.library.encoder import Encoder
from app.library.ItemDTO import Item
from app.library.router import route
from app.library.Utils import (
REMOVE_KEYS,
archive_read,
arg_converter,
get_archive_id,
validate_url,
)
from app.library.YTDLPOpts import YTDLPCli, YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__)
@route("POST", "api/yt-dlp/convert/", "convert")
async def convert(request: Request) -> Response:
"""
Convert the yt-dlp args to a dict.
Args:
request (Request): The request object.
Returns:
Response: The response object.
"""
post = await request.json()
args: str | None = post.get("args")
if not args:
return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code)
try:
response = {"opts": {}, "output_template": None, "download_path": None}
data = arg_converter(args, dumps=True)
if "outtmpl" in data and "default" in data["outtmpl"]:
response["output_template"] = data["outtmpl"]["default"]
if "paths" in data and "home" in data["paths"]:
response["download_path"] = data["paths"]["home"]
if "format" in data:
response["format"] = data["format"]
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key in data:
if key in bad_options.items():
removed_options.append(bad_options[key])
continue
if not key.startswith("_"):
response["opts"][key] = data[key]
if len(removed_options) > 0:
response["removed_options"] = removed_options
return web.json_response(data=response, status=web.HTTPOk.status_code)
except Exception as e:
err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err
err = err.replace("main.py: error: ", "").strip().capitalize()
return web.json_response(
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
status=web.HTTPBadRequest.status_code,
)
@route("GET", "api/yt-dlp/url/info/", "get_info")
async def get_info(request: Request, cache: Cache, config: Config) -> Response:
"""
Get the video info.
Args:
request (Request): The request object.
cache (Cache): The cache instance.
config (Config): The config instance.
Returns:
Response: The response object
"""
url: str | None = request.query.get("url")
if not url:
return web.json_response(
data={"status": False, "message": "URL is required.", "error": "URL is required."},
status=web.HTTPBadRequest.status_code,
)
try:
validate_url(url, allow_internal=config.allow_internal_urls)
except ValueError as e:
return web.json_response(
data={"status": False, "message": str(e), "error": str(e)},
status=web.HTTPBadRequest.status_code,
)
opts: YTDLPOpts = YTDLPOpts.get_instance()
preset: str = request.query.get("preset", config.default_preset)
if not Presets.get_instance().get(preset):
msg: str = f"Preset '{preset}' does not exist."
return web.json_response(
data={"status": False, "message": msg, "error": msg},
status=web.HTTPBadRequest.status_code,
)
opts = opts.preset(preset)
if cli_args := request.query.get("args", None):
try:
arg_converter(cli_args, dumps=True)
opts = opts.add_cli(cli_args, from_user=True)
except Exception as e:
err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err
err = err.replace("main.py: error: ", "").strip().capitalize()
return web.json_response(
data={"error": f"Failed to parse command options for yt-dlp. '{err}'."},
status=web.HTTPBadRequest.status_code,
)
try:
key: str = cache.hash(f"{preset}:{url}:{cli_args or ''}")
if cache.has(key) and not request.query.get("force", False):
data: Any | None = cache.get(key)
data["_cached"] = {
"status": "hit",
"preset": preset,
"cli_args": cli_args,
"key": key,
"ttl": data.get("_cached", {}).get("ttl", 300),
"ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(),
"expires": data.get("_cached", {}).get("expires", time.time() + 300),
}
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
ytdlp_opts: dict = opts.get_all()
(data, logs) = await fetch_info(
config=ytdlp_opts,
url=url,
debug=False,
no_archive=True,
follow_redirect=True,
sanitize_info=True,
capture_logs=logging.WARNING,
)
if not data or not isinstance(data, dict):
return web.json_response(
data={
"status": False,
"error": f"Failed to extract video info. {'. '.join(logs)}",
"message": "Failed to extract video info.",
},
status=web.HTTPInternalServerError.status_code,
)
if data and "formats" in data:
from yt_dlp.cookies import LenientSimpleCookie
for index, item in enumerate(data["formats"]):
if "cookies" in item and len(item["cookies"]) > 0:
cookies: list[str] = [f"{c.key}={c.value}" for c in LenientSimpleCookie(item["cookies"]).values()]
if len(cookies) > 0:
data["formats"][index]["h_cookies"] = "; ".join(cookies)
data["formats"][index]["h_cookies"] = data["formats"][index]["h_cookies"].strip()
data["_cached"] = {
"status": "miss",
"preset": preset,
"cli_args": cli_args,
"key": key,
"ttl": 300,
"ttl_left": 300,
"expires": time.time() + 300,
}
data["is_archived"] = False
archive_file: str | None = ytdlp_opts.get("download_archive")
data["archive_file"] = archive_file if archive_file else None
if archive_file and (archive_id := get_archive_id(url=url).get("archive_id")):
data["archive_id"] = archive_id
data["is_archived"] = len(archive_read(archive_file, [archive_id])) > 0
data = OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1]))))
cache.set(key=key, value=data, ttl=300)
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
except Exception as e:
LOG.exception(e)
LOG.error(f"Error encountered while getting video info for '{url}'. '{e!s}'.")
return web.json_response(
data={
"error": "failed to get video info.",
"message": str(e),
"formats": [],
},
status=web.HTTPInternalServerError.status_code,
)
@route("GET", "api/yt-dlp/options/", "get_options")
async def get_options() -> Response:
"""
Get the yt-dlp CLI options.
Returns:
Response: The response object with the yt-dlp CLI options.
"""
from app.library.ytdlp import ytdlp_options
return web.json_response(body=json.dumps(ytdlp_options(), indent=4, default=str), status=web.HTTPOk.status_code)
@route("POST", "api/yt-dlp/archive_id/", "get_archive_ids")
async def get_archive_ids(request: Request, config: Config) -> Response:
"""
Get the archive IDs for the given URLs.
Returns:
Response: The response object with the yt-dlp CLI options.
"""
from app.library.Utils import get_archive_id
data = (await request.json()) if request.body_exists else None
if not data or not isinstance(data, list):
return web.json_response(
data={"error": "Invalid request. expecting list with URLs."},
status=web.HTTPBadRequest.status_code,
)
response = []
for i, url in enumerate(data):
dct = {"index": i, "url": url}
try:
validate_url(url, allow_internal=config.allow_internal_urls)
dct.update(get_archive_id(url))
except ValueError as e:
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)})
response.append(dct)
return web.json_response(data=response, status=web.HTTPOk.status_code)
@route("POST", "api/yt-dlp/command/", "make_command")
async def make_command(request: Request, config: Config, encoder: Encoder) -> Response:
"""
Build yt-dlp CLI command.
Args:
request (Request): The request object.
config (Config): The config instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object with the merged fields and final yt-dlp CLI command string.
"""
if not config.console_enabled:
return web.json_response(data={"error": "Console is disabled."}, status=web.HTTPForbidden.status_code)
data = (await request.json()) if request.body_exists else None
if not data or not isinstance(data, dict):
return web.json_response(
data={"error": "Invalid request. expecting JSON body."},
status=web.HTTPBadRequest.status_code,
)
try:
it = Item.format(data)
except ValueError as e:
return web.json_response(data={"error": str(e), "data": data}, status=web.HTTPBadRequest.status_code)
try:
command, info = YTDLPCli(item=it, config=config).build()
except Exception as e:
LOG.exception(e)
return web.json_response(
data={"error": "Failed to build CLI command"},
status=web.HTTPBadRequest.status_code,
)
if request.query.get("full", False):
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
return web.json_response(data={"command": command}, status=web.HTTPOk.status_code)
import app.features.ytdlp.router # noqa: F401

View file

@ -1,13 +1,16 @@
import logging
from app.library.Events import EventBus, Events
from app.library.router import RouteType, route
LOG: logging.Logger = logging.getLogger(__name__)
@route(RouteType.SOCKET, "connect", "socket_connect")
async def connect(sid: str):
pass
async def connect(notify: EventBus, sid: str):
notify.emit(
Events.CONNECTED, data={"sid": sid}, title="Client connected", message=f"Client '{sid}' connected.", to=sid
)
@route(RouteType.SOCKET, "disconnect", "socket_disconnect")

View file

@ -26,7 +26,7 @@ class TestItemFormatAndBasics:
}
with (
patch("app.library.ItemDTO.Item._default_preset", return_value="default"),
patch("app.library.Utils.arg_converter") as mock_arg_conv,
patch("app.features.ytdlp.utils.arg_converter") as mock_arg_conv,
):
mock_arg_conv.return_value = None
item = Item.format(data)
@ -56,7 +56,7 @@ class TestItemFormatAndBasics:
):
Item.format({"url": "https://example.com", "preset": "bad"})
@patch("app.library.Utils.arg_converter")
@patch("app.features.ytdlp.utils.arg_converter")
def test_format_cli_parse_error(self, mock_arg_conv):
mock_arg_conv.side_effect = RuntimeError("bad cli")
with pytest.raises(ValueError, match="Failed to parse command options"):

View file

@ -1,98 +0,0 @@
import logging
from typing import Any
import pytest
from app.library.LogWrapper import LogWrapper
class CaptureHandler(logging.Handler):
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
def make_logger(name: str = "lw_test") -> tuple[logging.Logger, CaptureHandler]:
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
handler = CaptureHandler()
# Avoid duplicate handlers when tests run multiple times
for h in list(logger.handlers):
logger.removeHandler(h)
logger.addHandler(handler)
return logger, handler
class TestLogWrapper:
def test_add_target_type_validation(self) -> None:
lw = LogWrapper()
with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"):
lw.add_target(123) # type: ignore[arg-type]
def test_add_target_name_inference_and_custom(self) -> None:
lw = LogWrapper()
logger, _ = make_logger("one")
# Name inferred from logger
lw.add_target(logger)
assert lw.targets[-1].name == "one"
assert lw.has_targets() is True
# Name inferred from callable
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001
return None
lw.add_target(sink)
assert lw.targets[-1].name == "sink"
# Custom name overrides
lw.add_target(logger, name="custom")
assert lw.targets[-1].name == "custom"
def test_level_filtering_and_dispatch(self) -> None:
lw = LogWrapper()
logger, cap = make_logger("cap")
calls: list[tuple[int, str, tuple, dict]] = []
def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None:
calls.append((level, msg, args, kwargs))
# Logger target at INFO, callable target at WARNING
lw.add_target(logger, level=logging.INFO)
lw.add_target(sink, level=logging.WARNING)
# DEBUG should hit none
lw.debug("d1")
assert len(cap.records) == 0
assert len(calls) == 0
# INFO hits logger only
lw.info("hello %s", "X")
assert len(cap.records) == 1
assert cap.records[0].levelno == logging.INFO
assert cap.records[0].getMessage() == "hello X"
assert len(calls) == 0
# WARNING hits both
lw.warning("warn %s", "Y", extra={"k": 1})
assert len(cap.records) == 2
assert cap.records[1].levelno == logging.WARNING
assert cap.records[1].getMessage() == "warn Y"
assert len(calls) == 1
lvl, msg, args, kwargs = calls[0]
assert lvl == logging.WARNING
assert msg == "warn %s"
assert args == ("Y",)
assert "extra" in kwargs
assert kwargs["extra"] == {"k": 1}
# ERROR still hits both; CRITICAL too
lw.error("err")
lw.critical("boom")
assert any(r.levelno == logging.ERROR for r in cap.records)
assert any(r.levelno == logging.CRITICAL for r in cap.records)
assert any(c[0] == logging.ERROR for c in calls)
assert any(c[0] == logging.CRITICAL for c in calls)

View file

@ -10,14 +10,9 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.features.ytdlp.utils import arg_converter
from app.library.Utils import (
FileLogFormatter,
StreamingError,
archive_add,
archive_delete,
archive_read,
arg_converter,
calc_download_path,
check_id,
clean_item,
@ -25,15 +20,12 @@ from app.library.Utils import (
delete_dir,
dt_delta,
encrypt_data,
extract_ytdlp_logs,
get,
get_archive_id,
get_file,
get_file_sidecar,
get_files,
get_mime_type,
get_possible_images,
get_ytdlp,
init_class,
is_private_address,
list_folders,
@ -41,34 +33,15 @@ from app.library.Utils import (
load_modules,
merge_dict,
move_file,
parse_outtmpl,
parse_tags,
read_logfile,
rename_file,
str_to_dt,
strip_newline,
tail_log,
timed_lru_cache,
validate_url,
validate_uuid,
ytdlp_reject,
)
from app.library.downloads.extractor import extract_info_sync
class TestStreamingError:
"""Test the StreamingError exception class."""
def test_streaming_error_creation(self):
"""Test that StreamingError can be created with a message."""
error_msg = "Test error message"
error = StreamingError(error_msg)
assert str(error) == error_msg
def test_streaming_error_inheritance(self):
"""Test that StreamingError inherits from Exception."""
error = StreamingError("test")
assert isinstance(error, Exception)
from app.routes.api.logs import _read_logfile, _tail_log
class TestTimedLruCache:
@ -1258,29 +1231,6 @@ class TestValidateUrl:
assert True
class TestExtractYtdlpLogs:
"""Test YTDLP log extraction function."""
def test_extract_ytdlp_logs_basic(self):
"""Test basic log extraction."""
logs = ["This live event will begin soon", "ERROR: Failed", "WARNING: Deprecated"]
result = extract_ytdlp_logs(logs)
assert isinstance(result, list)
assert len(result) >= 1 # Should match "This live event will begin"
def test_extract_ytdlp_logs_with_filters(self):
"""Test log extraction with filters."""
logs = ["INFO: Downloading", "ERROR: Failed", "WARNING: Deprecated"]
filters = [re.compile(r"ERROR")]
result = extract_ytdlp_logs(logs, filters)
assert len(result) >= 0 # Should filter based on patterns
def test_extract_ytdlp_logs_empty(self):
"""Test with empty logs."""
result = extract_ytdlp_logs([])
assert result == []
class TestGetFileSidecar:
"""Test file sidecar function."""
@ -1310,79 +1260,6 @@ class TestGetFileSidecar:
assert isinstance(result, dict) # Returns dict, not list
class TestArchiveFunctions:
"""Test archive-related functions."""
def setup_method(self):
"""Set up test archive file."""
self.temp_dir = tempfile.mkdtemp()
self.archive_file = Path(self.temp_dir) / "archive.txt"
def teardown_method(self):
"""Clean up after tests."""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_archive_add_and_read(self):
"""Test adding and reading archive entries."""
ids = ["id1", "id2", "id3"]
# Add entries - just test it returns a boolean
result = archive_add(self.archive_file, ids)
assert isinstance(result, bool)
# Read entries - just test it returns a list
read_ids = archive_read(self.archive_file)
assert isinstance(read_ids, list)
def test_archive_delete(self):
"""Test deleting archive entries."""
# Delete some entries - just test it returns a boolean
delete_ids = ["id2"]
result = archive_delete(self.archive_file, delete_ids)
assert isinstance(result, bool)
def test_archive_read_nonexistent(self):
"""Test reading from non-existent archive."""
nonexistent = Path(self.temp_dir) / "nonexistent.txt"
result = archive_read(nonexistent)
assert result == []
class TestExtractInfo:
"""Test the extract_info function."""
@patch("app.library.downloads.extractor.YTDLP")
def test_extract_info_basic(self, mock_ytdlp_class):
"""Test basic extract_info functionality."""
mock_ytdlp = MagicMock()
mock_ytdlp.extract_info.return_value = {"title": "Test Video", "id": "test123"}
mock_ytdlp_class.return_value = mock_ytdlp
config = {"quiet": True}
url = "https://example.com/video"
(result, logs) = extract_info_sync(config, url)
assert isinstance(result, dict), "Result should be a dictionary"
assert isinstance(logs, list), "Logs should be a list"
mock_ytdlp.extract_info.assert_called_once()
@patch("app.library.downloads.extractor.YTDLP")
def test_extract_info_with_debug(self, mock_ytdlp_class):
"""Test extract_info with debug enabled."""
mock_ytdlp = MagicMock()
mock_ytdlp.extract_info.return_value = {"title": "Test Video"}
mock_ytdlp_class.return_value = mock_ytdlp
config = {}
url = "https://example.com/video"
(result, logs) = extract_info_sync(config, url, debug=True)
assert isinstance(result, dict), "Result should be a dictionary"
assert isinstance(logs, list), "Logs should be a list"
class TestCheckId:
"""Test the check_id function."""
@ -1639,7 +1516,7 @@ class TestReadLogfile:
"""Test reading non-existent log file."""
async def test():
result = await read_logfile(self.log_file)
result = await _read_logfile(self.log_file)
assert isinstance(result, dict)
assert "logs" in result
@ -1650,7 +1527,7 @@ class TestReadLogfile:
self.log_file.write_text("line 1\nline 2\nline 3\n")
async def test():
result = await read_logfile(self.log_file, limit=2)
result = await _read_logfile(self.log_file, limit=2)
assert isinstance(result, dict)
assert "logs" in result
@ -1681,7 +1558,7 @@ class TestTailLog:
async def test():
try:
await tail_log(self.log_file, emitter, sleep_time=0.1)
await _tail_log(self.log_file, emitter, sleep_time=0.1)
except Exception:
pass # Expected for non-existent file
@ -1715,26 +1592,6 @@ class TestLoadCookies:
assert True
class TestGetArchiveId:
"""Test the get_archive_id function."""
@patch("app.library.Utils.YTDLP_INFO_CLS")
def test_get_archive_id_basic(self, mock_ytdlp):
"""Test basic archive ID extraction."""
mock_ytdlp._ies = {}
result = get_archive_id("https://youtube.com/watch?v=test123")
assert isinstance(result, dict)
assert "id" in result
assert "ie_key" in result
assert "archive_id" in result
def test_get_archive_id_invalid_url(self):
"""Test with invalid URL."""
result = get_archive_id("invalid-url")
assert isinstance(result, dict)
class TestStrToDt:
"""Test the str_to_dt function."""
@ -1756,31 +1613,6 @@ class TestStrToDt:
assert True
class TestYtdlpReject:
"""Test the ytdlp_reject function."""
def test_ytdlp_reject_basic(self):
"""Test basic rejection logic."""
entry = {"title": "Test Video", "view_count": 1000}
yt_params = {}
passed, message = ytdlp_reject(entry, yt_params)
assert isinstance(passed, bool)
assert isinstance(message, str)
def test_ytdlp_reject_with_filters(self):
"""Test rejection with filters."""
entry = {"title": "Test Video", "upload_date": "20230101"}
yt_params = {"daterange": MagicMock()}
# Mock daterange to simulate rejection
yt_params["daterange"].__contains__ = MagicMock(return_value=False)
passed, message = ytdlp_reject(entry, yt_params)
assert isinstance(passed, bool)
assert isinstance(message, str)
class TestInitClass:
"""Test the init_class function."""
@ -2471,382 +2303,3 @@ class TestMoveFile:
# Original file should still exist
assert test_file.exists()
class TestGetThumbnail:
def test_returns_none_for_empty_list(self):
"""Test that None is returned for an empty thumbnail list."""
from app.library.Utils import get_thumbnail
assert get_thumbnail([]) is None
def test_returns_none_for_non_list(self):
"""Test that None is returned for non-list input."""
from app.library.Utils import get_thumbnail
assert get_thumbnail(None) is None
assert get_thumbnail("not a list") is None
assert get_thumbnail({"not": "list"}) is None
def test_returns_highest_preference_thumbnail(self):
"""Test that the thumbnail with highest preference is returned."""
from app.library.Utils import get_thumbnail
thumbnails = [
{"url": "low.jpg", "preference": 1, "width": 100, "height": 100},
{"url": "high.jpg", "preference": 10, "width": 200, "height": 200},
{"url": "medium.jpg", "preference": 5, "width": 150, "height": 150},
]
result = get_thumbnail(thumbnails)
assert result == {"url": "high.jpg", "preference": 10, "width": 200, "height": 200}
def test_returns_highest_width_when_preference_equal(self):
"""Test that the thumbnail with highest width is returned when preference is equal."""
from app.library.Utils import get_thumbnail
thumbnails = [
{"url": "small.jpg", "preference": 1, "width": 100, "height": 100},
{"url": "large.jpg", "preference": 1, "width": 200, "height": 200},
{"url": "medium.jpg", "preference": 1, "width": 150, "height": 150},
]
result = get_thumbnail(thumbnails)
assert result == {"url": "large.jpg", "preference": 1, "width": 200, "height": 200}
def test_handles_missing_attributes(self):
"""Test that thumbnails with missing attributes are handled correctly."""
from app.library.Utils import get_thumbnail
thumbnails = [
{"url": "no_pref.jpg", "width": 100},
{"url": "with_pref.jpg", "preference": 5, "width": 50},
]
result = get_thumbnail(thumbnails)
assert result["url"] == "with_pref.jpg"
def test_returns_first_when_all_equal(self):
"""Test that any thumbnail is returned when all attributes are equal."""
from app.library.Utils import get_thumbnail
thumbnails = [
{"url": "first.jpg"},
{"url": "second.jpg"},
]
result = get_thumbnail(thumbnails)
assert result is not None
assert result["url"] in ["first.jpg", "second.jpg"]
class TestGetExtras:
def test_returns_empty_dict_for_none(self):
"""Test that empty dict is returned for None input."""
from app.library.Utils import get_extras
assert get_extras(None) == {}
def test_returns_empty_dict_for_non_dict(self):
"""Test that empty dict is returned for non-dict input."""
from app.library.Utils import get_extras
assert get_extras("not a dict") == {}
assert get_extras([]) == {}
def test_extracts_video_information(self):
"""Test extracting information from a video entry."""
from app.library.Utils import get_extras
entry = {
"id": "test123",
"title": "Test Video",
"uploader": "Test Uploader",
"channel": "Test Channel",
"thumbnails": [{"url": "thumb.jpg", "preference": 1}],
"duration": 120,
}
result = get_extras(entry, kind="video")
assert result["uploader"] == "Test Uploader"
assert result["channel"] == "Test Channel"
assert result["thumbnail"] == "thumb.jpg"
assert result["duration"] == 120
assert result["is_premiere"] is False
def test_extracts_playlist_information(self):
"""Test extracting information from a playlist entry."""
from app.library.Utils import get_extras
entry = {
"id": "playlist123",
"title": "Test Playlist",
"uploader": "Playlist Owner",
"uploader_id": "owner123",
}
result = get_extras(entry, kind="playlist")
assert result["playlist_id"] == "playlist123"
assert result["playlist_title"] == "Test Playlist"
assert result["playlist_uploader"] == "Playlist Owner"
assert result["playlist_uploader_id"] == "owner123"
def test_handles_release_timestamp(self):
"""Test handling of release_timestamp for upcoming content."""
from app.library.Utils import get_extras
entry = {
"release_timestamp": 1234567890,
}
result = get_extras(entry)
assert "release_in" in result
assert result["release_in"] == "Fri, 13 Feb 2009 23:31:30 GMT"
def test_handles_upcoming_live_stream(self):
"""Test handling of upcoming live stream."""
from app.library.Utils import get_extras
entry = {
"release_timestamp": 1234567890,
"live_status": "is_upcoming",
}
result = get_extras(entry)
assert result["is_live"] == 1234567890
assert "release_in" in result
def test_handles_premiere_flag(self):
"""Test handling of is_premiere flag."""
from app.library.Utils import get_extras
entry = {
"is_premiere": True,
}
result = get_extras(entry)
assert result["is_premiere"] is True
entry2 = {"is_premiere": False}
result2 = get_extras(entry2)
assert result2["is_premiere"] is False
def test_youtube_fallback_thumbnail(self):
"""Test fallback thumbnail generation for YouTube videos."""
from app.library.Utils import get_extras
entry = {
"id": "dQw4w9WgXcQ",
"ie_key": "Youtube",
}
result = get_extras(entry)
assert result["thumbnail"] == "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"
def test_thumbnail_string_fallback(self):
"""Test fallback to thumbnail string when thumbnails list not available."""
from app.library.Utils import get_extras
entry = {
"thumbnail": "https://example.com/thumb.jpg",
}
result = get_extras(entry)
assert result["thumbnail"] == "https://example.com/thumb.jpg"
class TestGetStaticYtdlp:
"""Test the get_static_ytdlp function."""
def setup_method(self):
"""Reset YTDLP singleton state before each test."""
import app.library.Utils as Utils
Utils.YTDLP_INFO_CLS = None
def test_get_static_ytdlp_returns_instance(self):
"""Test that get_static_ytdlp returns a YTDLP instance."""
from app.library.ytdlp import YTDLP
# Get the cached instance
instance = get_ytdlp()
assert instance is not None
assert isinstance(instance, YTDLP)
def test_get_static_ytdlp_returns_same_instance(self):
"""Test that get_static_ytdlp returns the same cached instance."""
instance1 = get_ytdlp()
instance2 = get_ytdlp()
assert instance1 is instance2
def test_get_static_ytdlp_with_params(self):
"""Test that get_static_ytdlp returns a new instance when params are provided."""
instance1 = get_ytdlp()
instance2 = get_ytdlp(params={"quiet": False})
assert instance1 is not instance2
assert instance2 is not None
def test_get_static_ytdlp_has_correct_params(self):
"""Test that get_static_ytdlp initializes with correct parameters."""
instance = get_ytdlp()
# Access the internal params
params = instance.params
assert params.get("color") == "no_color"
assert params.get("extract_flat") is True
assert params.get("skip_download") is True
assert params.get("ignoreerrors") is True
assert params.get("ignore_no_formats_error") is True
assert params.get("quiet") is True
class TestParseOuttmpl:
"""Test the parse_outtmpl function."""
def setup_method(self):
"""Reset YTDLP singleton state before each test."""
import app.library.Utils as Utils
Utils.YTDLP_INFO_CLS = None
def test_parse_outtmpl_basic(self):
"""Test basic template parsing with simple placeholders."""
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video.mp4"
def test_parse_outtmpl_with_id(self):
"""Test template parsing with video ID."""
template = "[%(id)s] %(title)s.%(ext)s"
info_dict = {
"id": "dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "[dQw4w9WgXcQ] Never Gonna Give You Up.webm"
def test_parse_outtmpl_with_uploader(self):
"""Test template parsing with uploader information."""
template = "%(uploader)s - %(title)s.%(ext)s"
info_dict = {
"uploader": "Rick Astley",
"title": "Never Gonna Give You Up",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Rick Astley - Never Gonna Give You Up.mp4"
def test_parse_outtmpl_with_nested_path(self):
"""Test template parsing with nested directory structure."""
template = "%(uploader)s/%(title)s.%(ext)s"
info_dict = {
"uploader": "Test Channel",
"title": "Test Video",
"ext": "mkv",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Test Video.mkv"
def test_parse_outtmpl_with_missing_field(self):
"""Test template parsing with missing field defaults to NA."""
template = "%(title)s - %(upload_date)s.%(ext)s"
info_dict = {
"title": "Test Video",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Video - NA.mp4", "Missing field upload_date should default to NA"
def test_parse_outtmpl_complex(self):
"""Test complex template with multiple fields."""
template = "%(uploader)s/%(playlist_title)s/%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"
info_dict = {
"uploader": "Test Channel",
"playlist_title": "Best Videos",
"playlist_index": 5,
"title": "Amazing Content",
"id": "abc123xyz",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert result == "Test Channel/Best Videos/005 - Amazing Content [abc123xyz].mp4"
def test_parse_outtmpl_with_special_characters(self):
"""Test template parsing handles special characters in values."""
template = "%(title)s.%(ext)s"
info_dict = {
"title": "Test: Video / With \\ Special | Characters",
"ext": "mp4",
}
result = parse_outtmpl(template, info_dict)
assert ".mp4" in result, "yt-dlp should sanitize special characters but preserve extension"
assert "Test" in result, "yt-dlp should preserve safe parts of title"
def test_parse_outtmpl_with_playlist_info(self):
"""Test template parsing with playlist information."""
template = "%(playlist)s/%(title)s.%(ext)s"
info_dict = {
"playlist": "My Playlist",
"title": "Video Title",
"ext": "webm",
}
result = parse_outtmpl(template, info_dict)
assert result == "My Playlist/Video Title.webm"
def test_parse_outtmpl_with_restrict_filename(self):
"""Test template parsing with restrict_filename parameter."""
template = "%(uploader)s/%(title)s.%(ext)s"
info_dict = {
"uploader": "Foobar's Workshop",
"title": "Test Video",
"ext": "mp4",
}
result_unrestricted: str = parse_outtmpl(template, info_dict)
assert result_unrestricted == "Foobar's Workshop/Test Video.mp4"
result_restricted: str = parse_outtmpl(template, info_dict, params={"restrictfilenames": True})
assert result_restricted == "Foobar_s_Workshop/Test_Video.mp4"

View file

@ -135,10 +135,20 @@
<div>
<NuxtLoadingIndicator />
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<Message v-if="!config.is_loaded" class="is-info mt-5" title="Loading Configuration"
icon="fas fa-spinner fa-spin">
<p>This usually takes less than a second. If this is taking too long,
<NuxtLink class="button is-text p-0" @click="config.loadConfig">reload configuration</NuxtLink>.
<Message v-if="!config.is_loaded" class="mt-5"
:class="{ 'is-info': config.is_loading, 'is-danger': !config.is_loading }"
:title="config.is_loading ? 'Loading configuration...' : 'Failed to load configuration'"
:icon="config.is_loading ? 'fas fa-spinner fa-spin' : 'fas fa-triangle-exclamation'">
<p v-if="config.is_loading">
This usually takes less than a few seconds. If this is taking too long,
<NuxtLink class="button is-text p-0" @click="reloadPage">click here</NuxtLink> to reload the
page.
</p>
<p v-if="!config.is_loading">
Failed to load the application configuration. This likely indicates a problem with the backend. Try to
<NuxtLink class="button is-text p-0" @click="() => config.loadConfig(true)">reload
configuration</NuxtLink> or <NuxtLink class="button is-text p-0" @click="reloadPage">reload the page
</NuxtLink>.
</p>
<template v-if="socket.error">
<hr>

View file

@ -5,6 +5,9 @@ import type { Preset } from '~/types/presets';
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
import { request } from '~/utils';
let last_reload = 0;
const CONFIG_TTL = 10;
export const useConfigStore = defineStore('config', () => {
const state = reactive<ConfigState>({
showForm: useStorage('showForm', true),
@ -54,14 +57,23 @@ export const useConfigStore = defineStore('config', () => {
is_loading: false,
});
const loadConfig = async () => {
const loadConfig = async (force: boolean = false) => {
if (state.is_loading) {
return;
}
const now = Date.now()
if (state.is_loaded && !force && last_reload > 0) {
const age = (now - last_reload) / 1000
if (age < CONFIG_TTL) {
return
}
}
state.is_loaded = false;
state.is_loading = true;
try {
const resp = await request('/api/system/configuration');
const resp = await request('/api/system/configuration', { timeout: 10 });
if (!resp.ok) {
return;
}
@ -79,11 +91,12 @@ export const useConfigStore = defineStore('config', () => {
}
setAll(data);
state.is_loaded = true;
last_reload = now;
} catch (e: any) {
console.error('Failed to load configuration', e);
console.error(`Failed to load configuration: ${e}`);
}
finally {
state.is_loaded = true;
state.is_loading = false;
}
}

View file

@ -1,6 +1,5 @@
import { ref, readonly } from 'vue'
import { defineStore } from 'pinia'
import type { ConfigState } from "~/types/config";
import type { StoreItem } from "~/types/store";
import type {
ConfigUpdatePayload,
@ -263,36 +262,11 @@ export const useSocketStore = defineStore('socket', () => {
setupVisibilityListener()
}
on('configuration', (data: WSEP['configuration']) => {
config.setAll({
app: data.data.config,
presets: data.data.presets,
dl_fields: data.data.dl_fields,
paused: Boolean(data.data.paused)
} as unknown as Partial<ConfigState>)
})
on('connected', (data: WSEP['connected']) => {
if (!data?.data) {
return
}
if (data.data.folders) {
config.add('folders', data.data.folders)
}
if ('number' === typeof data.data.history_count) {
stateStore.setHistoryCount(data.data.history_count)
}
on('connect', () => config.loadConfig(false))
on('connected', () => {
error.value = null
})
on('active_queue', (data: WSEP['active_queue']) => {
if (!data.data.queue) {
return
}
stateStore.addAll('queue', data.data.queue || {})
config.loadConfig(false)
})
on('item_added', (data: WSEP['item_added']) => {

View file

@ -1,4 +1,3 @@
import type { AppConfig, ConfigState } from "./config"
import type { StoreItem } from "./store"
export type Event = {
@ -21,20 +20,7 @@ export type WSEP = {
connect: null
disconnect: null
connect_error: { message?: string }
configuration: EventPayload<{
config: AppConfig
presets: ConfigState['presets']
dl_fields: ConfigState['dl_fields']
paused: boolean
}>
connected: EventPayload<{
folders?: string[]
history_count?: number
queue?: Record<string, StoreItem>
}>
active_queue: EventPayload<{
queue?: Record<string, StoreItem>
}>
connected: EventPayload<{ sid: string }>
item_added: EventPayload<StoreItem>
item_updated: EventPayload<StoreItem>
item_cancelled: EventPayload<StoreItem>

View file

@ -228,25 +228,39 @@ const encodePath = (item: string): string => {
* @param options - Optional fetch options, automatically extended with common headers and credentials.
* @returns The fetch Response promise.
*/
const request = (url: string, options: RequestInit = {}): Promise<Response> => {
options.method = options.method || 'GET'
options.headers = options.headers || {}; (options as any).withCredentials = true
const request = (url: string, options: RequestInit & { timeout?: number } = {}): Promise<Response> => {
const { timeout, ...fetchOptions } = options
if (undefined === (options.headers as Record<string, any>)['Content-Type']) {
fetchOptions.method = fetchOptions.method || 'GET'
fetchOptions.headers = fetchOptions.headers || {};
(fetchOptions as any).withCredentials = true
if (undefined === (fetchOptions.headers as Record<string, any>)['Content-Type']) {
if (!(options?.body instanceof FormData)) {
; (options.headers as Record<string, any>)['Content-Type'] = 'application/json'
; (fetchOptions.headers as Record<string, any>)['Content-Type'] = 'application/json'
}
}
if (undefined === (options.headers as Record<string, any>)['Accept']) {
; (options.headers as Record<string, any>)['Accept'] = 'application/json'
if (undefined === (fetchOptions.headers as Record<string, any>)['Accept']) {
; (fetchOptions.headers as Record<string, any>)['Accept'] = 'application/json'
}
if (url.startsWith('/')) {
options.credentials = 'same-origin'
fetchOptions.credentials = 'same-origin'
}
return fetch(url.startsWith('/') ? uri(url) : url, options)
let controller: AbortController | undefined
let timer: ReturnType<typeof setTimeout> | undefined
if (typeof timeout === 'number' && timeout > 0) {
controller = new AbortController()
fetchOptions.signal = controller.signal
timer = setTimeout(() => controller!.abort(`Request timed out.`), timeout*1000)
}
return fetch(url.startsWith('/') ? uri(url) : url, fetchOptions).finally(() => {
if (timer) { clearTimeout(timer) }
})
}
/**
@ -879,10 +893,10 @@ const parse_api_error = async (json: unknown): Promise<string> => {
}
if (payload.error) {
return String(payload.error+(extra_detail ? ` - ${extra_detail}` : ''))
return String(payload.error + (extra_detail ? ` - ${extra_detail}` : ''))
}
if (payload.message) {
return String(payload.message+(extra_detail ? ` - ${extra_detail}` : ''))
return String(payload.message + (extra_detail ? ` - ${extra_detail}` : ''))
}
if (extra_detail) {