mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-03 04:23:59 +03:00
feat: make sure max_video_quality setting is working
This commit is contained in:
@@ -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`:
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
],
|
||||
|
||||
+32
-10
@@ -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"]),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user