mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-15 13:35:05 +00:00
refactor(music)!: make Song an ordinary Title on the generic path
Delete the dead grouped-download block, the planner, and the music service hooks; Song now flows through the one download loop, gets tagged via tag_file, and answers -w with {disc}x{track} keys.
BREAKING CHANGE: GROUP_AUDIO_DOWNLOADS, get_music_track_options and get_music_collection_label are gone. A music service returns one Audio track per quality and lets the framework choose.
This commit is contained in:
@@ -1,213 +0,0 @@
|
||||
"""Unit tests for the shared music rendering helpers in unshackle.core.music.display.
|
||||
|
||||
Rendering is visual, so these assert structure/type/no-crash rather than exact
|
||||
ANSI. No network: the artwork soft-fail path is exercised with a session whose
|
||||
get raises, and (when Pillow is absent) via the missing-dependency guard.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.panel import Panel
|
||||
|
||||
from unshackle.core.music.display import (
|
||||
MusicHeaderInfo,
|
||||
TrackRow,
|
||||
format_track_detail_quality,
|
||||
render_album_header,
|
||||
render_artwork_preview,
|
||||
render_track_panel,
|
||||
)
|
||||
from unshackle.core.titles.music import Song
|
||||
|
||||
|
||||
class DummyService:
|
||||
"""Stand-in service class; Song only requires a type, never an instance."""
|
||||
|
||||
|
||||
def make_song(*, track: int = 1, name: str = "Song", album: str = "Album") -> Song:
|
||||
return Song(
|
||||
id_=f"{album}-{track}",
|
||||
service=DummyService,
|
||||
name=name,
|
||||
artist="Artist",
|
||||
album=album,
|
||||
track=track,
|
||||
disc=1,
|
||||
year=2020,
|
||||
)
|
||||
|
||||
|
||||
def render_to_text(renderable: RenderableType) -> str:
|
||||
"""Render a rich renderable to plain text so we can assert on its content."""
|
||||
console = Console(width=120, record=True, force_terminal=False)
|
||||
console.print(renderable)
|
||||
return console.export_text()
|
||||
|
||||
|
||||
def test_trackrow_constructs() -> None:
|
||||
song = make_song(name="Track A")
|
||||
row = TrackRow(
|
||||
song=song,
|
||||
quality_label="FLAC 16-bit/44.1kHz",
|
||||
layout="Stereo",
|
||||
duration_str="3:21",
|
||||
hires=True,
|
||||
cd=True,
|
||||
note="",
|
||||
)
|
||||
assert row.song is song
|
||||
assert row.quality_label == "FLAC 16-bit/44.1kHz"
|
||||
assert row.layout == "Stereo"
|
||||
assert row.hires is True
|
||||
assert row.cd is True
|
||||
assert row.note == ""
|
||||
|
||||
|
||||
def test_trackrow_defaults() -> None:
|
||||
row = TrackRow(song=make_song(), quality_label="MP3 320", layout="Stereo", duration_str="2:00")
|
||||
assert row.hires is False
|
||||
assert row.cd is False
|
||||
assert row.note == ""
|
||||
|
||||
|
||||
def test_musicheaderinfo_constructs() -> None:
|
||||
info = MusicHeaderInfo(
|
||||
artist="Some Artist",
|
||||
album="Some Album",
|
||||
year="2021",
|
||||
track_count=12,
|
||||
quality_label="FLAC 24-bit/96kHz",
|
||||
duration_str="42:10",
|
||||
)
|
||||
assert info.artist == "Some Artist"
|
||||
assert info.album == "Some Album"
|
||||
assert info.year == "2021"
|
||||
assert info.track_count == 12
|
||||
assert info.quality_label == "FLAC 24-bit/96kHz"
|
||||
assert info.duration_str == "42:10"
|
||||
assert info.artist_label == "Artist"
|
||||
|
||||
|
||||
def test_format_track_detail_quality_brackets_codec() -> None:
|
||||
assert format_track_detail_quality("FLAC 16-bit/44.1kHz") == "[FLAC] | 16-bit/44.1kHz"
|
||||
assert format_track_detail_quality("ogg 320kbps") == "[OGG] | 320kbps"
|
||||
|
||||
|
||||
def test_format_track_detail_quality_passthrough() -> None:
|
||||
assert format_track_detail_quality("Lossless") == "Lossless"
|
||||
assert format_track_detail_quality("") == ""
|
||||
|
||||
|
||||
def test_render_track_panel_returns_panel() -> None:
|
||||
rows = [
|
||||
TrackRow(
|
||||
song=make_song(track=1, name="First"),
|
||||
quality_label="FLAC 16-bit/44.1kHz",
|
||||
layout="Stereo",
|
||||
duration_str="3:00",
|
||||
cd=True,
|
||||
),
|
||||
TrackRow(
|
||||
song=make_song(track=2, name="Second"),
|
||||
quality_label="OGG 320",
|
||||
layout="Stereo",
|
||||
duration_str="4:00",
|
||||
hires=True,
|
||||
),
|
||||
]
|
||||
panel = render_track_panel(rows, total=len(rows))
|
||||
assert isinstance(panel, Panel)
|
||||
|
||||
text = render_to_text(panel)
|
||||
assert "First" in text
|
||||
assert "Second" in text
|
||||
assert "2 Tracks" in text
|
||||
assert "CD" in text
|
||||
assert "Hi-Res" in text
|
||||
|
||||
|
||||
def test_render_track_panel_singular_label() -> None:
|
||||
rows = [
|
||||
TrackRow(song=make_song(name="Only"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00")
|
||||
]
|
||||
text = render_to_text(render_track_panel(rows, total=1))
|
||||
# The count node reads "1 Track" (singular); the panel title is always
|
||||
# "Available Tracks", so we only check the count node phrasing here.
|
||||
assert "1 Track" in text
|
||||
assert "1 Tracks" not in text
|
||||
|
||||
|
||||
def test_render_track_panel_note_marks_unavailable() -> None:
|
||||
rows = [
|
||||
TrackRow(
|
||||
song=make_song(name="Gone"),
|
||||
quality_label="FLAC",
|
||||
layout="Stereo",
|
||||
duration_str="3:00",
|
||||
cd=True,
|
||||
hires=True,
|
||||
note="(unavailable in region)",
|
||||
)
|
||||
]
|
||||
text = render_to_text(render_track_panel(rows, total=1))
|
||||
assert "unavailable in region" in text
|
||||
# When a note is present, the badges are suppressed.
|
||||
assert "CD" not in text
|
||||
assert "Hi-Res" not in text
|
||||
|
||||
|
||||
def test_render_album_header_without_artwork() -> None:
|
||||
info = MusicHeaderInfo(
|
||||
artist="The Artist",
|
||||
album="Greatest Hits",
|
||||
year="1999",
|
||||
track_count=10,
|
||||
quality_label="FLAC 16-bit/44.1kHz",
|
||||
duration_str="55:00",
|
||||
)
|
||||
header = render_album_header(info)
|
||||
assert header is not None
|
||||
text = render_to_text(header)
|
||||
assert "The Artist" in text
|
||||
assert "Greatest Hits" in text
|
||||
assert "1999" in text
|
||||
assert "10" in text
|
||||
|
||||
|
||||
def test_render_album_header_with_artwork() -> None:
|
||||
info = MusicHeaderInfo(
|
||||
artist="Owner Name",
|
||||
album="A Playlist",
|
||||
year="2024",
|
||||
track_count=3,
|
||||
quality_label="OGG 320",
|
||||
artist_label="Owner",
|
||||
)
|
||||
artwork = render_album_header(MusicHeaderInfo("x", "y", "1", 1, "q")) # any renderable
|
||||
header = render_album_header(info, artwork=artwork)
|
||||
assert header is not None
|
||||
text = render_to_text(header)
|
||||
assert "Owner Name" in text
|
||||
assert "A Playlist" in text
|
||||
assert "Owner" in text
|
||||
|
||||
|
||||
class RaisingSession:
|
||||
"""Session stub whose get() always raises, to drive the soft-fail path."""
|
||||
|
||||
def get(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise RuntimeError("network disabled in tests")
|
||||
|
||||
|
||||
def test_render_artwork_preview_empty_url_returns_none() -> None:
|
||||
assert render_artwork_preview(RaisingSession(), "") is None
|
||||
|
||||
|
||||
def test_render_artwork_preview_soft_fails_without_network() -> None:
|
||||
# Either Pillow is absent (guard returns None) or the session.get raises
|
||||
# (caught and returns None). Both paths must yield None, never raise.
|
||||
result: Optional[RenderableType] = render_artwork_preview(RaisingSession(), "https://example.invalid/cover.jpg")
|
||||
assert result is None
|
||||
@@ -9,7 +9,6 @@ import pytest
|
||||
from unshackle.core.music.extract import (
|
||||
build_music_from_songs,
|
||||
classify_release_kind,
|
||||
dedupe_track_options,
|
||||
duration_seconds,
|
||||
first_number,
|
||||
first_text,
|
||||
@@ -17,7 +16,6 @@ from unshackle.core.music.extract import (
|
||||
format_names,
|
||||
year_from_value,
|
||||
)
|
||||
from unshackle.core.music.models import MusicTrackOption
|
||||
from unshackle.core.titles.music import Music, Song
|
||||
|
||||
|
||||
@@ -171,18 +169,6 @@ def test_classify_release_kind(raw_kind: str, count: Optional[float], expected:
|
||||
assert classify_release_kind(raw_kind, count) == expected
|
||||
|
||||
|
||||
def test_dedupe_track_options() -> None:
|
||||
a = MusicTrackOption(codec="flac", bit_depth=16, sample_rate=44100, bitrate=None, quality_label="L", explicit=False)
|
||||
a_dup = MusicTrackOption(
|
||||
codec="FLAC", bit_depth=16, sample_rate=44100, bitrate=None, quality_label="L", explicit=False
|
||||
) # codec case-insensitive duplicate of `a`
|
||||
b = MusicTrackOption(codec="flac", bit_depth=24, sample_rate=96000, bitrate=None, quality_label="H", explicit=False)
|
||||
c = MusicTrackOption(codec="flac", bit_depth=16, sample_rate=44100, bitrate=None, quality_label="L", explicit=True)
|
||||
|
||||
result = dedupe_track_options([a, a_dup, b, c])
|
||||
assert result == [a, b, c]
|
||||
|
||||
|
||||
def test_build_music_from_songs() -> None:
|
||||
songs = [
|
||||
make_song(
|
||||
|
||||
@@ -26,13 +26,13 @@ def make_song(**overrides) -> Song:
|
||||
kwargs = dict(
|
||||
id_="track-0001",
|
||||
service=DummyService,
|
||||
name="NUEVAYoL",
|
||||
artist="Bad Bunny",
|
||||
album="DeBI TiRAR MaS FOToS",
|
||||
name="Example Track",
|
||||
artist="Example Artist",
|
||||
album="Example Album",
|
||||
track=1,
|
||||
disc=1,
|
||||
year=2025,
|
||||
album_artist="Bad Bunny",
|
||||
album_artist="Example Artist",
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return Song(**kwargs)
|
||||
@@ -52,13 +52,13 @@ def reset_folder_config():
|
||||
def test_folder_fallback_when_no_templates(reset_folder_config):
|
||||
song = make_song()
|
||||
result = song.get_filename(StubMediaInfo(), folder=True)
|
||||
assert result == sanitize_filename("Bad Bunny - DeBI TiRAR MaS FOToS (2025)", " ")
|
||||
assert result == sanitize_filename("Example Artist - Example Album (2025)", " ")
|
||||
|
||||
|
||||
def test_albums_template_used(reset_folder_config):
|
||||
reset_folder_config.folder_templates = {"albums": "{album_artist} - {album} ({year})"}
|
||||
result = make_song().get_filename(StubMediaInfo(), folder=True)
|
||||
assert result == sanitize_filename("Bad Bunny - DeBI TiRAR MaS FOToS (2025)", " ")
|
||||
assert result == sanitize_filename("Example Artist - Example Album (2025)", " ")
|
||||
# Album folder must NOT carry per-track info like the song file name does.
|
||||
assert "01" not in result
|
||||
|
||||
@@ -91,3 +91,37 @@ def test_validation_warns_on_unknown_folder_kind():
|
||||
with pytest.warns(UserWarning, match="Unknown folder template kind"):
|
||||
# A non-folder key is required so output-template validation actually runs.
|
||||
Config(output_template={"songs": "{track_number}. {title}", "folder": {"bogus": "{album}"}})
|
||||
|
||||
|
||||
def test_undated_single_degrades_cleanly(reset_folder_config):
|
||||
"""A single with no release year and no disc must not leave empty parens behind."""
|
||||
song = Song(
|
||||
id_="track-0001",
|
||||
service=DummyService,
|
||||
name="Example Track",
|
||||
artist="Example Artist",
|
||||
album="Example Album",
|
||||
track=1,
|
||||
album_artist="Example Artist",
|
||||
) # no disc, no year: both are optional
|
||||
|
||||
assert str(song) == "Example Artist - Example Album (?) / 01. Example Track"
|
||||
assert song.get_filename(StubMediaInfo(), folder=True) == sanitize_filename("Example Artist - Example Album", " ")
|
||||
assert song.get_filename(StubMediaInfo()) == "01.Example.Track" # spacer applies to the title too
|
||||
|
||||
context = song.build_template_context(StubMediaInfo())
|
||||
assert context["year"] == ""
|
||||
assert context["disc"] == "" # a lone disc is never numbered
|
||||
assert context["track_number"] == "01"
|
||||
|
||||
|
||||
def test_undated_album_template_omits_the_year_group(reset_folder_config):
|
||||
reset_folder_config.folder_templates = {"albums": "{album_artist} - {album} ({year?})"}
|
||||
result = make_song(year=None).get_filename(StubMediaInfo(), folder=True)
|
||||
assert result == "Example Artist - Example Album"
|
||||
|
||||
|
||||
def test_songs_separator_survives_a_config_without_a_songs_template(reset_folder_config):
|
||||
"""A user config with output_template but no `songs` key used to raise a KeyError."""
|
||||
reset_folder_config.output_template = {"movies": "{title}.{year}"}
|
||||
assert reset_folder_config.get_template_separator("songs") == "."
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Round-trip ``write_music_metadata`` against real audio files.
|
||||
|
||||
The dispatch test pins which tagger runs; this pins what it writes. Silent audio is
|
||||
generated with ffmpeg, tagged from a fully populated Song, and read back with mutagen.
|
||||
Cover art goes through the same path with a fake session, so no network is touched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.mp4 import MP4
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
|
||||
from unshackle.core.music.tagger import write_music_metadata
|
||||
from unshackle.core.titles.music import Song
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="requires ffmpeg")
|
||||
|
||||
PNG_STUB = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 # magic only; Pillow dimension probing soft-fails
|
||||
|
||||
|
||||
class DummyService:
|
||||
"""Stand-in service class; a Title only requires a type, never an instance."""
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
self.headers = {"Content-Type": "image/png"}
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
self.requested: list[str] = []
|
||||
|
||||
def get(self, url: str, timeout: int = 0) -> FakeResponse:
|
||||
self.requested.append(url)
|
||||
return FakeResponse(self.content)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flac_path(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "04. Example Track.flac"
|
||||
subprocess.run(
|
||||
["ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "0.2", str(path)],
|
||||
check=True,
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def make_song() -> Song:
|
||||
return Song(
|
||||
id_="track-0001",
|
||||
service=DummyService,
|
||||
name="Example Track",
|
||||
artist="Example Artist",
|
||||
album="Example Album, Live & Remastered",
|
||||
track=4,
|
||||
year=1983,
|
||||
album_artist="Example Artist",
|
||||
total_tracks=8,
|
||||
genre="Synth-pop",
|
||||
isrc="ZZABC2400001",
|
||||
artwork_url="https://cdn.example/cover.png",
|
||||
)
|
||||
|
||||
|
||||
def test_flac_tags_read_back(flac_path: Path) -> None:
|
||||
result = write_music_metadata(flac_path, make_song())
|
||||
|
||||
assert result.written
|
||||
assert not result.skipped
|
||||
assert not result.artwork_embedded, "no session was given, so no cover may be embedded"
|
||||
|
||||
audio = FLAC(flac_path)
|
||||
assert audio["TITLE"] == ["Example Track"]
|
||||
assert audio["ARTIST"] == ["Example Artist"]
|
||||
assert audio["ALBUM"] == ["Example Album, Live & Remastered"]
|
||||
assert audio["ALBUMARTIST"] == ["Example Artist"]
|
||||
assert audio["TRACKNUMBER"] == ["4/8"]
|
||||
assert audio["TRACKTOTAL"] == ["8"]
|
||||
assert audio["DATE"] == ["1983"]
|
||||
assert audio["GENRE"] == ["Synth-pop"]
|
||||
assert audio["ISRC"] == ["ZZABC2400001"]
|
||||
|
||||
|
||||
def test_lyrics_land_in_the_container_field_players_read(tmp_path: Path) -> None:
|
||||
"""Each container has one lyrics field. A generic custom tag is not read as lyrics.
|
||||
|
||||
Both the MP3 and MP4 writers fall back to writing an unmapped key as a custom tag
|
||||
(TXXX / freeform atom), so a lyrics field that is not mapped explicitly is written
|
||||
but never displayed.
|
||||
"""
|
||||
lyrics = "Line one\nLine two"
|
||||
song = Song(
|
||||
id_="track-0001",
|
||||
service=DummyService,
|
||||
name="Example Track",
|
||||
artist="Example Artist",
|
||||
album="Example Album",
|
||||
track=1,
|
||||
lyrics=lyrics,
|
||||
)
|
||||
|
||||
for suffix, encoder in ((".flac", "flac"), (".ogg", "libvorbis"), (".mp3", "libmp3lame"), (".m4a", "aac")):
|
||||
path = tmp_path / f"track{suffix}"
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"anullsrc=r=44100:cl=mono",
|
||||
"-t",
|
||||
"0.2",
|
||||
"-c:a",
|
||||
encoder,
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
assert write_music_metadata(path, song).written
|
||||
|
||||
if suffix == ".flac":
|
||||
assert FLAC(path)["LYRICS"] == [lyrics]
|
||||
elif suffix == ".ogg":
|
||||
assert OggVorbis(path)["LYRICS"] == [lyrics]
|
||||
elif suffix == ".mp3":
|
||||
tags = MP3(path).tags
|
||||
assert tags.getall("USLT")[0].text == lyrics
|
||||
assert not tags.getall("TXXX:LYRICS")
|
||||
else:
|
||||
audio = MP4(path)
|
||||
assert audio["\xa9lyr"] == [lyrics]
|
||||
assert not [key for key in audio if "LYRICS" in key]
|
||||
|
||||
|
||||
def test_a_song_without_lyrics_writes_no_lyrics_field(flac_path: Path) -> None:
|
||||
write_music_metadata(flac_path, make_song())
|
||||
|
||||
assert "LYRICS" not in FLAC(flac_path)
|
||||
|
||||
|
||||
def test_cover_art_is_fetched_with_the_session_and_embedded(flac_path: Path) -> None:
|
||||
session = FakeSession(PNG_STUB)
|
||||
|
||||
result = write_music_metadata(flac_path, make_song(), session=session)
|
||||
|
||||
assert result.artwork_embedded
|
||||
assert session.requested == ["https://cdn.example/cover.png"]
|
||||
audio = FLAC(flac_path)
|
||||
assert len(audio.pictures) == 1
|
||||
assert audio.pictures[0].mime == "image/png"
|
||||
assert audio.pictures[0].data == PNG_STUB
|
||||
|
||||
|
||||
def test_a_nested_metadata_dict_reaches_the_tags(flac_path: Path) -> None:
|
||||
"""The renderer reads a nested "metadata" sub-dict, so the tagger must read it too.
|
||||
|
||||
A service that nests its metadata used to lose every tag sourced from song.data,
|
||||
silently and with no log line.
|
||||
"""
|
||||
song = Song(
|
||||
id_="track-0001",
|
||||
service=DummyService,
|
||||
name="Example Track",
|
||||
artist="Example Artist",
|
||||
album="Example Album",
|
||||
track=1,
|
||||
data={"metadata": {"composer": "Example Composer", "release_date": "2020-01-02"}},
|
||||
)
|
||||
|
||||
write_music_metadata(flac_path, song)
|
||||
|
||||
audio = FLAC(flac_path)
|
||||
assert audio["COMPOSER"] == ["Example Composer"]
|
||||
assert audio["RELEASEDATE"] == ["2020-01-02"]
|
||||
assert audio["DATE"] == ["2020-01-02"], "a full release date must beat the bare year"
|
||||
@@ -0,0 +1,126 @@
|
||||
"""`-w`/`--wanted` track selection for music.
|
||||
|
||||
A song shares the episode key space, its disc reading as the season and its track as the
|
||||
episode. These check the whole path a user takes: the tokens they type, the keys the
|
||||
click type makes of them, and the titles the download loop keeps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from unshackle.commands.dl import title_wanted
|
||||
from unshackle.core.titles.movie import Movie
|
||||
from unshackle.core.titles.music import Song
|
||||
from unshackle.core.utils.click_types import SeasonRange
|
||||
|
||||
|
||||
class DummyService:
|
||||
pass
|
||||
|
||||
|
||||
def make_song(track: int = 1, disc: int = 1) -> Song:
|
||||
return Song(
|
||||
id_=f"track-{disc:02}{track:04}",
|
||||
service=DummyService,
|
||||
name=f"Example Track {track}",
|
||||
artist="Example Artist",
|
||||
album="Example Album",
|
||||
track=track,
|
||||
disc=disc,
|
||||
year=2025,
|
||||
album_artist="Example Artist",
|
||||
)
|
||||
|
||||
|
||||
def make_movie() -> Movie:
|
||||
return Movie(id_="movie-0001", service=DummyService, name="Example Movie", year=2025)
|
||||
|
||||
|
||||
def select(tokens: str, songs: list[Song]) -> list[int]:
|
||||
"""Track numbers kept, given what a user types after -w."""
|
||||
wanted = SeasonRange().convert(tokens)
|
||||
return [song.track for song in songs if title_wanted(song, wanted)]
|
||||
|
||||
|
||||
def single_disc(count: int = 8) -> list[Song]:
|
||||
return [make_song(track=n) for n in range(1, count + 1)]
|
||||
|
||||
|
||||
def multi_disc(per_disc: int = 4) -> list[Song]:
|
||||
return [make_song(track=n, disc=d) for d in (1, 2) for n in range(1, per_disc + 1)]
|
||||
|
||||
|
||||
def test_a_bare_number_takes_one_track():
|
||||
assert select("3", single_disc()) == [3]
|
||||
|
||||
|
||||
def test_a_range_takes_the_tracks_inside_it():
|
||||
assert select("2-5", single_disc()) == [2, 3, 4, 5]
|
||||
|
||||
|
||||
def test_a_comma_list_takes_each_track():
|
||||
assert select("1,3,7", single_disc()) == [1, 3, 7]
|
||||
|
||||
|
||||
def test_a_leading_minus_removes_a_track_from_a_range():
|
||||
assert select("1-5,-3", single_disc()) == [1, 2, 4, 5]
|
||||
|
||||
|
||||
def test_the_disc_form_takes_one_track_of_that_disc():
|
||||
songs = multi_disc()
|
||||
kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("2x3"))]
|
||||
assert kept == [(2, 3)]
|
||||
|
||||
|
||||
def test_the_disc_form_ranges_inside_one_disc():
|
||||
songs = multi_disc()
|
||||
kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("2x1-2x3"))]
|
||||
assert kept == [(2, 1), (2, 2), (2, 3)]
|
||||
|
||||
|
||||
def test_a_bare_number_on_a_multi_disc_release_is_disc_one():
|
||||
"""An omitted disc is disc 1, the number the tracklist shows for a one-disc release."""
|
||||
songs = multi_disc()
|
||||
kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("1-2"))]
|
||||
assert kept == [(1, 1), (1, 2)]
|
||||
|
||||
|
||||
def test_discs_mix_in_one_selection():
|
||||
songs = multi_disc()
|
||||
kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("1,2x4"))]
|
||||
assert kept == [(1, 1), (2, 4)]
|
||||
|
||||
|
||||
def test_a_selection_that_matches_nothing_keeps_no_track():
|
||||
assert select("40-45", single_disc()) == []
|
||||
|
||||
|
||||
def test_the_whole_release_downloads_without_a_selection():
|
||||
songs = single_disc()
|
||||
assert [s.track for s in songs if title_wanted(s, [])] == [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
|
||||
|
||||
def test_a_movie_stays_selected_whatever_the_tokens_are():
|
||||
"""A movie has no selection key, so -w must not remove it."""
|
||||
assert title_wanted(make_movie(), SeasonRange().convert("1-5")) is True
|
||||
|
||||
|
||||
def test_a_range_across_discs_takes_the_rest_of_the_first_disc():
|
||||
"""Same rule as a season range: the left disc runs to its end before the right one starts."""
|
||||
songs = multi_disc()
|
||||
kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("1x3-2x2"))]
|
||||
assert kept == [(1, 3), (1, 4), (2, 1), (2, 2)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", ["1x", "x1", "1x2x3", "1.2"])
|
||||
def test_bad_music_tokens_fail(token):
|
||||
with pytest.raises(click.UsageError):
|
||||
SeasonRange().parse_tokens(token)
|
||||
|
||||
|
||||
def test_song_matches_only_its_own_key():
|
||||
song = make_song(track=3, disc=2)
|
||||
assert song.matches_wanted(["2x3"]) is True
|
||||
assert song.matches_wanted(["1x3", "2x4"]) is False
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Which tagger ``tag_file`` picks per title type.
|
||||
|
||||
A Song is an audio file, so it must reach mutagen and never mkvpropedit. The bug this
|
||||
pins ran ``mkvpropedit`` against a ``.flac``. Movies and Episodes keep the Matroska path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.music.tagger import MusicMetadataResult
|
||||
from unshackle.core.titles.episode import Episode
|
||||
from unshackle.core.titles.movie import Movie
|
||||
from unshackle.core.titles.music import Song
|
||||
from unshackle.core.utils import tags as tags_module
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
SESSION = object() # sentinel: tag_file must hand this straight to the music tagger
|
||||
|
||||
|
||||
class DummyService:
|
||||
"""Stand-in service class; a Title only requires a type, never an instance."""
|
||||
|
||||
|
||||
class Calls:
|
||||
"""Records which tagger ran, and with what."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.music: list[tuple[Path, Any, Any]] = []
|
||||
self.mkv: list[tuple[Path, dict[str, str]]] = []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calls(monkeypatch: pytest.MonkeyPatch) -> Calls:
|
||||
recorded = Calls()
|
||||
|
||||
def fake_write_music_metadata(path: Path, song: Any, *, session: Any = None, source_md5: str = "") -> Any:
|
||||
recorded.music.append((path, song, session))
|
||||
return MusicMetadataResult(written=True)
|
||||
|
||||
def fake_apply_tags(path: Path, tags: dict[str, str]) -> None:
|
||||
recorded.mkv.append((path, tags))
|
||||
|
||||
monkeypatch.setattr(tags_module, "write_music_metadata", fake_write_music_metadata)
|
||||
monkeypatch.setattr(tags_module, "apply_tags", fake_apply_tags)
|
||||
monkeypatch.setattr(config, "tag_imdb_tmdb", False) # keep provider lookups out of a unit test
|
||||
return recorded
|
||||
|
||||
|
||||
def make_song(**overrides: Any) -> Song:
|
||||
kwargs: dict[str, Any] = dict(
|
||||
id_="track-0001",
|
||||
service=DummyService,
|
||||
name="Example Track",
|
||||
artist="Example Artist",
|
||||
album="Example Album, Live & Remastered",
|
||||
track=4,
|
||||
year=1983,
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return Song(**kwargs)
|
||||
|
||||
|
||||
def test_a_song_is_tagged_by_the_music_tagger(calls: Calls) -> None:
|
||||
path = Path("04. Example Track.flac")
|
||||
song = make_song()
|
||||
|
||||
tags_module.tag_file(path, song, session=SESSION)
|
||||
|
||||
assert calls.music == [(path, song, SESSION)], "the music tagger needs the session for cover art"
|
||||
assert calls.mkv == [], "mkvpropedit must never touch an audio container"
|
||||
|
||||
|
||||
def test_a_skipped_song_warns_with_the_reason(
|
||||
calls: Calls, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
tags_module,
|
||||
"write_music_metadata",
|
||||
lambda path, song, **kwargs: MusicMetadataResult(skipped=True, reason="install mutagen to write FLAC tags"),
|
||||
)
|
||||
with caplog.at_level("WARNING", logger="TAGS"):
|
||||
tags_module.tag_file(Path("04. Example Track.flac"), make_song(), session=SESSION)
|
||||
assert "install mutagen to write FLAC tags" in caplog.text
|
||||
|
||||
|
||||
def test_a_tagger_fault_warns_and_never_raises(
|
||||
calls: Calls, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The file already moved when tag_file runs, so a corrupt file must not abort the run."""
|
||||
|
||||
def broken_tagger(path: Path, song: Any, **kwargs: Any) -> Any:
|
||||
raise OSError("not a FLAC stream")
|
||||
|
||||
monkeypatch.setattr(tags_module, "write_music_metadata", broken_tagger)
|
||||
with caplog.at_level("WARNING", logger="TAGS"):
|
||||
tags_module.tag_file(Path("04. Example Track.flac"), make_song(), session=SESSION)
|
||||
assert "not a FLAC stream" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"title",
|
||||
[
|
||||
Movie(id_="movie-0001", service=DummyService, name="Some Movie", year=2024),
|
||||
Episode(id_="episode-0001", service=DummyService, title="Some Show", season=1, number=2, name="Pilot"),
|
||||
],
|
||||
ids=["movie", "episode"],
|
||||
)
|
||||
def test_a_movie_or_episode_still_takes_the_mkv_path(calls: Calls, title: Any) -> None:
|
||||
path = Path("Some Title.mkv")
|
||||
|
||||
tags_module.tag_file(path, title)
|
||||
|
||||
assert calls.music == [], "write_music_metadata must not see a Matroska file"
|
||||
assert [call[0] for call in calls.mkv] == [path]
|
||||
@@ -13,8 +13,7 @@ import pytest
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.titles.episode import Episode
|
||||
from unshackle.core.titles.movie import Movie
|
||||
from unshackle.core.titles.music import Song as MusicSong
|
||||
from unshackle.core.titles.song import Song
|
||||
from unshackle.core.titles.music import Song
|
||||
from unshackle.core.utilities import sanitize_filename
|
||||
from unshackle.core.utils.template_formatter import TemplateFormatter, detect_spacer
|
||||
|
||||
@@ -183,7 +182,7 @@ def make_album_song(**overrides):
|
||||
album_artist="Some Artist",
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return MusicSong(**kwargs)
|
||||
return Song(**kwargs)
|
||||
|
||||
|
||||
def assert_safe_path(result: str):
|
||||
|
||||
Reference in New Issue
Block a user