refactor: simplify music metadata processing by removing unused code and improving album signal detection

This commit is contained in:
Your GitHub Name
2026-07-17 10:43:39 -07:00
parent edf101faa0
commit f3d670e288
4 changed files with 80 additions and 429 deletions
+42 -193
View File
@@ -1,28 +1,15 @@
"""Conservative music metadata enrichment for audio downloads.
This module only consumes metadata already supplied by yt-dlp or retained on
MeTube's queued playlist entry. It intentionally performs no external lookup.
This module only consumes fields already supplied by yt-dlp or retained on
MeTube's queued playlist entry. It intentionally performs no external lookup
or site-specific album detection.
"""
from __future__ import annotations
import os
from typing import Any, Optional
from urllib.parse import urlparse
from mutagen import MutagenError
from mutagen.flac import FLAC
from mutagen.id3 import ID3, ID3NoHeaderError, TRCK, TXXX
from mutagen.mp4 import AtomDataType, MP4, MP4FreeForm
from mutagen.oggopus import OggOpus
from yt_dlp.postprocessor.common import PostProcessor
from yt_dlp.utils import PostProcessingError
_ARTISTS_KEY = '__metube_track_artists'
_TRACK_NUMBER_KEY = '__metube_track_number'
_TRACK_TOTAL_KEY = '__metube_track_total'
_YOUTUBE_MUSIC_HOSTS = frozenset(('music.youtube.com', 'music.youtube-nocookie.com'))
def _has_value(value: Any) -> bool:
@@ -51,71 +38,25 @@ def _track_position(value: Any) -> tuple[Optional[int], Optional[int]]:
return _positive_int(value), None
def _is_youtube_music_url(value: Any) -> bool:
if not isinstance(value, str) or not value.strip():
return False
try:
return (urlparse(value).hostname or '').lower() in _YOUTUBE_MUSIC_HOSTS
except ValueError:
return False
def _first_positive_int(*values: Any) -> Optional[int]:
return next((number for value in values if (number := _positive_int(value))), None)
def is_confirmed_music_album(source_url: Any, entry: Any) -> bool:
"""Recognize only strong YouTube Music album signals."""
entry = entry if isinstance(entry, dict) else {}
for key in ('playlist_id', 'playlist'):
playlist_id = entry.get(key)
if isinstance(playlist_id, str) and playlist_id.startswith('OLAK5uy_'):
return True
candidate_urls = (
source_url,
entry.get('original_url'),
entry.get('webpage_url'),
entry.get('url'),
)
for value in candidate_urls:
if not _is_youtube_music_url(value):
continue
try:
path = urlparse(value).path.rstrip('/')
except ValueError:
continue
if path.startswith('/browse/MPRE'):
return True
return False
def _is_music_audio(info: dict[str, Any], source_url: Any, confirmed_album: bool) -> bool:
if confirmed_album or _is_youtube_music_url(source_url):
return True
if (
_is_youtube_music_url(info.get('webpage_url'))
or _is_youtube_music_url(info.get('original_url'))
):
return True
return _has_value(info.get('track')) or (
_has_value(info.get('album')) and _has_value(info.get('artists'))
def _has_album_signal(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
"""Use only extractor-owned fields to identify album-level metadata."""
return any(
_has_value(entry.get(key))
for entry in (info, source_entry)
for key in ('album', 'track_number')
)
def structured_track_artists(info: dict[str, Any]) -> list[str]:
"""Return structured track artists without guessing at comma separators."""
artists = info.get('artists')
if not isinstance(artists, (list, tuple)):
return []
result: list[str] = []
for value in artists:
if not isinstance(value, str):
continue
# YouTube Music uses a *spaced* middle dot between artist credits.
# Do not split unspaced names such as "half\u00b7alive".
for artist in value.split(' \u00b7 '):
artist = artist.strip()
if artist and artist not in result:
result.append(artist)
return result
def _is_music_audio(info: dict[str, Any], source_entry: dict[str, Any]) -> bool:
return _has_album_signal(info, source_entry) or any(
_has_value(entry.get(key))
for entry in (info, source_entry)
for key in ('track', 'artists')
)
def prefer_square_thumbnail(info: dict[str, Any]) -> None:
@@ -143,133 +84,41 @@ def prefer_square_thumbnail(info: dict[str, Any]) -> None:
class MusicMetadataPreProcessor(PostProcessor):
"""Enrich a fully extracted audio info-dict from its queued source entry."""
"""Enrich extracted audio metadata using extractor-owned album signals."""
def __init__(self, downloader=None, *, source_url=None, source_entry=None):
def __init__(self, downloader=None, *, source_entry=None):
super().__init__(downloader)
self._source_url = source_url
self._source_entry = source_entry if isinstance(source_entry, dict) else {}
def run(self, info):
confirmed_album = is_confirmed_music_album(self._source_url, self._source_entry)
number, inline_total = _track_position(info.get('track_number'))
total = inline_total
if confirmed_album:
if _has_album_signal(info, self._source_entry):
number, inline_total = _track_position(info.get('track_number'))
if number is None:
number, source_inline_total = _track_position(
self._source_entry.get('track_number')
)
inline_total = inline_total or source_inline_total
if number is None:
number = _positive_int(self._source_entry.get('playlist_index'))
if number is not None:
info['track_number'] = number
total = total or next((
value for value in (
_positive_int(info.get('track_count')),
_positive_int(info.get('track_total')),
_positive_int(self._source_entry.get('playlist_count')),
_positive_int(self._source_entry.get('n_entries')),
) if value is not None
), None)
total = inline_total or _first_positive_int(
info.get('track_count'),
info.get('track_total'),
self._source_entry.get('track_count'),
self._source_entry.get('track_total'),
self._source_entry.get('playlist_count'),
self._source_entry.get('n_entries'),
)
if number is not None:
info['track_number'] = f'{number}/{total}' if total is not None else number
if not _has_value(info.get('album')):
album = self._source_entry.get('playlist_title')
album = self._source_entry.get('album') or self._source_entry.get(
'playlist_title'
)
if isinstance(album, str) and album.strip():
info['album'] = album.strip()
if number is not None:
info[_TRACK_NUMBER_KEY] = number
if total is not None:
info[_TRACK_TOTAL_KEY] = total
artists = structured_track_artists(info)
if len(artists) > 1:
info[_ARTISTS_KEY] = artists
if _is_music_audio(info, self._source_url, confirmed_album):
if _is_music_audio(info, self._source_entry):
prefer_square_thumbnail(info)
return [], info
def _write_mp3(path: str, artists: list[str], number: Optional[int], total: Optional[int]) -> None:
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
if artists:
tags.delall('TXXX:Artists')
tags.delall('TXXX:ARTISTS')
tags.add(TXXX(encoding=3, desc='Artists', text=artists))
if number is not None:
value = f'{number}/{total}' if total is not None else str(number)
tags.setall('TRCK', [TRCK(encoding=3, text=[value])])
tags.save(path, v2_version=4)
def _write_m4a(path: str, artists: list[str], number: Optional[int], total: Optional[int]) -> None:
audio = MP4(path)
if audio.tags is None:
audio.add_tags()
if artists:
# A list under one key is serialized as one multi-value atom. Duplicate
# atoms are intentionally avoided because TagLib reads only the first.
audio.tags['----:com.apple.iTunes:ARTISTS'] = [
MP4FreeForm(artist.encode('utf-8'), dataformat=AtomDataType.UTF8)
for artist in artists
]
if number is not None:
audio.tags['trkn'] = [(number, total or 0)]
audio.save()
def _write_vorbis(audio, artists: list[str], number: Optional[int], total: Optional[int]) -> None:
if artists:
audio['ARTISTS'] = artists
if number is not None:
audio['TRACKNUMBER'] = [str(number)]
if total is not None:
value = [str(total)]
audio['TRACKTOTAL'] = value
audio['TOTALTRACKS'] = value
audio.save()
def write_music_tags(
path: str,
extension: str,
artists: list[str],
number: Optional[int],
total: Optional[int],
) -> None:
"""Write only the supplemental tags needed for music library scanners."""
extension = extension.lower()
if extension == 'mp3':
_write_mp3(path, artists, number, total)
elif extension == 'm4a':
_write_m4a(path, artists, number, total)
elif extension == 'flac':
_write_vorbis(FLAC(path), artists, number, total)
elif extension == 'opus':
_write_vorbis(OggOpus(path), artists, number, total)
class MusicMetadataWriterPostProcessor(PostProcessor):
"""Write supplemental tags after yt-dlp has moved the completed audio file."""
_SUPPORTED_EXTENSIONS = frozenset(('mp3', 'm4a', 'flac', 'opus'))
def run(self, info):
artists = info.get(_ARTISTS_KEY)
artists = artists if isinstance(artists, list) else []
number = _positive_int(info.get(_TRACK_NUMBER_KEY))
total = _positive_int(info.get(_TRACK_TOTAL_KEY))
if not artists and number is None:
return [], info
path = info.get('filepath')
extension = str(info.get('ext') or os.path.splitext(str(path))[1][1:]).lower()
if not isinstance(path, str) or extension not in self._SUPPORTED_EXTENSIONS:
return [], info
try:
write_music_tags(path, extension, artists, number, total)
except (MutagenError, OSError, TypeError, ValueError) as error:
raise PostProcessingError(
f'Unable to write supplemental music metadata to "{path}": {error}'
) from error
return [], info