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)
- [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 <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
**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 .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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -137,9 +137,8 @@
</div>
<div class="column is-6-tablet is-12-mobile">
<DLInput id="ytdlpCookies" type="text" label="Cookies" v-model="form.cookies"
icon="fa-solid fa-cookie" :disabled="!socket.isConnected || addInProgress"
:placeholder="getDefault('cookies', '')">
<DLInput id="ytdlpCookies" type="text" label="Cookies" v-model="form.cookies" icon="fa-solid fa-cookie"
:disabled="!socket.isConnected || addInProgress" :placeholder="getDefault('cookies', '')">
<template #help>
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span>
@ -173,6 +172,12 @@
<span>yt-dlp Information</span>
</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" />
<NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
@ -199,6 +204,14 @@
</button>
</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">
<button type="button" class="button is-danger" @click="resetConfig"
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
@ -216,9 +229,6 @@
<datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</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" />
<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 TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
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 emitter = defineEmits<{
@ -242,12 +254,14 @@ const emitter = defineEmits<{
const config = useConfigStore()
const socket = useSocketStore()
const toast = useNotification()
const dialog = useDialog()
const showAdvanced = useStorage<boolean>('show_advanced', false)
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
const auto_start = useStorage<boolean>('auto_start', true)
const show_description = useStorage<boolean>('show_description', true)
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
const storedCommand = useStorage<string>('console_command', '')
const addInProgress = ref<boolean>(false)
const showFields = ref<boolean>(false)
@ -275,25 +289,25 @@ const dialog_confirm = ref({
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 () => {
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) {
const joined = []
for (const [key, value] of Object.entries(dlFields.value)) {
if (false === is_valid(key)) {
if (false === is_valid_dl_field(key)) {
continue
}
@ -382,13 +396,16 @@ const addDownload = async () => {
}
}
const resetConfig = () => {
dialog_confirm.value.visible = true
dialog_confirm.value.message = `Reset local configuration?`
dialog_confirm.value.confirm = reset_config
}
const resetConfig = async () => {
const { status } = await dialog.confirmDialog({
title: 'Confirm Action',
message: `Reset local configuration?`,
confirmColor: 'is-danger',
})
if (!status) {
return
}
const reset_config = () => {
form.value = {
url: '',
preset: config.app.default_preset,
@ -399,9 +416,7 @@ const reset_config = () => {
extras: {},
} as item_request
dlFields.value = {}
showAdvanced.value = false
toast.success('Local configuration has been reset.')
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 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 { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { useStorage } from '@vueuse/core'
import { disableOpacity, enableOpacity } from '~/utils'
import InputAutocomplete from '~/components/InputAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete'
@ -75,6 +76,7 @@ const terminalFit = ref<FitAddon>()
const command = ref<string>('')
const terminal_window = useTemplateRef<HTMLDivElement>('terminal_window')
const isLoading = ref<boolean>(false)
const storedCommand = useStorage<string>('console_command', '')
const ytDlpOptions = computed<AutoCompleteOptions>(() => config.ytdlp_options.flatMap(opt => opt.flags
.map(flag => ({ value: flag, description: opt.description || '' }))
@ -104,6 +106,34 @@ const handle_event = () => {
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 () => {
if ('' === command.value) {
return
@ -123,24 +153,7 @@ const runCommand = async () => {
}
}
if (!terminal.value) {
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();
}
await ensureTerminal()
if ('clear' === command.value) {
clearOutput(true)
@ -149,8 +162,9 @@ const runCommand = async () => {
socket.emit('cli_post', command.value)
isLoading.value = true
terminal.value.writeln(`user@YTPTube ~`)
terminal.value.writeln(`$ yt-dlp ${command.value}`)
terminal.value?.writeln(`user@YTPTube ~`)
terminal.value?.writeln(`$ yt-dlp ${command.value}`)
storedCommand.value = ''
}
const clearOutput = async (withCommand: boolean = false) => {
@ -166,7 +180,6 @@ const clearOutput = async (withCommand: boolean = false) => {
}
const focusInput = () => {
// Focus the InputAutocomplete component's input field
const inputElement = document.getElementById('command') as HTMLInputElement
if (inputElement) {
inputElement.focus()
@ -192,7 +205,16 @@ onMounted(async () => {
socket.off('cli_output', writer)
socket.on('cli_close', loader)
socket.on('cli_output', writer)
disableOpacity()
await ensureTerminal()
if (storedCommand.value) {
command.value = storedCommand.value
await nextTick()
runCommand()
}
})
onBeforeUnmount(() => {

View file

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

View file

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