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
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:
commit
d17aa6c29e
70 changed files with 2439 additions and 2595 deletions
24
API.md
24
API.md
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
0
app/features/streaming/__init__.py
Normal file
0
app/features/streaming/__init__.py
Normal 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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
279
app/features/streaming/router.py
Normal file
279
app/features/streaming/router.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
@ -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"):
|
||||
|
|
@ -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/")
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"
|
||||
6
app/features/streaming/types.py
Normal file
6
app/features/streaming/types.py
Normal 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."""
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
0
app/features/ytdlp/__init__.py
Normal file
0
app/features/ytdlp/__init__.py
Normal 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()
|
||||
507
app/features/ytdlp/router.py
Normal file
507
app/features/ytdlp/router.py
Normal 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)
|
||||
36
app/features/ytdlp/tests/test_ytdlp_extractor.py
Normal file
36
app/features/ytdlp/tests/test_ytdlp_extractor.py
Normal 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"
|
||||
|
|
@ -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):
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"
|
||||
584
app/features/ytdlp/tests/test_ytdlp_utils.py
Normal file
584
app/features/ytdlp/tests/test_ytdlp_utils.py
Normal 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
561
app/features/ytdlp/utils.py
Normal 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)
|
||||
|
|
@ -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]]:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']) => {
|
||||
|
|
|
|||
16
ui/app/types/sockets.d.ts
vendored
16
ui/app/types/sockets.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue