mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
feat: add music metadata processing and writing functionality
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user