mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-06-22 08:57:25 +00:00
* Add native Music core workflow
* feat(music): add shared music core helpers, album folder template, and UX cleanup
Consolidate duplicated music-service logic into core and extend the native music workflow.
- add core/music/extract.py: shared stateless helpers (first_text, first_number, year/duration/name formatting, classify_release_kind, dedupe_track_options, build_music_from_songs) so services stop carrying their own copies
- add core/music/display.py: shared rich rendering (render_track_panel, render_album_header, render_artwork_preview) with TrackRow / MusicHeaderInfo data holders
- export the new helpers from core/music/__init__.py
- add dedicated `albums` folder template kind (output_template.folder.albums) resolving albums -> songs -> "{artist} - {album} ({year})"; whitelist music template variables (album_artist, track_total, disc_total, release_type, genre, explicit, isrc, upc, label)
- fix song filename crash: config.get_output_template(...) did not exist; use config.output_template.get("songs") with a sane default
- strip emojis from music output (renderer, dl.py music branch) to match unshackle UX; remove dead MusicSongPlan import and music_icon logic
- document the albums folder key in unshackle-example.yaml
- add tests for extract, display, and the folder template
---------
Co-authored-by: MrMovies-Dev <MrMovies-Dev@users.noreply.github.com>
194 lines
6.3 KiB
Python
194 lines
6.3 KiB
Python
"""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
|