Merge pull request #468 from arabcoders/dev

[FEAT] Support running commands from newDownload into console.
This commit is contained in:
Abdulmohsen 2025-10-27 20:04:13 +03:00 committed by GitHub
commit 71153d6fb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1381 additions and 126 deletions

40
API.md
View file

@ -18,6 +18,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [Endpoints](#endpoints) - [Endpoints](#endpoints)
- [GET /api/ping](#get-apiping) - [GET /api/ping](#get-apiping)
- [POST /api/yt-dlp/convert](#post-apiyt-dlpconvert) - [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/yt-dlp/url/info](#get-apiyt-dlpurlinfo)
- [GET /api/history/add](#get-apihistoryadd) - [GET /api/history/add](#get-apihistoryadd)
- [POST /api/history](#post-apihistory) - [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 <file>` 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 ### GET /api/yt-dlp/url/info
**Purpose**: Retrieves metadata (info) for a provided URL without adding it to the download queue. **Purpose**: Retrieves metadata (info) for a provided URL without adding it to the download queue.

View file

@ -17,7 +17,7 @@ from .config import Config
from .Events import EventBus, Events from .Events import EventBus, Events
from .ffprobe import ffprobe from .ffprobe import ffprobe
from .ItemDTO import ItemDTO 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 from .ytdlp import YTDLP
@ -138,10 +138,11 @@ class Download:
"status": data.get("status"), "status": data.get("status"),
"filename": data.get("filename"), "filename": data.get("filename"),
"info_dict": { "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"] 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 and not isinstance(v, (type(None).__bases__[0], type(lambda: None))) # Skip non-serializable
} },
} }
self.logger.debug(f"PG Hook: {d_safe}") self.logger.debug(f"PG Hook: {d_safe}")
except Exception as e: except Exception as e:
@ -162,10 +163,11 @@ class Download:
"postprocessor": data.get("postprocessor"), "postprocessor": data.get("postprocessor"),
"status": data.get("status"), "status": data.get("status"),
"info_dict": { "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"] 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 and not isinstance(v, (type(None).__bases__[0], type(lambda: None))) # Skip non-serializable
} },
} }
self.logger.debug(f"PP Hook: {d_safe}") self.logger.debug(f"PP Hook: {d_safe}")
except Exception as e: except Exception as e:
@ -238,10 +240,7 @@ class Download:
self.logger.debug( self.logger.debug(
f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'." f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'."
) )
cookie_file.write_text(self.info.cookies) params["cookiefile"] = str(create_cookies_file(self.info.cookies, cookie_file).as_posix())
params["cookiefile"] = str(cookie_file.as_posix())
load_cookies(cookie_file)
except Exception as e: except Exception as e:
err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'." err_msg: str = f"Failed to create cookie file for '{self.info.id}: {self.info.title}'. '{e!s}'."
self.logger.error(err_msg) self.logger.error(err_msg)
@ -536,8 +535,7 @@ class Download:
# If still alive, use hard kill # If still alive, use hard kill
if self.proc.is_alive(): if self.proc.is_alive():
self.logger.warning( self.logger.warning(
f"Process PID={self.proc.pid} did not respond to terminate(), " f"Process PID={self.proc.pid} did not respond to terminate(), killing forcefully."
f"killing forcefully."
) )
self.proc.kill() self.proc.kill()
# Give it final moment # Give it final moment

View file

