diff --git a/API.md b/API.md index 6ec10e00..302013ec 100644 --- a/API.md +++ b/API.md @@ -18,6 +18,7 @@ This document describes the available endpoints and their usage. All endpoints r - [Endpoints](#endpoints) - [GET /api/ping](#get-apiping) - [POST /api/yt-dlp/convert](#post-apiyt-dlpconvert) + - [POST /api/yt-dlp/command/](#post-apiyt-dlpcommand) - [GET /api/yt-dlp/url/info](#get-apiyt-dlpurlinfo) - [GET /api/history/add](#get-apihistoryadd) - [POST /api/history](#post-apihistory) @@ -209,6 +210,45 @@ or an error: --- +### POST /api/yt-dlp/command/ +**Purpose**: Build a complete yt-dlp CLI command string with priority-based argument merging from user input, presets, and defaults. + +**Requires**: Console must be enabled (`YTP_CONSOLE_ENABLED=true` env). + +**Body**: JSON object: +```json +{ + "url": "https://example.com/video",// required - item url + "preset": "preset_name", // optional - preset name to apply + "folder": "subfolder", // optional - output folder (relative to download_path) + "template": "%(title)s.%(ext)s", // optional - output filename template + "cli": "--write-sub --embed-subs", // optional - additional yt-dlp CLI arguments + "cookies": "cookie_string" // optional - authentication cookies as string +} +``` + +If cookies are given, they will be stored in a temporary file and the appropriate `--cookies ` argument +will be added to the command. + +**Priority System** (User > Preset > Default): +1. **User fields** take highest priority (from request body) +2. **Preset fields** used only if user didn't provide them +3. **Default fields** used as final fallback (from configuration) + +**Response**: +```json +{ + "command": "--output-path /downloads/subfolder --output %(title)s.%(ext)s --write-sub --embed-subs https://example.com/video" +} +``` + +**Error Responses**: +- `403 Forbidden` if console is disabled +- `400 Bad Request` if body is invalid JSON or Item format validation fails +- `400 Bad Request` if CLI command building fails + +--- + ### GET /api/yt-dlp/url/info **Purpose**: Retrieves metadata (info) for a provided URL without adding it to the download queue. diff --git a/app/library/Download.py b/app/library/Download.py index 22da3829..a69a1a97 100644 --- a/app/library/Download.py +++ b/app/library/Download.py @@ -17,7 +17,7 @@ from .config import Config from .Events import EventBus, Events from .ffprobe import ffprobe from .ItemDTO import ItemDTO -from .Utils import delete_dir, extract_info, extract_ytdlp_logs, load_cookies +from .Utils import create_cookies_file, delete_dir, extract_info, extract_ytdlp_logs from .ytdlp import YTDLP @@ -138,10 +138,11 @@ class Download: "status": data.get("status"), "filename": data.get("filename"), "info_dict": { - k: v for k, v in data.get("info_dict", {}).items() + k: v + for k, v in data.get("info_dict", {}).items() if k not in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"] and not isinstance(v, (type(None).__bases__[0], type(lambda: None))) # Skip non-serializable - } + }, } self.logger.debug(f"PG Hook: {d_safe}") except Exception as e: @@ -162,10 +163,11 @@ class Download: "postprocessor": data.get("postprocessor"), "status": data.get("status"), "info_dict": { - k: v for k, v in data.get("info_dict", {}).items() + k: v + for k, v in data.get("info_dict", {}).items() if k not in ["formats", "thumbnails", "description", "tags", "_format_sort_fields"] and not isinstance(v, (type(None).__bases__[0], type(lambda: None))) # Skip non-serializable - } + }, } self.logger.debug(f"PP Hook: {d_safe}") except Exception as e: @@ -238,10 +240,7 @@ class Download: self.logger.debug( f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'." ) - cookie_file.write_text(self.info.cookies) - params["cookiefile"] = str(cookie_file.as_posix()) - - load_cookies(cookie_file) + params["cookiefile"] = str(create_cookies_file(self.info.cookies, cookie_file).as_posix()) except Exception as e: err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'." self.logger.error(err_msg) @@ -536,8 +535,7 @@ class Download: # If still alive, use hard kill if self.proc.is_alive(): self.logger.warning( - f"Process PID={self.proc.pid} did not respond to terminate(), " - f"killing forcefully." + f"Process PID={self.proc.pid} did not respond to terminate(), killing forcefully." ) self.proc.kill() # Give it final moment diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 274a247e..0cd7c1fb 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -29,10 +29,10 @@ from .Utils import ( archive_add, arg_converter, calc_download_path, + create_cookies_file, dt_delta, extract_info, extract_ytdlp_logs, - load_cookies, merge_dict, str_to_dt, ytdlp_reject, @@ -731,9 +731,7 @@ class DownloadQueue(metaclass=Singleton): if item.cookies: try: - cookie_file.write_text(item.cookies) - yt_conf["cookiefile"] = str(cookie_file.as_posix()) - load_cookies(cookie_file) + yt_conf["cookiefile"] = str(create_cookies_file(item.cookies, cookie_file).as_posix()) except Exception as e: msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'." LOG.error(msg) diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index d638d45c..0ff99fa4 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -5,7 +5,7 @@ import uuid from dataclasses import dataclass, field from email.utils import formatdate from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from app.library.encoder import Encoder from app.library.Utils import ( @@ -19,6 +19,9 @@ from app.library.Utils import ( ) from app.library.YTDLPOpts import YTDLPOpts +if TYPE_CHECKING: + from app.library.Presets import Preset + LOG: logging.Logger = logging.getLogger("ItemDTO") @@ -207,6 +210,18 @@ class Item: return Item(**data) + def get_preset(self) -> "Preset": + """ + Get the preset for the item. + + Returns: + Preset: The preset for the item. If not found, None. + + """ + from .Presets import Presets + + return Presets.get_instance().get(self.preset if self.preset else self._default_preset()) + def get_archive_id(self) -> str | None: """ Get the archive ID for the download URL. diff --git a/app/library/Presets.py b/app/library/Presets.py index 22477f74..e53f5562 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -10,7 +10,7 @@ from aiohttp import web from .config import Config from .Events import EventBus, Events from .Singleton import Singleton -from .Utils import arg_converter, init_class +from .Utils import arg_converter, create_cookies_file, init_class LOG = logging.getLogger("presets") @@ -103,8 +103,11 @@ class Preset: default: bool = False """If True, the preset is a default preset.""" + _cookies_file: Path | None = field(init=False, default=None) + """The path to the cookies file.""" + def serialize(self) -> dict: - return self.__dict__ + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} def json(self) -> str: from .encoder import Encoder @@ -112,7 +115,31 @@ class Preset: return Encoder().encode(self.serialize()) def get(self, key: str, default: Any = None) -> Any: - return self.serialize().get(key, default) + return self.__dict__.get(key, default) + + def get_cookies_file(self, config: Config | None = None) -> Path | None: + """ + Get the path to the cookies file. + + Args: + config (Config|None): The config instance. + + Returns: + Path|None: The path to the cookies file. + + """ + if self._cookies_file: + return self._cookies_file + + if not self.cookies or not self.id: + return None + + if not config: + config = Config.get_instance() + + self._cookies_file = create_cookies_file(self.cookies, Path(config.config_path) / "cookies" / f"{self.id}.txt") + + return self._cookies_file class Presets(metaclass=Singleton): diff --git a/app/library/Utils.py b/app/library/Utils.py index 3781878e..a09095db 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -21,7 +21,7 @@ from yt_dlp.utils import age_restricted from .LogWrapper import LogWrapper from .mini_filter import match_str -from .ytdlp import YTDLP +from .ytdlp import YTDLP, make_archive_id LOG: logging.Logger = logging.getLogger("Utils") @@ -1242,21 +1242,21 @@ def get_archive_id(url: str) -> dict[str, str | None]: } ) - for key, ie in YTDLP_INFO_CLS._ies.items(): + for key, _ie in YTDLP_INFO_CLS._ies.items(): try: - if not ie.suitable(url): + if not _ie.suitable(url): continue - if not ie.working(): - break + if not _ie.working(): + continue - temp_id = ie.get_temp_id(url) + temp_id = _ie.get_temp_id(url) if not temp_id: - break + continue idDict["id"] = temp_id idDict["ie_key"] = key - idDict["archive_id"] = YTDLP_INFO_CLS._make_archive_id(idDict) + idDict["archive_id"] = make_archive_id(_ie, temp_id) break except Exception as e: LOG.exception(e) @@ -1634,3 +1634,28 @@ def get_channel_images(thumbnails: list[dict]) -> dict: artwork["poster"] = artwork["thumb"] # optional fallback return artwork + + +def create_cookies_file(cookies: str, file: Path | None = None) -> Path: + """ + Create a cookies file from a string of cookies. + + Args: + cookies (str): The cookie string. + file (Path|None): The path to the cookie file. If None, a temporary file is created. + + Returns: + Path: The path to the created cookie file. + + """ + if file is None: + from .config import Config + + file = Path(Config.get_instance().temp_path, f"c_{uuid.uuid4().hex}.txt") + + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(cookies) + + load_cookies(file) + + return file diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index 727c5c78..80ba5551 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -1,13 +1,208 @@ import logging +import shlex from pathlib import Path from .config import Config from .Presets import Preset, Presets -from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, load_cookies, merge_dict +from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, create_cookies_file, merge_dict LOG: logging.Logger = logging.getLogger("YTDLPOpts") +class ARGSMerger: + def __init__(self): + self.args: list[str] = [] + "Args merger" + + def add(self, args: str) -> "ARGSMerger": + """ + Add command options for yt-dlp. + + Args: + args (str): The command options for yt-dlp to add + + Returns: + YTDLPCli: The instance of the class + + """ + if not args or not isinstance(args, str) or len(args) < 2: + return self + + _args = shlex.split(args) + if len(_args) > 0: + self.args.extend(_args) + + return self + + def as_string(self) -> str: + """ + Get all the options as a string. + + Returns: + str: The options as a string + + """ + return str(self) + + def as_dict(self) -> dict: + """ + Get all the options as a dict. + + Returns: + dict: The options as a dict + + """ + return shlex.split(shlex.join(self.args)) + + def as_ytdlp(self) -> dict: + """ + Get all options as yt-dlp JSON options. + + Returns: + dict: The options as a dict for yt-dlp + + """ + return arg_converter(args=shlex.join(self.args), level=False) + + def __str__(self) -> str: + """ + Get all the options as a string. + + Returns: + str: The options as a string + + """ + return shlex.join(self.args) + + @staticmethod + def get_instance() -> "ARGSMerger": + """ + Get the instance of the class. + + Returns: + Presets: The instance of the class + + """ + return ARGSMerger() + + def reset(self) -> "ARGSMerger": + """ + Reset the command options. + + Returns: + YTDLPCli: The instance of the class + + """ + self.args = [] + + return self + + +class YTDLPCli: + """ + Build yt-dlp CLI command. + + Th logic is simple + 1. User provided fields have highest priority + 2. Preset provided fields have second priority + 3. Defaults have lowest priority + """ + + def __init__(self, item, config: Config | None = None): + """ + Initialize the CLI builder. + + Args: + item: Item request + config (Config|None): The Config instance (optional) + + """ + from .ItemDTO import Item + + if not isinstance(item, Item): + msg = f"Expected Item instance, got {type(item).__name__}" + raise ValueError(msg) + + self.item = item + self.preset = item.get_preset() + self._config = config or Config.get_instance() + + def build(self) -> tuple[str, dict]: + """ + Build the CLI command following make_command logic. + + Returns: + tuple[str, dict]: (command_string, info_dict) + + """ + template: str | None = None + save_path: str | None = None + cookie_file: Path | None = None + cli_args = ARGSMerger.get_instance() + + if self.item.cookies: + cookie_file = create_cookies_file(self.item.cookies) + + if self.item.folder: + save_path = str(Path(self._config.download_path) / self.item.folder.lstrip("/")) + + if self.item.template: + template = self.item.template + + if self.preset: + if self.preset.cookies and cookie_file is None: + cookie_file = self.preset.get_cookie_file(config=self._config) + + if self.preset.folder and save_path is None: + save_path = str(Path(self._config.download_path) / self.preset.folder.lstrip("/")) + + if self.preset.template and template is None: + template = self.preset.template + + if self.preset.cli: + cli_args.add(self.preset.cli) + + if self.item.cli: + cli_args.add(self.item.cli) + + if not save_path: + save_path = self._config.download_path + + if not template: + template = self._config.output_template + + if cookie_file: + cli_args.add(f'--cookies "{cookie_file!s}"') + + if template: + cli_args.add(f'--output "{template}"') + + if save_path: + cli_args.add(f'--paths "home:{save_path}"') + + cli_args.add(f'--paths "temp:{self._config.temp_path}"') + + if self.item.url: + cli_args.add(self.item.url) + + command = str(cli_args) + for k, v in self._config.get_replacers().items(): + command = command.replace(f"%({k})s", v if isinstance(v, str) else str(v)) + + info = { + "command": command, + "dict": cli_args.as_dict(), + "ytdlp": cli_args.as_ytdlp(), + "merged": { + "template": template, + "save_path": save_path, + "cookie_file": str(cookie_file) if cookie_file else None, + }, + } + + return command, info + + class YTDLPOpts: def __init__(self): self._config: Config = Config.get_instance() @@ -102,22 +297,16 @@ class YTDLPOpts: self._preset_opts = {} self._preset_cli = preset.cli except Exception as e: - msg = f"Invalid preset '{preset.name}' command options for yt-dlp. '{e!s}'." + msg: str = f"Invalid preset '{preset.name}' command options for yt-dlp. '{e!s}'." raise ValueError(msg) from e if preset.cookies: - file: Path = Path(self._config.config_path) / "cookies" / f"{preset.id}.txt" - - if not file.parent.exists(): - file.parent.mkdir(parents=True, exist_ok=True) - - file.write_text(preset.cookies) - try: - load_cookies(file) - self._preset_opts["cookiefile"] = str(file) + file: Path | None = preset.get_cookies_file(config=self._config) + if file and file.exists(): + self._preset_opts["cookiefile"] = str(file) except ValueError as e: - LOG.error(f"Failed to load '{preset.name}' cookies from '{file}'. {e!s}") + LOG.error(f"Failed to load '{preset.name}' cookies. {e!s}") if preset.template: self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter} diff --git a/app/library/ytdlp.py b/app/library/ytdlp.py index d0a49dd8..d52a97c8 100644 --- a/app/library/ytdlp.py +++ b/app/library/ytdlp.py @@ -1,8 +1,10 @@ +# flake8: noqa: F401 from typing import Any import yt_dlp +from yt_dlp.utils import make_archive_id -import app.postprocessors # noqa: F401 +import app.postprocessors class _ArchiveProxy: diff --git a/app/routes/api/notifications.py b/app/routes/api/notifications.py index b1604627..6174b62b 100644 --- a/app/routes/api/notifications.py +++ b/app/routes/api/notifications.py @@ -43,7 +43,6 @@ async def notification_add(request: Request, encoder: Encoder) -> Response: Args: request (Request): The request object. encoder (Encoder): The encoder instance. - notify (EventBus): The event bus instance. Returns: Response: The response object. diff --git a/app/routes/api/yt_dlp.py b/app/routes/api/yt_dlp.py index 873be860..3da5b60b 100644 --- a/app/routes/api/yt_dlp.py +++ b/app/routes/api/yt_dlp.py @@ -9,6 +9,7 @@ from aiohttp.web import Request, Response from app.library.cache import Cache from app.library.config import Config +from app.library.ItemDTO import Item from app.library.Presets import Presets from app.library.router import route from app.library.Utils import ( @@ -19,7 +20,7 @@ from app.library.Utils import ( get_archive_id, validate_url, ) -from app.library.YTDLPOpts import YTDLPOpts +from app.library.YTDLPOpts import YTDLPCli, YTDLPOpts LOG: logging.Logger = logging.getLogger(__name__) @@ -272,3 +273,43 @@ async def get_archive_ids(request: Request, config: Config) -> Response: 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) -> Response: + """ + Build yt-dlp CLI command. + + Args: + request (Request): The request object. + config (Config): The config 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, _ = 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, + ) + + return web.json_response(data={"command": command}, status=web.HTTPOk.status_code) diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index aeeda927..2897777a 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -1701,3 +1701,330 @@ class TestLoadModules: except Exception: # Module loading might fail in test environment assert True + + +class TestGetChannelImages: + """Test the get_channel_images function.""" + + def test_get_channel_images_poster_from_portrait(self): + """Test extracting poster image from portrait ratio thumbnail.""" + from app.library.Utils import get_channel_images + + thumbnails = [ + {"url": "http://example.com/poster.jpg", "width": 200, "height": 300, "id": "some_id"} + ] + + result = get_channel_images(thumbnails) + + assert "poster" in result + assert result["poster"] == "http://example.com/poster.jpg" + + def test_get_channel_images_thumb_from_square(self): + """Test extracting thumbnail from square ratio.""" + from app.library.Utils import get_channel_images + + thumbnails = [{"url": "http://example.com/thumb.jpg", "width": 300, "height": 300, "id": "id"}] + + result = get_channel_images(thumbnails) + + assert "thumb" in result + assert result["thumb"] == "http://example.com/thumb.jpg" + + def test_get_channel_images_banner_from_wide(self): + """Test extracting banner from very wide image.""" + from app.library.Utils import get_channel_images + + thumbnails = [{"url": "http://example.com/banner.jpg", "width": 1920, "height": 200, "id": "id"}] + + result = get_channel_images(thumbnails) + + assert "banner" in result + assert result["banner"] == "http://example.com/banner.jpg" + + def test_get_channel_images_icon_from_avatar(self): + """Test extracting icon from avatar uncropped.""" + from app.library.Utils import get_channel_images + + thumbnails = [{"url": "http://example.com/icon.jpg", "id": "avatar_uncropped"}] + + result = get_channel_images(thumbnails) + + assert "icon" in result + assert result["icon"] == "http://example.com/icon.jpg" + + def test_get_channel_images_landscape_from_banner_uncropped(self): + """Test extracting landscape from banner uncropped.""" + from app.library.Utils import get_channel_images + + thumbnails = [{"url": "http://example.com/landscape.jpg", "id": "banner_uncropped"}] + + result = get_channel_images(thumbnails) + + assert "landscape" in result + assert result["landscape"] == "http://example.com/landscape.jpg" + + def test_get_channel_images_empty_list(self): + """Test with empty thumbnail list.""" + from app.library.Utils import get_channel_images + + result = get_channel_images([]) + + assert result == {} + + def test_get_channel_images_fallbacks(self): + """Test fallback logic for missing images.""" + from app.library.Utils import get_channel_images + + # Create image with fanart but no banner + thumbnails = [{"url": "http://example.com/fanart.jpg", "width": 1920, "height": 200, "id": "id"}] + + result = get_channel_images(thumbnails) + + assert "fanart" in result + assert "banner" in result # Should fallback to fanart + assert result["banner"] == result["fanart"] + + def test_get_channel_images_no_url(self): + """Test handling of thumbnail without URL.""" + from app.library.Utils import get_channel_images + + thumbnails = [ + {"width": 300, "height": 300, "id": "id"}, # Missing URL + {"url": "http://example.com/thumb.jpg", "width": 300, "height": 300, "id": "id"}, + ] + + result = get_channel_images(thumbnails) + + assert len(result) > 0 + assert result.get("thumb") == "http://example.com/thumb.jpg" + + +class TestIsSafeKey: + """Test the _is_safe_key function (indirectly through merge_dict).""" + + def test_safe_key_normal_string(self): + """Test that normal strings are considered safe keys.""" + from app.library.Utils import merge_dict + + source = {"normal_key": "value"} + dest = {} + + result = merge_dict(source, dest) + + assert "normal_key" in result + assert result["normal_key"] == "value" + + def test_unsafe_key_dunder_attributes(self): + """Test that dunder attributes are blocked.""" + from app.library.Utils import merge_dict + + source = {"__class__": "should_not_merge", "normal_key": "value"} + dest = {} + + result = merge_dict(source, dest) + + assert "__class__" not in result + assert "normal_key" in result + + def test_unsafe_key_empty_string(self): + """Test that empty keys are blocked.""" + from app.library.Utils import merge_dict + + source = {"": "empty_key_value", "normal_key": "value"} + dest = {} + + result = merge_dict(source, dest) + + assert "" not in result + assert "normal_key" in result + + def test_unsafe_key_whitespace_only(self): + """Test that whitespace-only keys are blocked.""" + from app.library.Utils import merge_dict + + source = {" ": "whitespace_key", "normal_key": "value"} + dest = {} + + result = merge_dict(source, dest) + + assert " " not in result + assert "normal_key" in result + + def test_unsafe_key_non_string(self): + """Test that non-string keys are blocked.""" + from app.library.Utils import merge_dict + + source = {123: "numeric_key", "normal_key": "value"} + dest = {} + + result = merge_dict(source, dest) + + assert 123 not in result + assert "normal_key" in result + + +class TestDecryptDataCornerCases: + """Additional tests for decrypt_data edge cases.""" + + def test_decrypt_data_invalid_base64(self): + """Test decrypting invalid base64 data.""" + from app.library.Utils import decrypt_data + + result = decrypt_data("not_valid_base64!!!", b"k" * 32) + + assert result is None + + def test_decrypt_data_wrong_key(self): + """Test decrypting with wrong key returns None.""" + from app.library.Utils import decrypt_data, encrypt_data + + correct_key = b"a" * 32 + wrong_key = b"z" * 32 + + encrypted = encrypt_data("test data", correct_key) + result = decrypt_data(encrypted, wrong_key) + + # Should return None on decryption failure + assert result is None + + def test_decrypt_data_truncated(self): + """Test decrypting truncated encrypted data.""" + from app.library.Utils import decrypt_data + + result = decrypt_data("YQ==", b"k" * 32) + + assert result is None + + +class TestArgConverterAdvanced: + """Advanced tests for arg_converter function.""" + + def test_arg_converter_with_removed_options(self): + """Test arg_converter with removed options tracking.""" + try: + removed = [] + result = arg_converter("--quiet --skip-download", level=True, removed_options=removed) + + assert isinstance(result, dict) + except (ModuleNotFoundError, AttributeError, ImportError): + assert True + + def test_arg_converter_dumps_enabled(self): + """Test arg_converter with dumps flag enabled.""" + try: + result = arg_converter("--format best", dumps=True) + + # With dumps=True, result should still be a dict with JSON-serializable content + assert isinstance(result, (dict, list)) + except (ModuleNotFoundError, AttributeError, ImportError): + assert True + + +class TestCreateCookiesFile: + """Test the create_cookies_file function.""" + + def setup_method(self): + """Set up test environment.""" + self.temp_dir = tempfile.mkdtemp() + self.test_path = Path(self.temp_dir) + + def teardown_method(self): + """Clean up after tests.""" + import shutil + + shutil.rmtree(self.temp_dir, ignore_errors=True) + + @patch("app.library.Utils.load_cookies") + def test_create_cookies_file_with_path(self, mock_load_cookies): + """Test creating a cookies file with a specific path.""" + from app.library.Utils import create_cookies_file + + mock_load_cookies.return_value = (True, MagicMock()) + cookie_path = self.test_path / "cookies" / "test_cookies.txt" + + result = create_cookies_file("session=abc123", file=cookie_path) + + assert result == cookie_path + assert cookie_path.exists() + assert cookie_path.is_file() + assert cookie_path.read_text() == "session=abc123" + mock_load_cookies.assert_called_once_with(cookie_path) + + @patch("app.library.config.Config") + @patch("app.library.Utils.load_cookies") + def test_create_cookies_file_without_path(self, mock_load_cookies, mock_config): + """Test creating a cookies file without a specific path (auto temp file).""" + from app.library.Utils import create_cookies_file + + mock_config_inst = MagicMock() + mock_config_inst.temp_path = self.temp_dir + mock_config.get_instance.return_value = mock_config_inst + + mock_load_cookies.return_value = (True, MagicMock()) + + result = create_cookies_file("session=def456") + + assert result.exists() + assert result.is_file() + assert result.read_text() == "session=def456" + assert result.parent == Path(self.temp_dir) + mock_load_cookies.assert_called_once() + + @patch("app.library.Utils.load_cookies") + def test_create_cookies_file_invalid_cookies(self, mock_load_cookies): + """Test creating a cookies file with invalid cookies raises error.""" + from app.library.Utils import create_cookies_file + + mock_load_cookies.side_effect = ValueError("Invalid cookies") + cookie_path = self.test_path / "bad_cookies.txt" + + with pytest.raises(ValueError, match="Invalid cookies"): + create_cookies_file("invalid_data", file=cookie_path) + + @patch("app.library.Utils.load_cookies") + def test_create_cookies_file_creates_parent_directory(self, mock_load_cookies): + """Test that create_cookies_file creates parent directories as needed.""" + from app.library.Utils import create_cookies_file + + mock_load_cookies.return_value = (True, MagicMock()) + # Use a deeply nested path that doesn't exist yet + cookie_path = self.test_path / "a" / "b" / "c" / "cookies.txt" + + result = create_cookies_file("test_data", file=cookie_path) + + assert result == cookie_path + assert cookie_path.exists() + assert cookie_path.parent == Path(self.test_path / "a" / "b" / "c") + + @patch("app.library.Utils.load_cookies") + def test_create_cookies_file_with_special_characters(self, mock_load_cookies): + """Test create_cookies_file with special characters in cookie data.""" + from app.library.Utils import create_cookies_file + + mock_load_cookies.return_value = (True, MagicMock()) + cookie_data = "session=abc123; path=/; domain=.example.com; secure; httponly" + cookie_path = self.test_path / "special_cookies.txt" + + result = create_cookies_file(cookie_data, file=cookie_path) + + assert result == cookie_path + assert cookie_path.read_text() == cookie_data + + @patch("app.library.Utils.load_cookies") + def test_create_cookies_file_overwrites_existing(self, mock_load_cookies): + """Test that create_cookies_file overwrites existing files.""" + from app.library.Utils import create_cookies_file + + mock_load_cookies.return_value = (True, MagicMock()) + cookie_path = self.test_path / "cookies.txt" + + # Create initial file + cookie_path.parent.mkdir(parents=True, exist_ok=True) + cookie_path.write_text("old_data") + + # Overwrite with new data + result = create_cookies_file("new_data", file=cookie_path) + + assert result == cookie_path + assert cookie_path.read_text() == "new_data" + diff --git a/app/tests/test_ytdlpopts.py b/app/tests/test_ytdlpopts.py index 736f1a04..6abdcdbe 100644 --- a/app/tests/test_ytdlpopts.py +++ b/app/tests/test_ytdlpopts.py @@ -172,7 +172,6 @@ class TestYTDLPOpts: with ( patch("app.library.YTDLPOpts.Config") as mock_config, patch("app.library.YTDLPOpts.Presets") as mock_presets, - patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies, ): # Mock config mock_config_instance = Mock() @@ -188,23 +187,23 @@ class TestYTDLPOpts: mock_preset.template = None mock_preset.folder = None + # Mock cookie_file path + mock_cookie_path = Mock() + mock_cookie_path.exists.return_value = True + mock_cookie_path.__str__ = Mock(return_value="/test/config/cookies/cookie_preset.txt") + mock_preset.get_cookies_file.return_value = mock_cookie_path + mock_presets_instance = Mock() mock_presets_instance.get.return_value = mock_preset mock_presets.get_instance.return_value = mock_presets_instance - # Use real Path but mock file operations - with ( - patch("pathlib.Path.exists", return_value=False), - patch("pathlib.Path.mkdir"), - patch("pathlib.Path.write_text"), - ): - opts = YTDLPOpts() - opts.preset("cookie_preset") + opts = YTDLPOpts() + opts.preset("cookie_preset") - # Check that the cookie file would be created at the right path - expected_path = "/test/config/cookies/cookie_preset.txt" - assert opts._preset_opts["cookiefile"] == expected_path - mock_load_cookies.assert_called_once() + # Check that the cookie file would be created at the right path + expected_path = "/test/config/cookies/cookie_preset.txt" + assert opts._preset_opts["cookiefile"] == expected_path + mock_preset.get_cookies_file.assert_called_once_with(config=mock_config_instance) def test_preset_with_invalid_cli_raises_error(self): """Test that preset with invalid CLI raises ValueError.""" @@ -443,7 +442,6 @@ class TestYTDLPOpts: with ( patch("app.library.YTDLPOpts.Config") as mock_config, patch("app.library.YTDLPOpts.Presets") as mock_presets, - patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies, patch("app.library.YTDLPOpts.LOG") as mock_log, ): # Mock config @@ -460,24 +458,24 @@ class TestYTDLPOpts: mock_preset.template = None mock_preset.folder = None + # Mock cookie loading failure + mock_preset.get_cookies_file.side_effect = ValueError("Invalid cookies") + mock_presets_instance = Mock() mock_presets_instance.get.return_value = mock_preset mock_presets.get_instance.return_value = mock_presets_instance - # Mock cookie loading failure - mock_load_cookies.side_effect = ValueError("Invalid cookies") + opts = YTDLPOpts() + opts.preset("cookie_preset") - with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text"): - opts = YTDLPOpts() - opts.preset("cookie_preset") + # Should log error but not raise + mock_log.error.assert_called_once() + error_args = mock_log.error.call_args[0][0] + assert "Failed to load" in error_args + assert "Cookie Preset" in error_args - # Should log error but not raise - mock_log.error.assert_called_once() - error_args = mock_log.error.call_args[0][0] - assert "Failed to load 'Cookie Preset' cookies" in error_args - - # cookiefile should not be set - assert "cookiefile" not in opts._preset_opts + # cookiefile should not be set + assert "cookiefile" not in opts._preset_opts def test_replacer_substitution_in_cli(self): """Test that CLI arguments get replacer substitution.""" @@ -506,3 +504,480 @@ class TestYTDLPOpts: # Should replace %(home)s and %(user)s expected_cli = "--output /actual/home/testuser" mock_converter.assert_called_once_with(args=expected_cli, level=True) + + +class TestARGSMerger: + """Test the ARGSMerger class.""" + + def test_constructor_initializes_empty_args(self): + """Test that ARGSMerger constructor initializes with empty args list.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + + assert merger.args == [] + + def test_add_valid_args(self): + """Test adding valid command-line arguments.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + result = merger.add("--format best") + + assert result is merger # Returns self for chaining + assert merger.args == ["--format", "best"] + + def test_add_multiple_args_with_chaining(self): + """Test adding multiple arguments using method chaining.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add("--format best").add("--output test.mp4").add("--no-playlist") + + assert merger.args == ["--format", "best", "--output", "test.mp4", "--no-playlist"] + + def test_add_args_with_quotes(self): + """Test adding arguments containing quoted strings.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add('--output "%(title)s.%(ext)s"') + + assert merger.args == ["--output", "%(title)s.%(ext)s"] + + def test_add_complex_args(self): + """Test adding complex arguments with multiple values.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add("--format 'bestvideo[height<=1080]+bestaudio/best'") + + assert "--format" in merger.args + assert "bestvideo[height<=1080]+bestaudio/best" in merger.args + + def test_add_empty_string_returns_self(self): + """Test that adding empty string returns self without modifying args.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + result = merger.add("") + + assert result is merger + assert merger.args == [] + + 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 + + merger = ARGSMerger() + result = merger.add("a") + + assert result is merger + assert merger.args == [] + + def test_add_non_string_returns_self(self): + """Test that adding non-string returns self without modifying args.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + result = merger.add(123) # type: ignore + + assert result is merger + assert merger.args == [] + + def test_as_string(self): + """Test converting args to string.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add("--format best").add("--output test.mp4") + + result = merger.as_string() + + assert result == "--format best --output test.mp4" + + def test_str_method(self): + """Test __str__ method.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add("--format best").add("--output test.mp4") + + result = str(merger) + + assert result == "--format best --output test.mp4" + + def test_as_dict(self): + """Test converting args to dict.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add("--format best").add("--output test.mp4") + + result = merger.as_dict() + + assert isinstance(result, list) + assert result == ["--format", "best", "--output", "test.mp4"] + + def test_as_ytdlp(self): + """Test converting args to yt-dlp JSON options.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add("--format best") + + with patch("app.library.YTDLPOpts.arg_converter") as mock_converter: + mock_converter.return_value = {"format": "best"} + + result = merger.as_ytdlp() + + assert result == {"format": "best"} + mock_converter.assert_called_once_with(args="--format best", level=False) + + def test_get_instance(self): + """Test get_instance static method.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger.get_instance() + + assert isinstance(merger, ARGSMerger) + assert merger.args == [] + + def test_reset(self): + """Test reset method clears args.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add("--format best").add("--output test.mp4") + + assert len(merger.args) > 0 + + result = merger.reset() + + assert result is merger + assert merger.args == [] + + def test_args_with_special_characters(self): + """Test handling arguments with special characters.""" + from app.library.YTDLPOpts import ARGSMerger + + merger = ARGSMerger() + merger.add('--postprocessor-args "-movflags +faststart"') + + assert "--postprocessor-args" in merger.args + assert "-movflags +faststart" in merger.args + + +class TestYTDLPCli: + """Test the YTDLPCli class.""" + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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 + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_presets.get_instance.return_value.get.return_value = None + + item = Item(url="https://example.com/video") + cli = YTDLPCli(item=item) + + assert cli.item is item + assert cli._config is mock_config_instance + + def test_constructor_with_invalid_type_raises_error(self): + """Test YTDLPCli constructor raises error with non-Item type.""" + from app.library.YTDLPOpts import YTDLPCli + + with pytest.raises(ValueError, match="Expected Item instance"): + YTDLPCli(item="not an item") # type: ignore + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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 + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_presets.get_instance.return_value.get.return_value = None + + item = Item( + url="https://example.com/video", + folder="myfolder", + template="%(id)s.%(ext)s", + cli="--format best", + ) + + cli = YTDLPCli(item=item) + command, info = cli.build() + + assert isinstance(command, str) + assert isinstance(info, dict) + assert "command" in info + assert "dict" in info + assert "ytdlp" in info + assert "merged" in info + assert info["merged"]["template"] == "%(id)s.%(ext)s" + assert info["merged"]["save_path"] == "/downloads/myfolder" + assert "--format best" in command + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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.library.Presets import Preset + from app.library.YTDLPOpts import YTDLPCli + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + # Create a preset with fields + preset = Preset( + name="test_preset", + folder="preset_folder", + template="%(channel)s/%(title)s.%(ext)s", + cli="--format 720p", + ) + + mock_presets.get_instance.return_value.get.return_value = preset + + # Item without folder/template - should use preset's + item = Item(url="https://example.com/video", preset="test_preset") + + cli = YTDLPCli(item=item) + command, info = cli.build() + + # Should use preset values + assert info["merged"]["template"] == "%(channel)s/%(title)s.%(ext)s" + assert info["merged"]["save_path"] == "/downloads/preset_folder" + assert "--format 720p" in command + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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.library.Presets import Preset + from app.library.YTDLPOpts import YTDLPCli + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + preset = Preset( + name="test_preset", + folder="preset_folder", + template="%(channel)s/%(title)s.%(ext)s", + cli="--format 720p", + ) + + mock_presets.get_instance.return_value.get.return_value = preset + + # Item with user fields - should override preset + item = Item( + url="https://example.com/video", + preset="test_preset", + folder="user_folder", + template="%(id)s.%(ext)s", + cli="--format best", + ) + + cli = YTDLPCli(item=item) + command, info = cli.build() + + # Should use user values, not preset + assert info["merged"]["template"] == "%(id)s.%(ext)s" + assert info["merged"]["save_path"] == "/downloads/user_folder" + # User CLI should appear after preset CLI in command + assert "--format best" in command + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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 + + mock_config_instance = Mock() + mock_config_instance.download_path = "/default/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_presets.get_instance.return_value.get.return_value = None + + # Item with minimal fields + item = Item(url="https://example.com/video") + + cli = YTDLPCli(item=item) + _, info = cli.build() + + # Should use default values + assert info["merged"]["template"] == "%(title)s.%(ext)s" + assert info["merged"]["save_path"] == "/default/downloads" + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.create_cookies_file") + @patch("app.library.YTDLPOpts.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 + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_cookie_path = Mock() + mock_cookie_path.__str__ = Mock(return_value="/tmp/cookies.txt") + mock_create_cookies.return_value = mock_cookie_path + + mock_presets.get_instance.return_value.get.return_value = None + + item = Item(url="https://example.com/video", cookies="session_id=abc123") + + cli = YTDLPCli(item=item) + command, info = cli.build() + + mock_create_cookies.assert_called_once_with("session_id=abc123") + assert "--cookies" in command + assert "/tmp/cookies.txt" in command + assert info["merged"]["cookie_file"] == "/tmp/cookies.txt" + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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.library.Presets import Preset + from app.library.YTDLPOpts import YTDLPCli + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_cookie_path = Mock() + mock_cookie_path.__str__ = Mock(return_value="/preset/cookies.txt") + + preset = Mock(spec=Preset) + preset.name = "test_preset" + preset.cookies = "preset_cookies" + preset.get_cookie_file = Mock(return_value=mock_cookie_path) + preset.folder = None + preset.template = None + preset.cli = None + + mock_presets.get_instance.return_value.get.return_value = preset + + item = Item(url="https://example.com/video", preset="test_preset") + + cli = YTDLPCli(item=item) + command, _ = cli.build() + + preset.get_cookie_file.assert_called_once_with(config=mock_config_instance) + assert "--cookies" in command + assert "/preset/cookies.txt" in command + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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 + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_presets.get_instance.return_value.get.return_value = None + + # Absolute path gets leading slash stripped and joined with download_path + # This is the actual behavior: /absolute/path -> absolute/path -> /downloads/absolute/path + item = Item(url="https://example.com/video", folder="/absolute/path") + + cli = YTDLPCli(item=item) + command, info = cli.build() + + # The implementation strips leading slash and joins with download_path + assert info["merged"]["save_path"] == "/downloads/absolute/path" + assert "--paths" in command + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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 + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + mock_presets.get_instance.return_value.get.return_value = None + + item = Item(url="https://youtube.com/watch?v=test123") + + cli = YTDLPCli(item=item) + command, _ = cli.build() + + assert "https://youtube.com/watch?v=test123" in command + + @patch("app.library.Presets.Presets") + @patch("app.library.YTDLPOpts.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.library.Presets import Preset + from app.library.YTDLPOpts import YTDLPCli + + mock_config_instance = Mock() + mock_config_instance.download_path = "/downloads" + mock_config_instance.output_template = "%(title)s.%(ext)s" + mock_config_instance.get_replacers.return_value = {} + mock_config.get_instance.return_value = mock_config_instance + + preset = Preset(name="test_preset", cli="--format 720p --extract-audio") + + mock_presets.get_instance.return_value.get.return_value = preset + + item = Item(url="https://example.com/video", preset="test_preset", cli="--format best --no-playlist") + + cli = YTDLPCli(item=item) + _, info = cli.build() + + # User args should come after preset args in the command + # This ensures user args can override preset args + args_list = info["dict"] + preset_format_idx = args_list.index("720p") if "720p" in args_list else -1 + user_format_idx = args_list.index("best") if "best" in args_list else -1 + + # User's 'best' should appear after preset's '720p' + assert user_format_idx > preset_format_idx diff --git a/ui/app/components/Connection.vue b/ui/app/components/Connection.vue index 0e5ac902..2aa7587e 100644 --- a/ui/app/components/Connection.vue +++ b/ui/app/components/Connection.vue @@ -1,15 +1,16 @@