91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
"""Derive a track's artist + title from a yt-dlp playlist entry.
|
|
|
|
Flat playlist extraction (used to dodge YouTube rate limits) gives sparse
|
|
per-entry data: often just ``title``, ``id``, ``duration``, and an
|
|
``uploader``/``channel`` that — for a playlist like "Likes" — is the PLAYLIST
|
|
OWNER, not the track artist. GitHub #863: every track came out as the owner
|
|
("Wing It"), or "Unknown Artist" when ``uploader`` was absent, because the
|
|
parser used ``entry['uploader']`` as the artist.
|
|
|
|
The artist is usually recoverable from one of, in priority order:
|
|
|
|
1. yt-dlp music-metadata fields (``artists`` / ``artist`` / ``creator``),
|
|
populated for YouTube Music tracks.
|
|
2. An auto-generated ``"<Artist> - Topic"`` channel name.
|
|
3. The classic ``"<Artist> - <Title>"`` form embedded in the video title.
|
|
|
|
This module is the single, pure place that decides which signal wins, so the
|
|
precedence is unit-testable instead of buried in the web_server endpoint. It
|
|
deliberately does NOT fall back to the channel/uploader as the artist — on a
|
|
playlist that's the owner, and mislabelling every track is worse than an honest
|
|
"Unknown Artist" (which downstream MusicBrainz discovery can still try to fix).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any, Mapping, Tuple
|
|
|
|
# Trailing "- Topic" on an auto-generated YouTube Music channel.
|
|
_TOPIC_RE = re.compile(r'\s*-\s*topic\s*$', re.IGNORECASE)
|
|
|
|
# "Artist - Title": a hyphen/en-dash/em-dash flanked by spaces, both sides
|
|
# non-empty. Splits on the FIRST such separator so "A - B (C Remix)" → ("A",
|
|
# "B (C Remix)"). Spaces around the dash are required so hyphenated names like
|
|
# "Jean-Michel Jarre" aren't split.
|
|
_TITLE_SPLIT_RE = re.compile(r'^\s*(?P<artist>.+?)\s+[-–—]\s+(?P<title>.+?)\s*$')
|
|
|
|
|
|
def _first_music_field(entry: Mapping[str, Any]) -> str:
|
|
"""First non-empty value from yt-dlp's music-metadata fields."""
|
|
artists = entry.get('artists')
|
|
if isinstance(artists, (list, tuple)):
|
|
for a in artists:
|
|
s = str(a or '').strip()
|
|
if s:
|
|
return s
|
|
for key in ('artist', 'creator'):
|
|
v = entry.get(key)
|
|
if isinstance(v, str) and v.strip():
|
|
return v.strip()
|
|
return ''
|
|
|
|
|
|
def derive_artist_and_title(entry: Mapping[str, Any]) -> Tuple[str, str]:
|
|
"""Return ``(artist, title)`` from a yt-dlp (flat) playlist entry.
|
|
|
|
``artist`` is ``''`` when no reliable signal exists — the caller defaults
|
|
that to "Unknown Artist" rather than using the playlist owner's channel
|
|
(#863). ``title`` is the raw video title, except when an "Artist - Title"
|
|
split provided the artist, in which case it's the right-hand side.
|
|
"""
|
|
if not isinstance(entry, Mapping):
|
|
return '', 'Unknown Track'
|
|
|
|
title = str(entry.get('title') or '').strip() or 'Unknown Track'
|
|
|
|
# 1. Music-metadata fields (YouTube Music).
|
|
field_artist = _first_music_field(entry)
|
|
if field_artist:
|
|
return field_artist, title
|
|
|
|
# 2. "<Artist> - Topic" auto-channel — the channel name IS the artist.
|
|
channel = str(entry.get('uploader') or entry.get('channel') or '').strip()
|
|
if _TOPIC_RE.search(channel):
|
|
stripped = _TOPIC_RE.sub('', channel).strip()
|
|
if stripped:
|
|
return stripped, title
|
|
|
|
# 3. "<Artist> - <Title>" embedded in the title.
|
|
m = _TITLE_SPLIT_RE.match(title)
|
|
if m:
|
|
artist = m.group('artist').strip()
|
|
rest = m.group('title').strip()
|
|
if artist and rest:
|
|
return artist, rest
|
|
|
|
# 4. No reliable artist signal — caller defaults to "Unknown Artist".
|
|
return '', title
|
|
|
|
|
|
__all__ = ['derive_artist_and_title']
|