@ -29,10 +29,10 @@ from .Utils import (
archive_add, archive_add,
arg_converter, arg_converter,
calc_download_path, calc_download_path,
create_cookies_file,
dt_delta, dt_delta,
extract_info, extract_info,
extract_ytdlp_logs, extract_ytdlp_logs,
load_cookies,
merge_dict, merge_dict,
str_to_dt, str_to_dt,
ytdlp_reject, ytdlp_reject,
@ -731,9 +731,7 @@ class DownloadQueue(metaclass=Singleton):
if item.cookies: if item.cookies:
try: try:
cookie_file.write_text(item.cookies) yt_conf["cookiefile"] = str(create_cookies_file(item.cookies, cookie_file).as_posix())
yt_conf["cookiefile"] = str(cookie_file.as_posix())
load_cookies(cookie_file)
except Exception as e: except Exception as e:
msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'." msg = f"Failed to create cookie file for '{item.url}'. '{e!s}'."
LOG.error(msg) LOG.error(msg)

View file

@ -5,7 +5,7 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from email.utils import formatdate from email.utils import formatdate
from pathlib import Path from pathlib import Path
from typing import Any from typing import TYPE_CHECKING, Any
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Utils import ( from app.library.Utils import (
@ -19,6 +19,9 @@ from app.library.Utils import (
) )
from app.library.YTDLPOpts import YTDLPOpts from app.library.YTDLPOpts import YTDLPOpts
if TYPE_CHECKING:
from app.library.Presets import Preset
LOG: logging.Logger = logging.getLogger("ItemDTO") LOG: logging.Logger = logging.getLogger("ItemDTO")
@ -207,6 +210,18 @@ class Item:
return Item(**data) 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: def get_archive_id(self) -> str | None:
""" """
Get the archive ID for the download URL. Get the archive ID for the download URL.

View file

@ -10,7 +10,7 @@ from aiohttp import web
from .config import Config from .config import Config
from .Events import EventBus, Events from .Events import EventBus, Events
from .Singleton import Singleton 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") LOG = logging.getLogger("presets")
@ -103,8 +103,11 @@ class Preset:
default: bool = False default: bool = False
"""If True, the preset is a default preset.""" """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: 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: def json(self) -> str:
from .encoder import Encoder from .encoder import Encoder
@ -112,7 +115,31 @@ class Preset:
return Encoder().encode(self.serialize()) return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any: 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): class Presets(metaclass=Singleton):

View file

@ -21,7 +21,7 @@ from yt_dlp.utils import age_restricted
from .LogWrapper import LogWrapper from .LogWrapper import LogWrapper
from .mini_filter import match_str from .mini_filter import match_str
from .ytdlp import YTDLP from .ytdlp import YTDLP, make_archive_id
LOG: logging.Logger = logging.getLogger("Utils") 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: try:
if not ie.suitable(url): if not _ie.suitable(url):
continue continue
if not ie.working(): if not _ie.working():
break continue
temp_id = ie.get_temp_id(url) temp_id = _ie.get_temp_id(url)
if not temp_id: if not temp_id:
break continue
idDict["id"] = temp_id idDict["id"] = temp_id
idDict["ie_key"] = key idDict["ie_key"] = key
idDict["archive_id"] = YTDLP_INFO_CLS._make_archive_id(idDict) idDict["archive_id"] = make_archive_id(_ie, temp_id)
break break
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
@ -1634,3 +1634,28 @@ def get_channel_images(thumbnails: list[dict]) -> dict:
artwork["poster"] = artwork["thumb"] # optional fallback artwork["poster"] = artwork["thumb"] # optional fallback
return artwork 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

View file

@ -1,13 +1,208 @@
import logging import logging
import shlex
from pathlib import Path from pathlib import Path
from .config import Config from .config import Config
from .Presets import Preset, Presets 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") 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: class YTDLPOpts:
def __init__(self): def __init__(self):
self._config: Config = Config.get_instance() self._config: Config = Config.get_instance()
@ -102,22 +297,16 @@ class YTDLPOpts:
self._preset_opts = {} self._preset_opts = {}
self._preset_cli = preset.cli self._preset_cli = preset.cli
except Exception as e: 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 raise ValueError(msg) from e
if preset.cookies: 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: try:
load_cookies(file) file: Path | None = preset.get_cookies_file(config=self._config)
self._preset_opts["cookiefile"] = str(file) if file and file.exists():
self._preset_opts["cookiefile"] = str(file)
except ValueError as e: 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: if preset.template:
self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter} self._preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter}

View file

@ -1,8 +1,10 @@
# flake8: noqa: F401
from typing import Any from typing import Any
import yt_dlp import yt_dlp
from yt_dlp.utils import make_archive_id
import app.postprocessors # noqa: F401 import app.postprocessors
class _ArchiveProxy: class _ArchiveProxy:

View file

@ -43,7 +43,6 @@ async def notification_add(request: Request, encoder: Encoder) -> Response:
Args: Args:
request (Request): The request object. request (Request): The request object.
encoder (Encoder): The encoder instance. encoder (Encoder): The encoder instance.
notify (EventBus): The event bus instance.
Returns: Returns:
Response: The response object. Response: The response object.

View file

@ -9,6 +9,7 @@ from aiohttp.web import Request, Response
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.ItemDTO import Item
from app.library.Presets import Presets from app.library.Presets import Presets
from app.library.router import route from app.library.router import route
from app.library.Utils import ( from app.library.Utils import (
@ -19,7 +20,7 @@ from app.library.Utils import (
get_archive_id, get_archive_id,
validate_url, validate_url,
) )
from app.library.YTDLPOpts import YTDLPOpts from app.library.YTDLPOpts import YTDLPCli, YTDLPOpts
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
@ -272,3 +273,43 @@ async def get_archive_ids(request: Request, config: Config) -> Response:
response.append(dct) response.append(dct)
return web.json_response(data=response, status=web.HTTPOk.status_code) 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)

View file

@ -1701,3 +1701,330 @@ class TestLoadModules:
except Exception: except Exception:
# Module loading might fail in test environment # Module loading might fail in test environment
assert True 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"

View file

@ -172,7 +172,6 @@ class TestYTDLPOpts:
with ( with (
patch("app.library.YTDLPOpts.Config") as mock_config, patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.Presets") as mock_presets, patch("app.library.YTDLPOpts.Presets") as mock_presets,
patch("app.library.YTDLPOpts.load_cookies") as mock_load_cookies,
): ):
# Mock config # Mock config
mock_config_instance = Mock() mock_config_instance = Mock()
@ -188,23 +187,23 @@ class TestYTDLPOpts:
mock_preset.template = None mock_preset.template = None
mock_preset.folder = 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 = Mock()
mock_presets_instance.get.return_value = mock_preset mock_presets_instance.get.return_value = mock_preset
mock_presets.get_instance.return_value = mock_presets_instance mock_presets.get_instance.return_value = mock_presets_instance
# Use real Path but mock file operations opts = YTDLPOpts()
with ( opts.preset("cookie_preset")
patch("pathlib.Path.exists", return_value=False),
patch("pathlib.Path.mkdir"),
patch("pathlib.Path.write_text"),
):
opts = YTDLPOpts()
opts.preset("cookie_preset")
# Check that the cookie file would be created at the right path # Check that the cookie file would be created at the right path
expected_path = "/test/config/cookies/cookie_preset.txt" expected_path = "/test/config/cookies/cookie_preset.txt"
assert opts._preset_opts["cookiefile"] == expected_path assert opts._preset_opts["cookiefile"] == expected_path
mock_load_cookies.assert_called_once() mock_preset.get_cookies_file.assert_called_once_with(config=mock_config_instance)
def test_preset_with_invalid_cli_raises_error(self): def test_preset_with_invalid_cli_raises_error(self):
"""Test that preset with invalid CLI raises ValueError.""" """Test that preset with invalid CLI raises ValueError."""
@ -443,7 +442,6 @@ class TestYTDLPOpts:
with ( with (
patch("app.library.YTDLPOpts.Config") as mock_config, patch("app.library.YTDLPOpts.Config") as mock_config,
patch("app.library.YTDLPOpts.Presets") as mock_presets, 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, patch("app.library.YTDLPOpts.LOG") as mock_log,
): ):
# Mock config # Mock config
@ -460,24 +458,24 @@ class TestYTDLPOpts:
mock_preset.template = None mock_preset.template = None
mock_preset.folder = 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 = Mock()
mock_presets_instance.get.return_value = mock_preset mock_presets_instance.get.return_value = mock_preset
mock_presets.get_instance.return_value = mock_presets_instance mock_presets.get_instance.return_value = mock_presets_instance
# Mock cookie loading failure opts = YTDLPOpts()
mock_load_cookies.side_effect = ValueError("Invalid cookies") opts.preset("cookie_preset")
with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.write_text"): # Should log error but not raise
opts = YTDLPOpts() mock_log.error.assert_called_once()
opts.preset("cookie_preset") 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 # cookiefile should not be set
mock_log.error.assert_called_once() assert "cookiefile" not in opts._preset_opts
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
def test_replacer_substitution_in_cli(self): def test_replacer_substitution_in_cli(self):
"""Test that CLI arguments get replacer substitution.""" """Test that CLI arguments get replacer substitution."""
@ -506,3 +504,480 @@ class TestYTDLPOpts:
# Should replace %(home)s and %(user)s # Should replace %(home)s and %(user)s
expected_cli = "--output /actual/home/testuser" expected_cli = "--output /actual/home/testuser"
mock_converter.assert_called_once_with(args=expected_cli, level=True) 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

View file

@ -1,15 +1,16 @@
<template> <template>
<Message message_class="p-2 m-2 is-success has-background-warning-90 has-text-dark" v-if="status === 'disconnected'"> <div class="message is-warning" v-if="status === 'disconnected'">
<span class="icon"><i class="fas fa-info-circle" /></span> <div class="message-body">
<span> <span class="icon"><i class="fas fa-info-circle" /></span>
Connection lost, <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here</NuxtLink> <span>
to reconnect. Connection lost, <NuxtLink class="is-bold" @click.prevent="() => $emit('reconnect')">Click here</NuxtLink>
</span> to reconnect.
</Message> </span>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Message from '~/components/Message.vue'
import type { connectionStatus } from '~/stores/SocketStore' import type { connectionStatus } from '~/stores/SocketStore'
defineProps<{ 'status': connectionStatus }>() defineProps<{ 'status': connectionStatus }>()
defineEmits<{ (e: "reconnect"): void }>() defineEmits<{ (e: "reconnect"): void }>()

View file

@ -137,9 +137,8 @@
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<DLInput id="ytdlpCookies" type="text" label="Cookies" v-model="form.cookies" <DLInput id="ytdlpCookies" type="text" label="Cookies" v-model="form.cookies" icon="fa-solid fa-cookie"
icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress" :disabled="!socket.isConnected || addInProgress" :placeholder="getDefault('cookies', '')">
:placeholder="getDefault('cookies', '')">
<template #help> <template #help>
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -173,6 +172,12 @@
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" v-if="config.app.console_enabled" />
<NuxtLink class="dropdown-item" @click="runCliCommand" v-if="config.app.console_enabled">
<span class="icon has-text-warning"><i class="fa-solid fa-terminal" /></span>
<span>Run CLI</span>
</NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="resetConfig"> <NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span> <span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
@ -199,6 +204,14 @@
</button> </button>
</div> </div>
<div class="control" v-if="config.app.console_enabled">
<button type="button" class="button is-warning" @click="runCliCommand"
:disabled="!socket.isConnected || !form?.url">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Run CLI</span>
</button>
</div>
<div class="control"> <div class="control">
<button type="button" class="button is-danger" @click="resetConfig" <button type="button" class="button is-danger" @click="resetConfig"
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'"> :disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
@ -216,9 +229,6 @@
<datalist id="folders" v-if="config?.folders"> <datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" /> <option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist> </datalist>
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:message="dialog_confirm.message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" />
<DLFields v-if="showFields" @cancel="() => showFields = false" /> <DLFields v-if="showFields" @cancel="() => showFields = false" />
<Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'"> <Modal v-if="showOptions" @close="showOptions = false" :contentClass="'modal-content-max'">
@ -232,7 +242,9 @@ import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue' import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { item_request } from '~/types/item' import type { item_request } from '~/types/item'
import type { AutoCompleteOptions } from '~/types/autocomplete'; import type { AutoCompleteOptions } from '~/types/autocomplete'
import { navigateTo } from '#app'
import { useDialog } from '~/composables/useDialog'
const props = defineProps<{ item?: Partial<item_request> }>() const props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{ const emitter = defineEmits<{
@ -242,12 +254,14 @@ const emitter = defineEmits<{
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const toast = useNotification() const toast = useNotification()
const dialog = useDialog()
const showAdvanced = useStorage<boolean>('show_advanced', false) const showAdvanced = useStorage<boolean>('show_advanced', false)
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',') const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
const auto_start = useStorage<boolean>('auto_start', true) const auto_start = useStorage<boolean>('auto_start', true)
const show_description = useStorage<boolean>('show_description', true) const show_description = useStorage<boolean>('show_description', true)
const dlFields = useStorage<Record<string, any>>('dl_fields', {}) const dlFields = useStorage<Record<string, any>>('dl_fields', {})
const storedCommand = useStorage<string>('console_command', '')
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false)
const showFields = ref<boolean>(false) const showFields = ref<boolean>(false)
@ -275,25 +289,25 @@ const dialog_confirm = ref({
options: [], options: [],
}) })
const is_valid_dl_field = (dl_field: string): boolean => {
if (dlFieldsExtra.includes(dl_field)) {
return true
}
if (config.dl_fields && config.dl_fields.length > 0) {
return config.dl_fields.some(f => f.field === dl_field)
}
return false;
}
const addDownload = async () => { const addDownload = async () => {
let form_cli = (form.value?.cli || '').trim() let form_cli = (form.value?.cli || '').trim()
const is_valid = (dl_field: string): boolean => {
if (dlFieldsExtra.includes(dl_field)) {
return true
}
if (config.dl_fields && config.dl_fields.length > 0) {
return config.dl_fields.some(f => f.field === dl_field)
}
return false;
}
if (dlFields.value && Object.keys(dlFields.value).length > 0) { if (dlFields.value && Object.keys(dlFields.value).length > 0) {
const joined = [] const joined = []
for (const [key, value] of Object.entries(dlFields.value)) { for (const [key, value] of Object.entries(dlFields.value)) {
if (false === is_valid(key)) { if (false === is_valid_dl_field(key)) {
continue continue
} }
@ -382,13 +396,16 @@ const addDownload = async () => {
} }
} }
const resetConfig = () => { const resetConfig = async () => {
dialog_confirm.value.visible = true const { status } = await dialog.confirmDialog({
dialog_confirm.value.message = `Reset local configuration?` title: 'Confirm Action',
dialog_confirm.value.confirm = reset_config message: `Reset local configuration?`,
} confirmColor: 'is-danger',
})
if (!status) {
return
}
const reset_config = () => {
form.value = { form.value = {
url: '', url: '',
preset: config.app.default_preset, preset: config.app.default_preset,
@ -399,9 +416,7 @@ const reset_config = () => {
extras: {}, extras: {},
} as item_request } as item_request
dlFields.value = {} dlFields.value = {}
showAdvanced.value = false showAdvanced.value = false
toast.success('Local configuration has been reset.') toast.success('Local configuration has been reset.')
dialog_confirm.value.visible = false dialog_confirm.value.visible = false
} }
@ -475,6 +490,84 @@ onMounted(async () => {
} }
}) })
const runCliCommand = async (): Promise<void> => {
if (!form.value.url) {
toast.warning('Please enter a URL first')
return
}
const {status} = await dialog.confirmDialog({
title: 'Run CLI Command',
message: `This will generate a yt-dlp command and run it in the console. Continue?`,
confirmColor: 'is-warning',
})
if (!status) {
return
}
let form_cli = (form.value?.cli || '').trim()
if (dlFields.value && Object.keys(dlFields.value).length > 0) {
const joined = []
for (const [key, value] of Object.entries(dlFields.value)) {
if (false === is_valid_dl_field(key)) {
continue
}
if ([undefined, null, '', false].includes(value as any)) {
continue
}
const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
if (form_cli && keyRegex.test(form_cli)) {
continue;
}
joined.push(true === value ? `${key}` : `${key} ${value}`)
}
if (joined.length > 0) {
form_cli = form_cli ? `${form_cli} ${joined.join(' ')}` : joined.join(' ')
}
}
if (form_cli && form_cli.trim()) {
const options = await convertOptions(form_cli)
if (null === options) {
return
}
}
try {
const resp = await request('/api/yt-dlp/command', {
method: 'POST',
body: JSON.stringify({
url: form.value.url,
preset: form.value.preset,
folder: form.value.folder,
cookies: form.value.cookies,
template: form.value.template,
cli: form_cli,
})
})
const json = await resp.json() as { command?: string; error?: string }
if (!resp.ok) {
toast.error(`Error: ${json.error || 'Failed to generate command.'}`)
return
}
storedCommand.value = json.command
await nextTick()
await navigateTo('/console')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create and navigate to command')
}
}
const hasFormatInConfig = computed((): boolean => !!form.value.cli?.match(/(?<!\S)(-f|--format)(=|\s)(\S+)/)) const hasFormatInConfig = computed((): boolean => !!form.value.cli?.match(/(?<!\S)(-f|--format)(=|\s)(\S+)/))
const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag: boolean = true) => config.presets.filter(item => item.default === flag)

View file

@ -62,6 +62,7 @@
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { Terminal } from '@xterm/xterm' import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import { useStorage } from '@vueuse/core'
import { disableOpacity, enableOpacity } from '~/utils' import { disableOpacity, enableOpacity } from '~/utils'
import InputAutocomplete from '~/components/InputAutocomplete.vue' import InputAutocomplete from '~/components/InputAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete' import type { AutoCompleteOptions } from '~/types/autocomplete'
@ -75,6 +76,7 @@ const terminalFit = ref<FitAddon>()
const command = ref<string>('') const command = ref<string>('')
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window') const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false)
const storedCommand = useStorage<string>('console_command', '')
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
.map(flag => ({ value: flag, description: opt.description || '' })) .map(flag => ({ value: flag, description: opt.description || '' }))
@ -104,6 +106,34 @@ const handle_event = () => {
terminalFit.value?.fit() terminalFit.value?.fit()
} }
const ensureTerminal = async () => {
if (terminal.value) {
return
}
terminal.value = new Terminal({
fontSize: 14,
fontFamily: "'JetBrains Mono', monospace",
cursorBlink: false,
cursorStyle: 'underline',
cols: 108,
rows: 10,
disableStdin: true,
scrollback: 1000,
})
terminalFit.value = new FitAddon()
terminal.value.loadAddon(terminalFit.value)
await nextTick()
if (terminal_window.value) {
terminal.value.open(terminal_window.value)
}
terminalFit.value.fit();
}
const runCommand = async () => { const runCommand = async () => {
if ('' === command.value) { if ('' === command.value) {
return return
@ -123,24 +153,7 @@ const runCommand = async () => {
} }
} }
if (!terminal.value) { await ensureTerminal()
terminal.value = new Terminal({
fontSize: 14,
fontFamily: "'JetBrains Mono', monospace",
cursorBlink: false,
cursorStyle: 'underline',
cols: 108,
rows: 10,
disableStdin: true,
scrollback: 1000,
})
terminalFit.value = new FitAddon()
terminal.value.loadAddon(terminalFit.value)
if (terminal_window.value) {
terminal.value.open(terminal_window.value)
}
terminalFit.value.fit();
}
if ('clear' === command.value) { if ('clear' === command.value) {
clearOutput(true) clearOutput(true)
@ -149,8 +162,9 @@ const runCommand = async () => {
socket.emit('cli_post', command.value) socket.emit('cli_post', command.value)
isLoading.value = true isLoading.value = true
terminal.value.writeln(`user@YTPTube ~`) terminal.value?.writeln(`user@YTPTube ~`)
terminal.value.writeln(`$ yt-dlp ${command.value}`) terminal.value?.writeln(`$ yt-dlp ${command.value}`)
storedCommand.value = ''
} }
const clearOutput = async (withCommand: boolean = false) => { const clearOutput = async (withCommand: boolean = false) => {
@ -166,7 +180,6 @@ const clearOutput = async (withCommand: boolean = false) => {
} }
const focusInput = () => { const focusInput = () => {
// Focus the InputAutocomplete component's input field
const inputElement = document.getElementById('command') as HTMLInputElement const inputElement = document.getElementById('command') as HTMLInputElement
if (inputElement) { if (inputElement) {
inputElement.focus() inputElement.focus()
@ -192,7 +205,16 @@ onMounted(async () => {
socket.off('cli_output', writer) socket.off('cli_output', writer)
socket.on('cli_close', loader) socket.on('cli_close', loader)
socket.on('cli_output', writer) socket.on('cli_output', writer)
disableOpacity() disableOpacity()
await ensureTerminal()
if (storedCommand.value) {
command.value = storedCommand.value
await nextTick()
runCommand()
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

View file

@ -29,6 +29,8 @@ export const useSocketStore = defineStore('socket', () => {
event.forEach(e => socket.value?.off(e, callback)); event.forEach(e => socket.value?.off(e, callback));
} }
const getSessionId = (): string | null => socket.value?.id || null
const reconnect = () => { const reconnect = () => {
if (true === isConnected.value) { if (true === isConnected.value) {
return; return;
@ -220,6 +222,7 @@ export const useSocketStore = defineStore('socket', () => {
connect, reconnect, disconnect, connect, reconnect, disconnect,
on, off, emit, on, off, emit,
socket, isConnected, socket, isConnected,
getSessionId,
connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>, connectionStatus: readonly(connectionStatus) as Readonly<Ref<connectionStatus>>,
}; };
}); });

View file

@ -25,7 +25,7 @@ export default withNuxt(
'vue/script-setup-uses-vars': 'off', 'vue/script-setup-uses-vars': 'off',
'vue/html-quotes': ['warn', 'double'], 'vue/html-quotes': ['warn', 'double'],
'vue/mustache-interpolation-spacing': ['warn', 'always'], 'vue/mustache-interpolation-spacing': "off",
'vue/v-bind-style': ['warn', 'shorthand'], 'vue/v-bind-style': ['warn', 'shorthand'],
'vue/v-on-style': ['warn', 'shorthand'], 'vue/v-on-style': ['warn', 'shorthand'],
'vue/attributes-order': 'off', 'vue/attributes-order': 'off',