diff --git a/README.md b/README.md index 7c496bf..eca18f8 100644 --- a/README.md +++ b/README.md @@ -27,27 +27,27 @@ Download the latest release from [releases](https://github.com/darkzoul5/Youtube ## Configure -On first run, the app will auto-create a default `yt-playlist-config.json` in the project root (if missing). +On first run, the app will auto-create a default `config/yt-playlist-config.json` (if missing). -Create/edit `yt-playlist-config.json`: +Create/edit `config/yt-playlist-config.json`: ```json { - "config_path": "./", "ffmpeg_path": "./bin/ffmpeg.exe", "playlists": [ { "url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID", "download_mode": "audio", + "max_download_quality": "1080p", "save_path": "./downloads" } ] } ``` -`config_path` (optional): +`max_download_quality`: -- If set to a string path, the app loads the config from that file instead (path is relative to the current config file). +- Limits yt-dlp download quality (e.g. `"1080p"`, `"720p"`). This only affects the downloaded video format selection. `download_mode`: diff --git a/config/yt-playlist-config.example.json b/config/yt-playlist-config.example.json index 7059006..61e1d76 100644 --- a/config/yt-playlist-config.example.json +++ b/config/yt-playlist-config.example.json @@ -3,7 +3,7 @@ { "url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE", "download_mode": "audio", - "max_video_quality": "1080p", + "max_download_quality": "1080p", "save_path": "./downloads" } ], diff --git a/src/app/config/settings.py b/src/app/config/settings.py index 20c0925..d28bac0 100644 --- a/src/app/config/settings.py +++ b/src/app/config/settings.py @@ -8,30 +8,52 @@ from typing import Any, Dict, List, Optional DEFAULT_CONFIG: Dict[str, Any] = { "playlists": [], "download_mode": "audio", - "max_video_quality": "1080p", + "max_download_quality": "1080p", "save_path": "./downloads", "ffmpeg_path": "ffmpeg", } class Settings: - def __init__(self, config_path: Optional[Path] = None) -> None: + def __init__(self) -> None: base_dir = Path("config") base_dir.mkdir(parents=True, exist_ok=True) - self.path = (config_path or (base_dir / "yt-playlist-config.json")).resolve() + self.path = (base_dir / "yt-playlist-config.json").resolve() self.data: Dict[str, Any] = dict(DEFAULT_CONFIG) - if self.path.exists(): - try: - self.data.update(json.loads(self.path.read_text(encoding="utf-8"))) - except Exception: - # Leave defaults if invalid JSON; validation can be added later. - pass + + # Ensure there is always a config file at the default path. + if not self.path.exists(): + self._write_default_config(self.path) + + self._load_from_path(self.path) + + def _load_from_path(self, path: Path) -> None: + try: + self.data.update(json.loads(path.read_text(encoding="utf-8"))) + except Exception: + # Leave defaults if invalid JSON; validation can be added later. + pass + + def _write_default_config(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + default_payload: Dict[str, Any] = { + "playlists": [ + { + "url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID", + "download_mode": "audio", + "max_download_quality": "1080p", + "save_path": "./downloads", + } + ], + "ffmpeg_path": "ffmpeg", + } + path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8") @property def playlists(self) -> List[Dict[str, Any]]: global_defaults = { "download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]), - "max_video_quality": self.data.get("max_video_quality", DEFAULT_CONFIG["max_video_quality"]), + "max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]), "save_path": self.data.get("save_path", DEFAULT_CONFIG["save_path"]), "ffmpeg_path": self.data.get("ffmpeg_path", DEFAULT_CONFIG["ffmpeg_path"]), } diff --git a/src/app/core/download/downloader.py b/src/app/core/download/downloader.py index a422586..cc6d834 100644 --- a/src/app/core/download/downloader.py +++ b/src/app/core/download/downloader.py @@ -15,6 +15,30 @@ class Downloader: self.yt_dlp_path = yt_dlp_path self.ffmpeg_path = ffmpeg_path + @staticmethod + def build_format(max_download_quality) -> str: + def parse_height_cap(value) -> int | None: + if value is None: + return None + if isinstance(value, int): + return value if value > 0 else None + s = str(value).strip().lower() + if not s or s in {"best", "max", "auto", "none", "null"}: + return None + digits = "".join(ch for ch in s if ch.isdigit()) + if not digits: + return None + try: + cap = int(digits) + except Exception: + return None + return cap if cap > 0 else None + + cap = parse_height_cap(max_download_quality) + if cap is not None: + return f"best[ext=mp4][acodec!=none][vcodec!=none][height<={cap}]/best[ext=mp4][height<={cap}]/best[ext=mp4]" + return "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]" + async def handle_job(self, job: DownloadJob): try: job.state = JobState.DOWNLOADING @@ -53,12 +77,14 @@ class Downloader: outtmpl = str(job.output_path) + fmt = self.build_format(getattr(job, "max_download_quality", None)) + # All modes download a single muxed mp4 when possible. # This avoids any ffmpeg-driven merging during the download step, satisfying: # - video: "original file, no processing" # - audio/both: extraction is done separately after download ydl_opts = { - "format": "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]", + "format": fmt, "outtmpl": outtmpl, "noplaylist": True, "quiet": True, diff --git a/src/app/core/download/queue_manager.py b/src/app/core/download/queue_manager.py index da83cd1..eaf9148 100644 --- a/src/app/core/download/queue_manager.py +++ b/src/app/core/download/queue_manager.py @@ -28,6 +28,7 @@ class DownloadJob: state: JobState = JobState.QUEUED error: Optional[str] = None ffmpeg_path: Optional[str] = None + max_download_quality: Optional[str] = None audio_output_path: Optional[Path] = None # when mode=video and we also want mp3 keep_video: bool = True diff --git a/src/app/core/sync/executor.py b/src/app/core/sync/executor.py index b0d4f0b..dcebb1d 100644 --- a/src/app/core/sync/executor.py +++ b/src/app/core/sync/executor.py @@ -152,6 +152,7 @@ class ActionExecutor: d["video"] = a.to_name ffmpeg_cfg = str(playlist_cfg.get("ffmpeg_path", "ffmpeg")) if playlist_cfg.get("ffmpeg_path") is not None else None + max_quality_cfg = playlist_cfg.get("max_download_quality") temp_video_root = video_root / ".tmp" temp_video_root.mkdir(parents=True, exist_ok=True) @@ -176,6 +177,7 @@ class ActionExecutor: url=url, mode="video", ffmpeg_path=ffmpeg_cfg, + max_download_quality=max_quality_cfg, audio_output_path=audio_path, ) jobs.append(job) @@ -200,6 +202,7 @@ class ActionExecutor: url=url, mode="video", ffmpeg_path=ffmpeg_cfg, + max_download_quality=max_quality_cfg, audio_output_path=audio_path, keep_video=False, ) @@ -215,6 +218,7 @@ class ActionExecutor: url=url, mode="video", ffmpeg_path=ffmpeg_cfg, + max_download_quality=max_quality_cfg, ) jobs.append(job) await queue.enqueue(job) diff --git a/tests/dummy_config.py b/tests/dummy_config.py index eeaf3f7..33913ed 100644 --- a/tests/dummy_config.py +++ b/tests/dummy_config.py @@ -12,7 +12,7 @@ class DummyConfig: max_parallel_downloads = int(os.getenv("TEST_MAX_PARALLEL", "2")) aria2c_connections = int(os.getenv("TEST_ARIA2C_CONN", "2")) download_mode = os.getenv("TEST_DOWNLOAD_MODE", "audio") - max_video_quality = os.getenv("TEST_MAX_VIDEO_QUALITY", "1080p") + max_download_quality = os.getenv("TEST_MAX_DOWNLOAD_QUALITY", "1080p") # runtime flags debug = False non_interactive = False diff --git a/tests/test_download_quality_format.py b/tests/test_download_quality_format.py new file mode 100644 index 0000000..ff75018 --- /dev/null +++ b/tests/test_download_quality_format.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from src.app.core.download.downloader import Downloader + + +def test_build_format_defaults_to_best_mp4(): + fmt = Downloader.build_format(None) + assert "height<=" not in fmt + assert "best[ext=mp4]" in fmt + + +def test_build_format_applies_height_cap(): + fmt = Downloader.build_format("720p") + assert "height<=720" in fmt + diff --git a/tests/test_settings_default_config.py b/tests/test_settings_default_config.py new file mode 100644 index 0000000..1a2f25a --- /dev/null +++ b/tests/test_settings_default_config.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from src.app.config.settings import Settings + + +def test_settings_creates_root_config_if_missing(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + cfg_path = tmp_path / "config" / "yt-playlist-config.json" + assert not cfg_path.exists() + + settings = Settings() + assert settings.path == cfg_path.resolve() + assert cfg_path.exists() + + data = json.loads(cfg_path.read_text(encoding="utf-8")) + assert "playlists" in data + + +def test_settings_reads_config_from_default_location(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + cfg_path = tmp_path / "config" / "yt-playlist-config.json" + cfg_path.parent.mkdir(parents=True, exist_ok=True) + cfg_path.write_text(json.dumps({"playlists": [{"url": "X", "save_path": "./downloads"}]}), encoding="utf-8") + + settings = Settings() + assert settings.path == cfg_path.resolve() + assert settings.playlists and settings.playlists[0]["url"] == "X"