mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
Merge PR #1031: conservative music metadata enrichment for audio downloads
Adds MusicMetadataPreProcessor (app/music_metadata.py), a pre_process postprocessor that enriches the info dict using only extractor-owned fields: track-number/total resolution, album-title fallback, and square-thumbnail preference. Track numbers and album flow into files through yt-dlp's existing FFmpegMetadata embedding, so no per-format tag-writing code and no new dependency. The earlier mutagen writer and its download-failing PostProcessingError path were dropped per review. Co-authored-by: jahruz67 <jahruz67@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
"""Conservative music metadata enrichment for audio downloads.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from yt_dlp.postprocessor.common import PostProcessor
|
||||||
|
|
||||||
|
|
||||||
|
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 _first_positive_int(*values: Any) -> Optional[int]:
|
||||||
|
return next((number for value in values if (number := _positive_int(value))), None)
|
||||||
|
|
||||||
|
|
||||||
|
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 _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:
|
||||||
|
"""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 extracted audio metadata using extractor-owned album signals."""
|
||||||
|
|
||||||
|
def __init__(self, downloader=None, *, source_entry=None):
|
||||||
|
super().__init__(downloader)
|
||||||
|
self._source_entry = source_entry if isinstance(source_entry, dict) else {}
|
||||||
|
|
||||||
|
def run(self, info):
|
||||||
|
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'))
|
||||||
|
|
||||||
|
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('album') or self._source_entry.get(
|
||||||
|
'playlist_title'
|
||||||
|
)
|
||||||
|
if isinstance(album, str) and album.strip():
|
||||||
|
info['album'] = album.strip()
|
||||||
|
|
||||||
|
if _is_music_audio(info, self._source_entry):
|
||||||
|
prefer_square_thumbnail(info)
|
||||||
|
return [], info
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Tests for conservative audio metadata enrichment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from music_metadata import MusicMetadataPreProcessor
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess(source_entry, info):
|
||||||
|
processor = MusicMetadataPreProcessor(source_entry=source_entry)
|
||||||
|
_, result = processor.run(info)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_album_uses_existing_order_and_total_when_track_number_is_missing():
|
||||||
|
result = _preprocess(
|
||||||
|
{
|
||||||
|
'playlist_index': '03',
|
||||||
|
'playlist_count': 12,
|
||||||
|
'playlist_title': 'Example Album',
|
||||||
|
},
|
||||||
|
{'title': 'Track', 'album': 'Example Album'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '3/12'
|
||||||
|
assert result['album'] == 'Example Album'
|
||||||
|
|
||||||
|
|
||||||
|
def test_official_track_number_wins_over_album_order():
|
||||||
|
result = _preprocess(
|
||||||
|
{'playlist_index': 3, 'playlist_count': 12},
|
||||||
|
{'track_number': 7, 'album': 'Official Album'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '7/12'
|
||||||
|
assert result['album'] == 'Official Album'
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_official_track_total_is_preserved():
|
||||||
|
result = _preprocess(
|
||||||
|
{'playlist_count': 12},
|
||||||
|
{'track_number': '4/10', 'album': 'Official Album'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '4/10'
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_track_number_and_total_are_retained_from_flat_extraction():
|
||||||
|
result = _preprocess(
|
||||||
|
{'track_number': 2, 'track_count': 9, 'playlist_index': 4},
|
||||||
|
{'title': 'Track'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['track_number'] == '2/9'
|
||||||
|
|
||||||
|
|
||||||
|
def test_album_title_falls_back_to_source_playlist_title():
|
||||||
|
result = _preprocess(
|
||||||
|
{'playlist_title': 'Example Album'},
|
||||||
|
{'track_number': 4},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['album'] == 'Example Album'
|
||||||
|
assert result['track_number'] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlist_without_extractor_album_signals_is_not_changed():
|
||||||
|
result = _preprocess(
|
||||||
|
{
|
||||||
|
'playlist_index': 3,
|
||||||
|
'playlist_count': 12,
|
||||||
|
'playlist_title': 'Example Playlist',
|
||||||
|
},
|
||||||
|
{'title': 'Track'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'album' not in result
|
||||||
|
assert 'track_number' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_regular_video_artwork_is_not_changed():
|
||||||
|
thumbnails = [
|
||||||
|
{'url': 'square.jpg', 'width': 500, 'height': 500},
|
||||||
|
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
|
||||||
|
]
|
||||||
|
result = _preprocess({}, {'title': 'Regular Video', 'thumbnails': thumbnails.copy()})
|
||||||
|
|
||||||
|
assert result['thumbnails'] == thumbnails
|
||||||
|
assert 'thumbnail' not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_music_audio_prefers_largest_existing_square_thumbnail():
|
||||||
|
result = _preprocess(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
'track': 'Track',
|
||||||
|
'thumbnails': [
|
||||||
|
{'url': 'small-square.jpg', 'width': 200, 'height': 200},
|
||||||
|
{'url': 'large-square.jpg', 'width': 1000, 'height': 1000},
|
||||||
|
{'url': 'landscape.jpg', 'width': 1280, 'height': 720},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['thumbnails'][-1]['url'] == 'large-square.jpg'
|
||||||
|
assert result['thumbnail'] == 'large-square.jpg'
|
||||||
|
|
||||||
|
|
||||||
|
def test_landscape_only_music_artwork_keeps_existing_order():
|
||||||
|
thumbnails = [
|
||||||
|
{'url': 'small.jpg', 'width': 640, 'height': 360},
|
||||||
|
{'url': 'large.jpg', 'width': 1280, 'height': 720},
|
||||||
|
]
|
||||||
|
result = _preprocess(
|
||||||
|
{},
|
||||||
|
{'track': 'Track', 'thumbnails': thumbnails.copy()},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result['thumbnails'] == thumbnails
|
||||||
|
assert 'thumbnail' not in result
|
||||||
@@ -73,6 +73,7 @@ import ytdl
|
|||||||
from ytdl import (
|
from ytdl import (
|
||||||
Download,
|
Download,
|
||||||
DownloadInfo,
|
DownloadInfo,
|
||||||
|
MusicMetadataPreProcessor,
|
||||||
_compact_persisted_entry,
|
_compact_persisted_entry,
|
||||||
_convert_srt_to_txt_file,
|
_convert_srt_to_txt_file,
|
||||||
_AlbumArtistPostProcessor,
|
_AlbumArtistPostProcessor,
|
||||||
@@ -201,9 +202,15 @@ class AlbumArtistRegistrationTests(unittest.TestCase):
|
|||||||
result = download._make_youtube_dl({'quiet': True})
|
result = download._make_youtube_dl({'quiet': True})
|
||||||
|
|
||||||
self.assertIs(result, fake_ydl)
|
self.assertIs(result, fake_ydl)
|
||||||
postprocessor, = fake_ydl.add_post_processor.call_args.args
|
album_artist_call = fake_ydl.add_post_processor.call_args_list[0]
|
||||||
|
postprocessor, = album_artist_call.args
|
||||||
self.assertIsInstance(postprocessor, _AlbumArtistPostProcessor)
|
self.assertIsInstance(postprocessor, _AlbumArtistPostProcessor)
|
||||||
self.assertEqual(fake_ydl.add_post_processor.call_args.kwargs, {'when': 'pre_process'})
|
self.assertEqual(album_artist_call.kwargs, {'when': 'pre_process'})
|
||||||
|
metadata_pre_call = fake_ydl.add_post_processor.call_args_list[1]
|
||||||
|
metadata_preprocessor, = metadata_pre_call.args
|
||||||
|
self.assertIsInstance(metadata_preprocessor, MusicMetadataPreProcessor)
|
||||||
|
self.assertEqual(metadata_pre_call.kwargs, {'when': 'pre_process'})
|
||||||
|
self.assertEqual(fake_ydl.add_post_processor.call_count, 2)
|
||||||
|
|
||||||
def test_video_download_does_not_register_postprocessor(self):
|
def test_video_download_does_not_register_postprocessor(self):
|
||||||
download = _make_test_download()
|
download = _make_test_download()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from yt_dlp.postprocessor.common import PostProcessor
|
|||||||
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
|
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
|
||||||
import bg_tasks
|
import bg_tasks
|
||||||
from dl_formats import get_format, get_opts, AUDIO_FORMATS, merge_ytdl_option_layers
|
from dl_formats import get_format, get_opts, AUDIO_FORMATS, merge_ytdl_option_layers
|
||||||
|
from music_metadata import MusicMetadataPreProcessor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf, to_json_compatible
|
from state_store import AtomicJsonStore, from_json_compatible, read_legacy_shelf, to_json_compatible
|
||||||
from subscriptions import _entry_id
|
from subscriptions import _entry_id
|
||||||
@@ -625,6 +626,13 @@ class Download:
|
|||||||
)
|
)
|
||||||
if getattr(self.info, 'download_type', '') == 'audio':
|
if getattr(self.info, 'download_type', '') == 'audio':
|
||||||
ydl.add_post_processor(_AlbumArtistPostProcessor(ydl), when='pre_process')
|
ydl.add_post_processor(_AlbumArtistPostProcessor(ydl), when='pre_process')
|
||||||
|
ydl.add_post_processor(
|
||||||
|
MusicMetadataPreProcessor(
|
||||||
|
ydl,
|
||||||
|
source_entry=getattr(self.info, 'entry', None),
|
||||||
|
),
|
||||||
|
when='pre_process',
|
||||||
|
)
|
||||||
return ydl
|
return ydl
|
||||||
|
|
||||||
def _download(self):
|
def _download(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user