Files
metube/app/music_metadata.py
T

276 lines
9.6 KiB
Python

"""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.
"""
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:
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, tuple)):
return any(_has_value(item) for item in value)
return value is not None
def _positive_int(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _track_position(value: Any) -> tuple[Optional[int], Optional[int]]:
"""Return a track number and optional total from a scalar or ``n/total``."""
if isinstance(value, str) and '/' in value:
number, total = value.split('/', 1)
return _positive_int(number.strip()), _positive_int(total.strip())
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 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 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 prefer_square_thumbnail(info: dict[str, Any]) -> None:
"""Move the largest known square thumbnail to yt-dlp's preferred slot."""
thumbnails = info.get('thumbnails')
if not isinstance(thumbnails, list) or len(thumbnails) < 2:
return
candidates: list[tuple[int, int]] = []
for index, thumbnail in enumerate(thumbnails):
if not isinstance(thumbnail, dict):
continue
width = _positive_int(thumbnail.get('width'))
height = _positive_int(thumbnail.get('height'))
if width is not None and width == height:
candidates.append((width * height, index))
if not candidates:
return
_, selected_index = max(candidates)
selected = thumbnails.pop(selected_index)
thumbnails.append(selected)
if selected.get('url'):
info['thumbnail'] = selected['url']
class MusicMetadataPreProcessor(PostProcessor):
"""Enrich a fully extracted audio info-dict from its queued source entry."""
def __init__(self, downloader=None, *, source_url=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 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)
if not _has_value(info.get('album')):
album = 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):
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