fix: route sparse-metadata playlist tracks by yt-dlp's own metadata

SoundCloud sets (and similar) return flat-playlist entries without per-track
artist/title. When a track Hit has no artist, download via an output template
(-o <root>/%(artist,uploader,channel)s/<source>/...) so yt-dlp places the file
under the real artist instead of "Unknown Artist". yt_download gains an optional
outtmpl mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 06:55:56 -07:00
parent f103b6c253
commit 6730f1f141
2 changed files with 37 additions and 11 deletions

View File

@@ -593,14 +593,18 @@ def _quality_args(quality: str) -> list[str]:
return ["-f", "bestaudio/best", "-x"] return ["-f", "bestaudio/best", "-x"]
def yt_download(url_or_query: str, target_folder: str, quality: str, dry_run: bool, def yt_download(url_or_query: str, target_folder: Optional[str], quality: str, dry_run: bool,
hit: Optional[Hit] = None): hit: Optional[Hit] = None, outtmpl: Optional[str] = None):
cmd = ["yt-dlp", cmd = ["yt-dlp",
*_quality_args(quality), *_quality_args(quality),
"--embed-metadata", "--embed-metadata",
"--embed-thumbnail", "--embed-thumbnail",
"--no-playlist", "--no-playlist"]
"-P", target_folder] # 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. # Override tags from the chosen hit so they don't rely on scraped titles.
if hit: if hit:
if hit.artist: 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 += ["--parse-metadata", f"{hit.year}:%(release_year)s"]
cmd.append(url_or_query) cmd.append(url_or_query)
dest = outtmpl or target_folder
if dry_run: if dry_run:
if target_folder:
print(f"[dry-run] mkdir -p {target_folder}") print(f"[dry-run] mkdir -p {target_folder}")
print(f"[dry-run] {' '.join(cmd)}") print(f"[dry-run] {' '.join(cmd)}")
return True return True
if target_folder:
os.makedirs(target_folder, exist_ok=True) os.makedirs(target_folder, exist_ok=True)
print(f"Downloading via yt-dlp -> {target_folder}") print(f"Downloading via yt-dlp -> {dest}")
return subprocess.run(cmd).returncode == 0 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): def act_youtube(hit: Hit, root: str, quality: str, dry_run: bool):
url = _track_url(hit) url = _track_url(hit)
artist_dir = hit.artist.split(",")[0].strip() or "Unknown Artist"
source = hit.payload.get("extractor") or "youtube" source = hit.payload.get("extractor") or "youtube"
artist_dir = hit.artist.split(",")[0].strip()
if artist_dir:
target = os.path.join(root, artist_dir, source) target = os.path.join(root, artist_dir, source)
return yt_download(url, target, quality, dry_run, hit=hit) 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -60,13 +60,27 @@ def test_act_youtube_soundcloud_folder(monkeypatch):
def test_act_youtube_youtube_folder(monkeypatch): def test_act_youtube_youtube_folder(monkeypatch):
captured = {} captured = {}
monkeypatch.setattr(mf, "yt_download", 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", h = mf.Hit(source="youtube", kind="track", title="T", artist="A",
payload={"videoId": "vid", "extractor": "youtube"}) payload={"videoId": "vid", "extractor": "youtube"})
mf.act_youtube(h, "/media/music", "best", False) mf.act_youtube(h, "/media/music", "best", False)
assert captured["target"] == "/media/music/A/youtube" 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 ---- # ---- download_single per-source folder ----
def test_download_single_bandcamp_folder(monkeypatch): def test_download_single_bandcamp_folder(monkeypatch):
monkeypatch.setattr(mf, "run_yt_dlp_get_metadata", monkeypatch.setattr(mf, "run_yt_dlp_get_metadata",