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:
Alex Shnitman
2026-07-24 12:00:38 +03:00
4 changed files with 260 additions and 2 deletions
+119
View File
@@ -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
+9 -2
View File
@@ -73,6 +73,7 @@ import ytdl
from ytdl import (
Download,
DownloadInfo,
MusicMetadataPreProcessor,
_compact_persisted_entry,
_convert_srt_to_txt_file,
_AlbumArtistPostProcessor,
@@ -201,9 +202,15 @@ class AlbumArtistRegistrationTests(unittest.TestCase):
result = download._make_youtube_dl({'quiet': True})
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.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):
download = _make_test_download()