refactor: move archiver to be under yt_dlp submodule

This commit is contained in:
arabcoders 2026-01-27 20:04:54 +03:00
parent 2c5edc64f4
commit b1a05b1d79
14 changed files with 618 additions and 637 deletions

View file

@ -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):

View file

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

View file

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

View file

@ -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

View file

@ -1,5 +1,7 @@
import logging
from pathlib import Path
import re
import tempfile
from typing import Any
from unittest.mock import MagicMock, patch
@ -14,6 +16,9 @@ from app.features.ytdlp.utils import (
get_extras,
get_thumbnail,
get_ytdlp,
archive_delete,
archive_add,
archive_read,
)
@ -537,3 +542,43 @@ class TestGetArchiveId:
"""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 == []

View file

@ -6,6 +6,7 @@ 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
@ -506,3 +507,55 @@ def get_archive_id(url: str) -> dict[str, str | None]:
LOG.error(f"Error getting archive ID: {e}")
return idDict
def archive_add(file: str | Path, ids: list[str], skip_check: bool = False) -> bool:
"""
Add IDs to an archive file (delegates to the global Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to add.
skip_check (bool): If True, skip checking for existing IDs.
Returns:
bool: True if any new IDs were appended, False otherwise.
"""
from app.features.ytdlp.archiver import Archiver
return Archiver.get_instance().add(file, ids, skip_check)
def archive_read(file: str | Path, ids: list[str] | None = None) -> list[str]:
"""
Read IDs from an archive file with optional filtering (delegates to Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]|None): Optional list of IDs to query; None/empty returns all.
Returns:
list[str]: IDs present in the archive, optionally filtered.
"""
from app.features.ytdlp.archiver import Archiver
return Archiver.get_instance().read(file, ids)
def archive_delete(file: str | Path, ids: list[str]) -> bool:
"""
Delete IDs from an archive file (delegates to Archiver).
Args:
file (str|Path): The archive file path.
ids (list[str]): List of IDs to remove.
Returns:
bool: True on success (including no-op), False on error.
"""
from app.features.ytdlp.archiver import Archiver
return Archiver.get_instance().delete(file, ids)

View file

@ -24,7 +24,7 @@ class _ArchiveProxy:
return False
try:
from app.library.Archiver import Archiver
from app.features.ytdlp.archiver import Archiver
status: bool = item in Archiver.get_instance().read(self._file, [item])
return status
@ -36,7 +36,7 @@ class _ArchiveProxy:
return False
try:
from app.library.Archiver import Archiver
from app.features.ytdlp.archiver import Archiver
status: bool = Archiver.get_instance().add(self._file, [item])
return status

View file

@ -7,17 +7,10 @@ from email.utils import formatdate
from pathlib import Path
from typing import TYPE_CHECKING, Any
from app.features.ytdlp.utils import get_archive_id
from app.features.ytdlp.utils import archive_add, archive_delete, archive_read, get_archive_id
from app.features.ytdlp.ytdlp_opts import YTDLPOpts
from app.library.encoder import Encoder
from app.library.Utils import (
archive_add,
archive_delete,
archive_read,
clean_item,
get_file,
get_file_sidecar,
)
from app.library.Utils import clean_item, get_file, get_file_sidecar
if TYPE_CHECKING:
from app.features.presets.schemas import Preset

View file

@ -1293,58 +1293,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.

View file

@ -9,17 +9,12 @@ import yt_dlp.utils
from app.features.conditions.service import Conditions
from app.features.presets.service import Presets
from app.features.ytdlp.utils import arg_converter, get_extras, ytdlp_reject
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,
create_cookies_file,
merge_dict,
)
from app.library.Utils import create_cookies_file, merge_dict
from ...features.ytdlp.extractor import fetch_info
from .core import Download
from .playlist_processor import process_playlist
from .video_processor import add_video

View file

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

View file

@ -1,310 +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.features.ytdlp.extractor import fetch_info
from app.features.ytdlp.utils import 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 (
archive_read,
validate_url,
)
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"]
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)
import app.features.ytdlp.router # noqa: F401

View file

@ -13,9 +13,6 @@ import pytest
from app.features.ytdlp.utils import arg_converter
from app.library.Utils import (
FileLogFormatter,
archive_add,
archive_delete,
archive_read,
calc_download_path,
check_id,
clean_item,
@ -1264,46 +1261,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 TestCheckId:
"""Test the check_id function."""