diff --git a/musicfetch b/musicfetch index 56e1a4e..8de5cc8 100755 --- a/musicfetch +++ b/musicfetch @@ -593,14 +593,18 @@ def _quality_args(quality: str) -> list[str]: return ["-f", "bestaudio/best", "-x"] -def yt_download(url_or_query: str, target_folder: str, quality: str, dry_run: bool, - hit: Optional[Hit] = None): +def yt_download(url_or_query: str, target_folder: Optional[str], quality: str, dry_run: bool, + hit: Optional[Hit] = None, outtmpl: Optional[str] = None): cmd = ["yt-dlp", *_quality_args(quality), "--embed-metadata", "--embed-thumbnail", - "--no-playlist", - "-P", target_folder] + "--no-playlist"] + # Either a fixed output dir (-P) or a metadata-driven output template (-o). + if outtmpl: + cmd += ["-o", outtmpl] + else: + cmd += ["-P", target_folder] # Override tags from the chosen hit so they don't rely on scraped titles. if hit: if hit.artist: @@ -616,12 +620,15 @@ def yt_download(url_or_query: str, target_folder: str, quality: str, dry_run: bo cmd += ["--parse-metadata", f"{hit.year}:%(release_year)s"] cmd.append(url_or_query) + dest = outtmpl or target_folder if dry_run: - print(f"[dry-run] mkdir -p {target_folder}") + if target_folder: + print(f"[dry-run] mkdir -p {target_folder}") print(f"[dry-run] {' '.join(cmd)}") return True - os.makedirs(target_folder, exist_ok=True) - print(f"Downloading via yt-dlp -> {target_folder}") + if target_folder: + os.makedirs(target_folder, exist_ok=True) + print(f"Downloading via yt-dlp -> {dest}") return subprocess.run(cmd).returncode == 0 @@ -648,10 +655,15 @@ def _track_url(hit: Hit) -> str: def act_youtube(hit: Hit, root: str, quality: str, dry_run: bool): url = _track_url(hit) - artist_dir = hit.artist.split(",")[0].strip() or "Unknown Artist" source = hit.payload.get("extractor") or "youtube" - target = os.path.join(root, artist_dir, source) - return yt_download(url, target, quality, dry_run, hit=hit) + artist_dir = hit.artist.split(",")[0].strip() + if artist_dir: + target = os.path.join(root, artist_dir, source) + return yt_download(url, target, quality, dry_run, hit=hit) + # Sparse playlist metadata (e.g. SoundCloud sets): let yt-dlp route the file + # by the track's own metadata so it lands under the real artist. + outtmpl = os.path.join(root, "%(artist,uploader,channel)s", source, "%(title)s [%(id)s].%(ext)s") + return yt_download(url, None, quality, dry_run, hit=hit, outtmpl=outtmpl) # --------------------------------------------------------------------------- diff --git a/tests/test_multiplatform.py b/tests/test_multiplatform.py index 01e7039..7d4910c 100644 --- a/tests/test_multiplatform.py +++ b/tests/test_multiplatform.py @@ -60,13 +60,27 @@ def test_act_youtube_soundcloud_folder(monkeypatch): def test_act_youtube_youtube_folder(monkeypatch): captured = {} monkeypatch.setattr(mf, "yt_download", - lambda url, target, quality, dry_run, hit=None: captured.update(target=target) or True) + lambda url, target, quality, dry_run, hit=None, outtmpl=None: + captured.update(target=target) or True) h = mf.Hit(source="youtube", kind="track", title="T", artist="A", payload={"videoId": "vid", "extractor": "youtube"}) mf.act_youtube(h, "/media/music", "best", False) assert captured["target"] == "/media/music/A/youtube" +def test_act_youtube_unknown_artist_uses_metadata_template(monkeypatch): + captured = {} + monkeypatch.setattr(mf, "yt_download", + lambda url, target, quality, dry_run, hit=None, outtmpl=None: + captured.update(target=target, outtmpl=outtmpl) or True) + h = mf.Hit(source="youtube", kind="track", title="", artist="", + payload={"videoId": None, "extractor": "soundcloud", "url": "https://soundcloud.com/a/t"}) + mf.act_youtube(h, "/media/music", "best", False) + assert captured["target"] is None + assert "%(artist,uploader,channel)s" in captured["outtmpl"] + assert captured["outtmpl"].endswith("/soundcloud/%(title)s [%(id)s].%(ext)s") + + # ---- download_single per-source folder ---- def test_download_single_bandcamp_folder(monkeypatch): monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",