Files
unshackle/unshackle/core/music/display.py
sp4rk.y 78a6a97fcf feat(music): native music core - shared helpers, album folder template, display cleanup (#125)
* 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>
2026-06-15 13:34:34 -06:00

176 lines
6.2 KiB
Python

"""Shared rich-based rendering helpers for music releases.
Data-in / renderable-out: services do their own quality and field extraction,
then hand plain data here. The helpers know nothing about how a ``quality_label``
was derived; they only style and lay it out. Quality-schema parsing stays
per-service while panel/header/artwork rendering lives in one place.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from io import BytesIO
from typing import Any, Optional
# RenderableType is rich's union of "things that can be printed to a console".
from rich.console import Group, RenderableType
from rich.padding import Padding
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
from unshackle.core.titles.music import Song
@dataclass
class TrackRow:
"""One row in the track tree, already formatted by the calling service.
When ``note`` is set the row is treated as unavailable: the quality label is
styled red, ``note`` is appended muted, and the ``cd``/``hires`` badges are
suppressed. Otherwise the normal detail line is built and ``cd`` drives a
"CD" badge.
"""
song: Song
quality_label: str
layout: str
duration_str: str
hires: bool = False
cd: bool = False
note: str = ""
@dataclass
class MusicHeaderInfo:
"""Plain data holder for the release-header metadata grid."""
artist: str
album: str
year: str
track_count: int
quality_label: str
duration_str: str = ""
artist_label: str = "Artist"
# Quality strings look like "FLAC 16-bit/44.1kHz"; split the codec token off the
# front and bracket it so the detail line reads "[FLAC] | 16-bit/44.1kHz".
QUALITY_DETAIL_RE = re.compile(r"^(FLAC|OGG|AAC|MP3)\s+(.+)$", flags=re.IGNORECASE)
def format_track_detail_quality(quality_label: str) -> str:
"""Bracket the leading codec token of a quality string for the detail line."""
text = str(quality_label or "").strip()
match = QUALITY_DETAIL_RE.match(text)
if match:
return f"[{match.group(1).upper()}] | {match.group(2).strip()}"
return text
def render_artwork_preview(
session: Any,
artwork_url: str,
*,
width: int = 25,
) -> Optional[RenderableType]:
"""Fetch artwork via ``session`` and render it as half-block coloured cells.
Pillow (``PIL``) is an optional dependency; any error (missing PIL, network,
bad image, zero dimensions) soft-fails to ``None`` so callers skip the preview.
"""
if not artwork_url:
return None
try:
from PIL import Image
except Exception:
return None
try:
response = session.get(artwork_url, timeout=20)
response.raise_for_status()
image = Image.open(BytesIO(response.content)).convert("RGB")
if not image.width or not image.height:
return None
height = max(1, int(width * image.height / image.width * 0.5))
# Image.Resampling exists on Pillow >= 9.1; fall back gracefully on older.
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1)
image = image.resize((width, height * 2), resampling)
lines = []
for y in range(0, image.height, 2):
line = Text()
for x in range(image.width):
top = image.getpixel((x, y))
bottom = image.getpixel((x, min(y + 1, image.height - 1)))
top_color = f"#{top[0]:02x}{top[1]:02x}{top[2]:02x}"
bottom_color = f"#{bottom[0]:02x}{bottom[1]:02x}{bottom[2]:02x}"
# Lower-half block fg=bottom/bg=top packs two pixel rows per cell.
line.append("", style=f"{bottom_color} on {top_color}")
lines.append(line)
return Group(*lines)
except Exception:
return None
def render_track_panel(rows: list[TrackRow], total: int) -> Panel:
"""Render the per-track tree (one node + detail line each) in a Panel."""
track_label = "Track" if total == 1 else "Tracks"
tree = Tree(f"[repr.number]{total}[/] {track_label}", guide_style="bright_black")
for row in rows:
title_line = Text(f"{row.song.track:02}", style="repr.number")
title_line.append(" ")
title_line.append(row.song.name, style="bold #009900")
node = tree.add(title_line, guide_style="bright_black")
detail = Text()
if row.note:
detail.append(row.quality_label, style="red")
detail.append(" ")
detail.append(row.note, style="bright_black")
else:
detail.append(format_track_detail_quality(row.quality_label))
if row.layout:
detail.append(" | ")
detail.append(row.layout)
if row.duration_str:
detail.append(" | ")
detail.append(row.duration_str)
if row.cd:
detail.append(" | ")
detail.append("CD", style="yellow1")
if row.hires:
detail.append(" | ")
detail.append("Hi-Res", style="gold1")
node.add(detail, guide_style="bright_black")
return Panel(tree, title="Available Tracks")
def render_album_header(
info: MusicHeaderInfo,
artwork: Optional[RenderableType] = None,
) -> Optional[RenderableType]:
"""Render the release metadata grid, optionally placing artwork beside it."""
# Table.grid is a borderless table used purely for column alignment.
grid = Table.grid(padding=(0, 2))
grid.add_column(style="orchid1", no_wrap=True)
grid.add_column()
grid.add_row(info.artist_label, Text(info.artist, style="bold #ff0000"))
grid.add_row("Collection", Text(info.album, style="blue"))
grid.add_row("Year", Text(info.year, style="blue"))
grid.add_row("Tracks", Text(str(info.track_count), style="blue"))
grid.add_row("Quality", Text(info.quality_label, style="blue"))
if info.duration_str:
grid.add_row("Length", Text(info.duration_str, style="blue"))
if not artwork:
return grid
header = Table.grid(expand=True, padding=(0, 2))
header.add_column(no_wrap=True)
header.add_column(ratio=1)
header.add_row(artwork, Padding(grid, (3, 0, 0, 0)))
return header