Files
unshackle/tests/orchestration/test_music_remux_flow.py
T
imSp4rky a4be2cd23a fix(music): remux songs into the container of their codec
A song skips the muxer, so the decrypted fragmented MP4 was the delivered file: its header states a length of zero and FLAC in an MP4 is not a FLAC stream, so playback stopped early and tagging failed.
2026-08-21 14:00:04 -06:00

123 lines
4.9 KiB
Python

"""A Song must end as a playable, correctly named, correctly tagged music file.
``remux_music_tracks`` (``unshackle/commands/dl.py``) is the hook the download loop runs
on every title after repack and before naming. These tests drive the real hook, then the
real naming, move, and tagging seams in the order ``dl.result()`` composes them. Only the
surrounding Click command does not run, because it needs a service, a CDM, and a network.
Every music-specific decision it makes lives in the seams driven here.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
import mutagen
import pytest
from mutagen.flac import FLAC
from pymediainfo import MediaInfo
from tests.core.test_music_container import fragmented_mp4, header_length
from tests.core.test_music_tagger_roundtrip import make_song
from unshackle.commands.dl import remux_music_tracks
from unshackle.core.config import config
from unshackle.core.tracks import Audio, Tracks
from unshackle.core.utils import tags
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="requires ffmpeg")
log = logging.getLogger("test_music_remux_flow")
@pytest.fixture
def default_templates():
"""Pin the naming templates, because the suite shares the live global config."""
saved = (config.output_template, config.folder_templates, config.folder_template, config.tag_imdb_tmdb)
config.output_template = {"songs": "{track_number}. {title}"}
config.folder_templates = {}
config.folder_template = ""
config.tag_imdb_tmdb = False # keep provider lookups out of the test
yield
config.output_template, config.folder_templates, config.folder_template, config.tag_imdb_tmdb = saved
def make_audio(path: Path, codec: Audio.Codec, track_id: str) -> Audio:
track = Audio(id_=track_id, url=f"https://example.test/{track_id}.mp4", language="en", codec=codec)
track.path = path
return track
def test_the_hook_remuxes_every_audio_track_of_a_song(tmp_path: Path) -> None:
song = make_song()
song.tracks = Tracks(
[
make_audio(fragmented_mp4(tmp_path / "Audio_a1.mp4", "flac"), Audio.Codec.FLAC, "a1"),
make_audio(fragmented_mp4(tmp_path / "Audio_a2.mp4", "aac"), Audio.Codec.AAC, "a2"),
]
)
remux_music_tracks(song, log)
assert [t.path.suffix for t in song.tracks.audio] == [".flac", ".m4a"]
for track in song.tracks.audio:
assert header_length(track.path) == pytest.approx(6.0, abs=0.5)
def test_one_broken_track_warns_and_does_not_stop_the_rest(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
broken = tmp_path / "Audio_a1.mp4"
broken.write_bytes(b"") # ffmpeg cannot read this, so its remux must fail
song = make_song()
song.tracks = Tracks(
[
make_audio(broken, Audio.Codec.AAC, "a1"),
make_audio(fragmented_mp4(tmp_path / "Audio_a2.mp4", "aac"), Audio.Codec.AAC, "a2"),
]
)
with caplog.at_level(logging.WARNING, logger=log.name):
remux_music_tracks(song, log)
assert "Could not remux audio track a1" in caplog.text
assert song.tracks.audio[0].path == broken, "a failed remux must keep the downloaded file"
assert song.tracks.audio[1].path.suffix == ".m4a", "the failure must not stop the other tracks"
def test_a_non_song_title_is_left_alone(tmp_path: Path) -> None:
source = fragmented_mp4(tmp_path / "Audio_a1.mp4", "aac")
track = make_audio(source, Audio.Codec.AAC, "a1")
class NotASong:
tracks = Tracks([track])
remux_music_tracks(NotASong(), log)
assert track.path == source
def test_a_fragmented_flac_song_ends_playable_named_and_tagged(tmp_path: Path, default_templates: None) -> None:
"""The full post-download flow: hook, MediaInfo naming, final move, music tagging."""
song = make_song()
source = fragmented_mp4(tmp_path / "Audio_track-0001.mp4", "flac") # the downloader's naming scheme
track = make_audio(source, Audio.Codec.FLAC, "track-0001")
song.tracks = Tracks([track])
assert header_length(source) == 0.0, "the fixture must reproduce the fault the remux corrects"
remux_music_tracks(song, log)
# the naming and move seams, with the arguments dl.result() hands them
media_info = MediaInfo.parse(track.path)
final_dir = tmp_path / "downloads" / song.get_filename(media_info, show_service=False, folder=True)
final_dir.mkdir(parents=True)
final_path = final_dir / f"{song.get_filename(media_info, show_service=False)}{track.path.suffix}"
shutil.move(track.path, final_path)
tags.tag_file(final_path, song)
assert final_path == final_dir / "04.Example.Track.flac"
assert not source.exists(), "the fragmented source must be gone"
assert mutagen.File(final_path).info.length == pytest.approx(6.0, abs=0.5)
audio = FLAC(final_path)
assert audio["TITLE"] == ["Example Track"]
assert audio["ARTIST"] == ["Example Artist"]