From 45d9dbffd5e511468bb58c65b5b16feca78b070d Mon Sep 17 00:00:00 2001 From: Kenth Fagerlund Date: Fri, 11 Sep 2026 18:18:01 +0100 Subject: [PATCH 1/2] feat(lyrics,art): real-time synced lyrics & multi-protocol terminal album art Closes #23. Adds two new zero-dependency engine packages and wires them into the TUI. pkg/lyrics - LRCLIB exact lookup, LRCLIB search fallback, then NetEase. - LRC parser handling [mm:ss(.xx|.xxx)], multiple stamps per line and [offset:NNN]; ActiveIndex/Progress resolve the line for a playback offset. - SplitTrackTitle turns dirty ICY titles into artist/title, returning nothing for station names and advert slugs so no pointless lookup is made. - RAM LRU plus a disk cache under ~/.cache/halpradio/lyrics/, with negative caching so unmatched stations do not hammer the providers. pkg/art - Cover lookup from iTunes, Deezer, MusicBrainz/Cover Art Archive and optionally Last.fm, capped and validated before use. - Terminal renderers for the Kitty graphics protocol, iTerm2 inline images, Sixel, truecolor half-blocks and Braille, plus env-based auto-detection. - Every renderer returns exactly `rows` lines whose lipgloss.Width equals `cols`, so escape-sequence transports cannot shift the Bubble Tea layout. TUI integration - L opens a lyrics drawer that takes its own columns rather than overlapping the station list, auto-scrolling around the active line with a progress gauge; j/k scroll unsynced sheets and , / . nudge the sync, which persists. - A opens a floating cover art viewer; the drawer also carries a thumbnail when the terminal is tall enough to spare the rows. - Lookups run as tea.Cmds keyed on a station+track signature, so every play path is covered and late results for a superseded track are dropped. - Artwork is rasterised in the update loop on a new cover, a resize or a surface toggle, keeping component views pure; a closed surface emits the protocol's image-delete escape so Kitty placements do not linger. While in the analog tuner, L keeps sweeping the dial. That branch also had its step sign inverted, so a fast sweep right now raises the frequency. No new module dependencies. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 6 + CLAUDE.md | 8 +- README.md | 63 ++ docs/ARCHITECTURE.md | 6 +- docs/CONFIGURATION.md | 8 + docs/LYRICS_AND_ART.md | 197 ++++++ pkg/art/braille.go | 140 ++++ pkg/art/cache.go | 281 ++++++++ pkg/art/cache_test.go | 331 +++++++++ pkg/art/client.go | 527 ++++++++++++++ pkg/art/client_test.go | 706 +++++++++++++++++++ pkg/art/detect.go | 178 +++++ pkg/art/detect_test.go | 160 +++++ pkg/art/halfblock.go | 69 ++ pkg/art/halfblock_test.go | 111 +++ pkg/art/iterm2.go | 19 + pkg/art/kitty.go | 61 ++ pkg/art/render.go | 118 ++++ pkg/art/render_test.go | 306 ++++++++ pkg/art/resize.go | 207 ++++++ pkg/art/resize_test.go | 214 ++++++ pkg/art/sixel.go | 330 +++++++++ pkg/art/sixel_test.go | 242 +++++++ pkg/lyrics/cache.go | 319 +++++++++ pkg/lyrics/cache_test.go | 428 ++++++++++++ pkg/lyrics/client.go | 555 +++++++++++++++ pkg/lyrics/client_test.go | 1122 ++++++++++++++++++++++++++++++ pkg/lyrics/lrc.go | 158 +++++ pkg/lyrics/lrc_test.go | 214 ++++++ pkg/lyrics/sheet.go | 120 ++++ pkg/lyrics/sheet_test.go | 203 ++++++ pkg/lyrics/title.go | 261 +++++++ pkg/lyrics/title_test.go | 237 +++++++ pkg/ui/components/art.go | 106 +++ pkg/ui/components/art_test.go | 81 +++ pkg/ui/components/lyrics.go | 397 +++++++++++ pkg/ui/components/lyrics_test.go | 210 ++++++ pkg/ui/components/whichkey.go | 5 + pkg/ui/keymap.go | 20 + pkg/ui/model.go | 51 ++ pkg/ui/nowplaying.go | 469 +++++++++++++ pkg/ui/nowplaying_test.go | 575 +++++++++++++++ pkg/ui/update.go | 139 +++- pkg/ui/view.go | 93 ++- pkg/util/config.go | 49 ++ 45 files changed, 10071 insertions(+), 29 deletions(-) create mode 100644 docs/LYRICS_AND_ART.md create mode 100644 pkg/art/braille.go create mode 100644 pkg/art/cache.go create mode 100644 pkg/art/cache_test.go create mode 100644 pkg/art/client.go create mode 100644 pkg/art/client_test.go create mode 100644 pkg/art/detect.go create mode 100644 pkg/art/detect_test.go create mode 100644 pkg/art/halfblock.go create mode 100644 pkg/art/halfblock_test.go create mode 100644 pkg/art/iterm2.go create mode 100644 pkg/art/kitty.go create mode 100644 pkg/art/render.go create mode 100644 pkg/art/render_test.go create mode 100644 pkg/art/resize.go create mode 100644 pkg/art/resize_test.go create mode 100644 pkg/art/sixel.go create mode 100644 pkg/art/sixel_test.go create mode 100644 pkg/lyrics/cache.go create mode 100644 pkg/lyrics/cache_test.go create mode 100644 pkg/lyrics/client.go create mode 100644 pkg/lyrics/client_test.go create mode 100644 pkg/lyrics/lrc.go create mode 100644 pkg/lyrics/lrc_test.go create mode 100644 pkg/lyrics/sheet.go create mode 100644 pkg/lyrics/sheet_test.go create mode 100644 pkg/lyrics/title.go create mode 100644 pkg/lyrics/title_test.go create mode 100644 pkg/ui/components/art.go create mode 100644 pkg/ui/components/art_test.go create mode 100644 pkg/ui/components/lyrics.go create mode 100644 pkg/ui/components/lyrics_test.go create mode 100644 pkg/ui/nowplaying.go create mode 100644 pkg/ui/nowplaying_test.go diff --git a/AGENTS.md b/AGENTS.md index 261392d..0b48636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ halpradio/ ├── party/ # P2P mesh synchronized radio rooms, Argon2id/AES-256-GCM E2EE & protocol engine ├── plugin/ # Wazero Wasm sandbox, capability permissions, host API, registry client ├── radio/ # Store (bundled/local/favorites), Station struct, RadioBrowser HTTP client + ├── lyrics/ # LRCLIB & NetEase lyric providers, LRC parser, RAM + disk cache + ├── art/ # Cover art providers & Kitty/iTerm2/Sixel/half-block/Braille renderers ├── theme/theme.go # Theme struct & color palettes (tokyonight, catppuccin, synthwave, nord, gruvbox, dracula) ├── timer/ # Pomodoro focus interval engine, sleep timer with volume fade, OS event dispatcher ├── ui/ # Model, Update loop, View orchestrator, keymaps @@ -69,6 +71,10 @@ halpradio/ - **Rule**: Never hardcode hex color strings (e.g. `#7aa2f7`) inside component files. - Always use active theme tokens provided by `m.theme` (e.g. `theme.Primary`, `theme.Secondary`, `theme.Border`, `theme.Playing`). +### 3b. Terminal Image Rendering (`pkg/art`) +- Every renderer must return exactly `rows` lines whose `lipgloss.Width` equals `cols`. Escape-sequence transports (Kitty APC, iTerm2 OSC 1337, Sixel DCS) pad with spaces so Bubble Tea's layout arithmetic still holds. +- **Rule**: Rasterise artwork in [`pkg/ui/update.go`](./pkg/ui/update.go) (on a new cover or a `tea.WindowSizeMsg`), never inside a component `View()`. + ### 4. Error Handling & TUI Resilience - Audio stream errors or invalid URLs should update `player.Manager` status to `StatusError` or populate `lastError`. - **Rule**: Never call `panic()` or `os.Exit()` inside UI updates or stream handlers. The TUI must remain interactive even when a stream fails. diff --git a/CLAUDE.md b/CLAUDE.md index 2daa95c..b86baf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,14 @@ - `pkg/player/player.go`: Multi-backend player manager (`mpv`, `vlc`, `ffplay`, etc.) + native Go fallback (`oto/v3` + `go-mp3`) and ICY stream metadata listener. - `pkg/radio/store.go`: Station catalog store (`bundled`, `local`, `favorites`), YAML/JSON persistence. - `pkg/radio/radiobrowser.go`: RadioBrowser HTTP search client. +- `pkg/lyrics/`: LRCLIB + NetEase lyric providers, LRC timestamp parser, RAM/disk cache. +- `pkg/art/`: cover art providers and terminal image renderers (Kitty, iTerm2, Sixel, half-block, Braille). - `pkg/theme/theme.go`: Theme definitions (`tokyonight`, `catppuccin`, `synthwave`, `nord`, `gruvbox`, `dracula`). - `pkg/timer/`: Pomodoro focus state machine, sleep timer countdown, and OS notification dispatcher. - `pkg/ui/model.go` & `update.go` & `view.go`: Bubble Tea Model, Update loop, View orchestrator. -- `pkg/ui/components/`: Sub-views (`header`, `sidebar`, `stationlist`, `playerbar`, `statusbar`, `visualizer`, `modals`, `whichkey`). -- `pkg/util/`: Path resolution (`~/.config/halpradio/`) and clipboard helper. +- `pkg/ui/components/`: Sub-views (`header`, `sidebar`, `stationlist`, `playerbar`, `statusbar`, `visualizer`, `modals`, `whichkey`, `lyrics`, `art`). +- `pkg/ui/nowplaying.go`: Lyric/artwork lookup commands, sync offset, and the artwork rasterisation step. +- `pkg/util/`: Path resolution (`~/.config/halpradio/`, `~/.cache/halpradio/`) and clipboard helper. ## 🎨 Code Style & Architectural Constraints 1. **Thread Safety**: Always protect shared state in `player.Manager` with `m.mu.Lock()` / `m.mu.Unlock()`. @@ -29,3 +32,4 @@ 4. **Theme Tokens**: Never hardcode hex color strings in UI components. Use `theme.Primary`, `theme.Border`, `theme.Playing`, etc. 5. **Resilience**: Never call `panic()` or `os.Exit()` on playback errors. Set `m.status = StatusError` and let the TUI inform the user gracefully. 6. **Verification**: Always run `go test ./...` and `gofmt -s -w .` after making modifications. +7. **Terminal Images**: Every `art.Renderer` protocol must return exactly `rows` lines whose `lipgloss.Width` equals `cols`, so escape-sequence transports cannot shift the surrounding layout. Rasterise artwork in `pkg/ui/update.go`, never inside a component `View()`. diff --git a/README.md b/README.md index 66a574a..b7f52dd 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,65 @@ Internet radio ICY streams often emit dirty titles like: --- +## 📜 Real-Time Synced Karaoke Lyrics & Multi-Protocol Terminal Album Art + +Modern terminals grew real graphics capabilities, so `halpradio` uses them. Press `L` for a live lyric sheet that scrolls itself, and `A` for the cover art of whatever is on air — all without leaving the terminal. + +### 🎤 Live Synced Lyrics Drawer (`L` key) +- **LRCLIB First**: Queries [LRCLIB](https://lrclib.net) with the artist, title and duration taken from ICY stream metadata or the acoustic fingerprint, then falls back to NetEase when LRCLIB has no match. +- **Auto-Scrolling Karaoke View**: When timestamped `.lrc` data exists, the drawer highlights the line being sung, fades the surrounding lines, and draws a progress gauge across the active line. +- **Manual Scroll For Plain Text**: Unsynced lyrics render as a formatted sheet you scroll with `j` / `k`. +- **Sync Nudge**: Internet radio exposes no seek position, so the lyric clock starts when the station announces a new title. Press `,` and `.` to shift the sync in 0.5 second steps when a station announces late or early. +- **Never Blocks The UI**: Every lookup runs as a Bubble Tea command off the update loop, so the TUI stays responsive on slow connections, and the drawer takes its own columns rather than overlapping the station list. +- **Disk & Memory Cache**: Sheets are memoised in RAM and cached under `~/.cache/halpradio/lyrics/`, and stations with no match are negative-cached so the APIs are not hammered every track. + +### 🖼️ Multi-Protocol Album Art (`A` key) +`halpradio` detects your terminal's best image transport at startup and encodes artwork for it: + +| Priority | Protocol | Terminals | +|---|---|---| +| 1 | **Kitty Graphics** | Ghostty, Kitty, WezTerm | +| 2 | **iTerm2 Inline Images** | iTerm2, WezTerm | +| 3 | **Sixel** | Foot, xterm, mlterm, yaft | +| 4 | **Truecolor Half-Block** | every 24-bit colour terminal | +| 5 | **Braille** | 256-colour and monochrome fallback | + +- **High-Res Cover Lookup**: Artwork is resolved from the iTunes Search API, Deezer, MusicBrainz plus the [Cover Art Archive](https://coverartarchive.org/), and Last.fm when you supply `lastfm_api_key`. +- **No Distortion On Basic Terminals**: The half-block and Braille renderers letterbox the image to keep covers square, and every renderer emits output padded to an exact cell grid so the surrounding layout never shifts. +- **Two Surfaces**: A thumbnail sits at the top of the lyrics drawer, and `A` opens a floating full-size viewer showing the album, the provider and the active protocol. +- **Cached Locally**: Downloaded covers live under `~/.cache/halpradio/art/`. + +```text +┌─ 📻 CATALOG ──────────────────┬─ 📜 LIVE LYRICS ───────────────┐ +│ ▶ SomaFM Groove Salad │ ▄▄▄▄▄▄▄▄▄▄▄▄ │ +│ Nightwave Plaza │ █ ALBUM ART █ │ +│ Radio Paradise │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ +│ KEXP 90.3 │ 🖼 iTunes │ +│ │ Tycho - A Walk │ +│ │ │ +│ │ I've been wandering │ +│ │ ► Searching for a signal ◄ │ +│ │ Everything is quiet │ +│ │ ━━━━━━━━━━━─────── │ +│ │ ⏱ Synced via LRCLIB │ +│ │ L close · , . sync │ +└───────────────────────────────┴────────────────────────────────┘ +``` + +Tune the feature from `~/.config/halpradio/config.yaml`: +```yaml +lyrics_enabled: true # LRCLIB / NetEase synced lyrics engine +lyrics_auto_open: false # open the drawer on startup +lyrics_offset_ms: 0 # persistent sync correction +album_art_enabled: true # terminal cover art renderer +album_art_protocol: auto # auto | kitty | iterm2 | sixel | halfblock | braille | off +lastfm_api_key: "" # optional extra cover art provider +``` + +Set `HALPRADIO_NO_ART=1` to disable image rendering for a single run, or `HALPRADIO_ART_PROTOCOL=halfblock` to force a transport when detection guesses wrong. + +--- + ## 🎉 Terminal Party Line: P2P Mesh Synchronized Radio Rooms & Reactions Share the groove with teammates, study groups, or friends with zero central audio relaying! `halpradio` features an end-to-end encrypted (E2EE) P2P mesh party system powered by WebRTC data channels: @@ -378,6 +437,9 @@ Press `?` or `F1` anywhere in **halpradio** to open the floating **WhichKey Over | **Discovery & Sharing** | `Ctrl+p` | Open **Party Room Manager** (P2P mesh synchronized listening & room setup) | | | `1` - `5` | Send live floating ASCII reaction (🔥 ❤️ ☕ 🚀 👀) when in Party Room | | | `I` | **Identify playing track** via acoustic stream fingerprinting (Chromaprint / AcoustID) | +| | `L` | Toggle **live synced lyrics drawer** (LRCLIB / NetEase) | +| | `A` | Toggle **album art viewer** (Kitty / Sixel / iTerm2 / half-block) | +| | `,` / `.` | Nudge lyric sync backward / forward by 0.5s (lyrics drawer open) | | | `y` | Yank / copy track metadata (`Artist - Title`) or identified song to system clipboard | | | `o` | Open streaming search in default web browser (Spotify, YT Music, Apple, DDG, Google) | | | `s` | Star / bookmark track to `~/.config/halpradio/saved_tracks.txt` (on History tab) | @@ -510,6 +572,7 @@ Explore detailed technical documentation in the [`docs/`](./docs) folder: - 🔌 **[Plugin & Extension System Guide](./docs/PLUGINS.md)**: Sandboxed WebAssembly (Wasm) architecture, capability permissions, developer SDK, and publishing to the official registry. - 🎵 **[Audio Engine & Stream Player](./docs/AUDIO_PLAYER.md)**: Multi-backend auto-detection (`mpv`, `vlc`, `ffplay`, native Go), process lifecycle, and real-time ICY metadata extraction. - 📻 **[Station Catalog & RadioBrowser Integration](./docs/STATION_MANAGEMENT.md)**: Station storage hierarchy (`stations.yaml`, local config, favorites), RadioBrowser API client, and PR export workflow. +- 📜 **[Synced Lyrics & Terminal Album Art](./docs/LYRICS_AND_ART.md)**: LRCLIB / NetEase lyric providers, LRC parsing, playback-position estimation, cover art providers, and the Kitty / iTerm2 / Sixel / half-block / Braille renderers. - 🎨 **[Theme System & Audio Visualizers](./docs/THEME_SYSTEM.md)**: Lipgloss styling system, theme palettes, and TUI visualizer algorithms. - ⚙️ **[Configuration & Keybindings](./docs/CONFIGURATION.md)**: Directory layout, `config.yaml` options, CLI flags, and complete keymap reference. - 📦 **[Packaging & Distribution Guide](./docs/PACKAGING.md)**: Specifications for Homebrew, Arch Linux AUR, Docker, Scoop, and Nix. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7810658..cf0daeb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,6 +53,8 @@ halpradio/ ├── docs/ # Detailed technical documentation └── pkg/ ├── app/ # CLI flag parsing, configuration loading & app bootstrap + ├── art/ # Cover art providers plus Kitty / iTerm2 / Sixel / half-block / Braille renderers + ├── lyrics/ # LRCLIB & NetEase lyric providers, LRC parser, RAM and disk caches ├── player/ # Multi-backend audio playback engine & ICY stream reader │ └── fingerprint/ # Acoustic stream recognition, Chromaprint / AcoustID client, LRU cache ├── party/ # P2P mesh synchronized radio rooms, Argon2id/AES-256-GCM E2EE & protocol engine @@ -61,7 +63,7 @@ halpradio/ ├── theme/ # Theme definitions & color palette registry ├── timer/ # Pomodoro focus engine, sleep timer with volume fade, and OS event dispatcher ├── ui/ # Main Bubble Tea Model, Update, View, and Keymap logic - │ └── components/ # Modular UI sub-views (Header, StationList, PlayerBar, Visualizer, Modals, PartyBar) + │ └── components/ # Modular UI sub-views (Header, StationList, PlayerBar, Visualizer, Modals, PartyBar, LyricsDrawer, AlbumArt) └── util/ # OS configuration directory resolution & clipboard utilities ``` @@ -71,6 +73,8 @@ halpradio/ |---|---|---| | [`pkg/app`](../pkg/app/app.go) | `Run()`, `RunPluginCLI()` | Parses CLI flags (`--backend`, `--theme`, `--version`, `--fingerprint`, `--auto-identify`), handles CLI subcommands (`remote`, `plugin`, `party`), sets up store, instantiates `player.Manager`, initializes `tea.Program`. | | [`pkg/party`](../pkg/party/sync.go) | `Session`, `MeshNode`, `Packet`, `Crypto` | P2P mesh synchronized radio rooms, WebRTC data channels, Argon2id key derivation & AES-256-GCM encryption, sub-second playback sync, host election, ASCII reaction bus. | +| [`pkg/art`](../pkg/art/client.go) | `Client`, `Cover`, `Renderer`, `Protocol` | Resolves high-resolution cover art from iTunes, Deezer, MusicBrainz / Cover Art Archive and Last.fm, then encodes it for the terminal's best image transport with RAM and disk caching. | +| [`pkg/lyrics`](../pkg/lyrics/lrclib.go) | `Client`, `Sheet`, `Line`, `ParseLRC()` | Queries LRCLIB and falls back to NetEase, parses `.lrc` timestamps, resolves the active line for a playback offset, and caches sheets in RAM and on disk. | | [`pkg/player`](../pkg/player/player.go) | `Player`, `Manager`, `TrackInfo` | Detects audio CLI backends (`mpv`, `vlc`, `ffplay`, etc.) or falls back to native Go audio. Runs ICY metadata streaming goroutine. | | [`pkg/player/fingerprint`](../pkg/player/fingerprint/client.go) | `Client`, `Result`, `LRUCache` | Captures 5s audio buffers, computes Chromaprint subfingerprints, queries AcoustID & MusicBrainz APIs with LRU caching. | | [`pkg/plugin`](../pkg/plugin/manager.go) | `Manager`, `Sandbox`, `Manifest`, `RegistryClient` | Executes sandboxed WebAssembly plugins via Wazero with capability checks (`network`, `storage`, `events`). Fetches and verifies official registry packages. | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c0b9ab3..cb872c9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -70,6 +70,14 @@ fingerprint_enabled: true # Enable on-demand acoustic recognition via 'I' (Chr auto_identify: false # Automatically identify music when streams lack ICY track metadata acoustid_api_key: "" # AcoustID API key (leave empty to use default halpradio client key) +# Synced Lyrics & Terminal Album Art +lyrics_enabled: true # Enable the LRCLIB / NetEase synced lyrics drawer ('L' key) +lyrics_auto_open: false # Open the lyrics drawer automatically on startup +lyrics_offset_ms: 0 # Persistent lyric sync correction in milliseconds (',' / '.' adjust live) +album_art_enabled: true # Enable terminal cover art rendering ('A' key) +album_art_protocol: auto # auto | kitty | iterm2 | sixel | halfblock | braille | off +lastfm_api_key: "" # Optional extra cover art provider (iTunes, Deezer & Cover Art Archive need no key) + # Experimental Features (On Hold) experimental_tuner: false # Enable experimental Analog Frequency Tuner on Tab 0 (see docs/TUNER.md) ``` diff --git a/docs/LYRICS_AND_ART.md b/docs/LYRICS_AND_ART.md new file mode 100644 index 0000000..bad7a15 --- /dev/null +++ b/docs/LYRICS_AND_ART.md @@ -0,0 +1,197 @@ +# Synced Lyrics & Terminal Album Art 📜🖼️ + +`halpradio` renders a live, self-scrolling lyric sheet and real album artwork +without leaving the terminal. Press `L` for the lyrics drawer and `A` for the +full-size cover art viewer. + +--- + +## 📜 The Lyrics Engine (`pkg/lyrics`) + +### Providers + +| Order | Provider | Endpoint | Key required | +|---|---|---|---| +| 1 | **LRCLIB** exact match | `GET /api/get?artist_name=&track_name=&album_name=&duration=` | no | +| 2 | **LRCLIB** search | `GET /api/search?artist_name=&track_name=` | no | +| 3 | **NetEase Cloud Music** | `GET /api/search/get` then `GET /api/song/lyric` | no | + +The exact lookup is tried first because it takes the track duration into +account. When it misses, the search endpoint picks the best candidate, +preferring entries that carry timestamped lyrics and then the closest duration. +NetEase is the last resort and is treated as best-effort: an unexpected +response shape resolves to "no lyrics" rather than an error. + +Instrumental tracks come back as a single `♪ Instrumental ♪` line rather than an +empty sheet, so the drawer can say why there is nothing to sing. + +### LRC parsing + +`ParseLRC` handles `[mm:ss]`, `[mm:ss.xx]` and `[mm:ss.xxx]` stamps, several +stamps sharing one line of text, and the `[offset:NNN]` tag, which shifts every +timestamp by that many milliseconds. Metadata tags (`[ar:]`, `[ti:]`, `[al:]`, +`[length:]`) are skipped. Lines come back sorted ascending. + +### Splitting a radio stream title + +ICY metadata is messy. `SplitTrackTitle` recognises `Artist - Title` with any +dash variant plus the lower-case `Title by Artist` form, and strips station +noise: wrapping quotes, `(Official Video)`, `[HQ]`, ` - Topic`, a trailing +` | StationName`, and advert or jingle slugs. When the input is only a station +name or an advert it returns two empty strings, and no lookup is attempted. + +### Estimating playback position + +Internet radio exposes no seek position, so the lyric clock starts the moment a +station announces a new title and runs from there. Two consequences: + +- Stations that announce metadata a few seconds late or early drift. Press `,` + and `.` to shift the sync in 0.5 second steps. The live value shows in the + drawer footer, and `lyrics_offset_ms` in `config.yaml` makes a correction + persistent. +- Joining a station mid-track starts the sheet from the top. The next track + announcement re-syncs it. + +### Caching + +Sheets are memoised in RAM with a six hour time-to-live and written to +`~/.cache/halpradio/lyrics/` as one JSON file per track, named by a SHA-256 of +the normalised lookup key. Entries older than 30 days are stale. Tracks with no +match anywhere are negative-cached for an hour so a station full of unmatched +tracks does not hammer the providers. Every disk failure is non-fatal and falls +back to network-only operation. + +--- + +## 🖼️ The Album Art Engine (`pkg/art`) + +### Cover providers + +| Order | Provider | Notes | +|---|---|---| +| 1 | **iTunes Search API** | fastest, no key; the `100x100bb` artwork path is rewritten to `600x600bb` | +| 2 | **Deezer** | `album.cover_xl`, no key | +| 3 | **MusicBrainz → Cover Art Archive** | recording query resolves a release MBID, then `front-500` | +| 4 | **Last.fm** | only when `lastfm_api_key` is set | + +Downloads are capped at 8 MB, must present an image content type, and must +decode before they are accepted. + +### Protocol detection + +`art.Detect` reads the environment and picks the best transport: + +| Priority | Protocol | Detected from | +|---|---|---| +| 1 | Kitty graphics | `TERM_PROGRAM=ghostty`, `TERM` containing `kitty`, `KITTY_WINDOW_ID`, `TERM_PROGRAM=WezTerm` | +| 2 | iTerm2 inline images | `TERM_PROGRAM=iTerm.app`, `ITERM_SESSION_ID` | +| 3 | Sixel | `TERM` containing `foot`, `mlterm`, `yaft` or `sixel`, or `HALPRADIO_SIXEL` | +| 4 | Truecolor half-block | `COLORTERM` of `truecolor`/`24bit`, or `TERM` containing `256color` | +| 5 | Braille | anything else | +| — | None | `TERM` empty or `dumb`, or `HALPRADIO_NO_ART` / `NO_GRAPHICS` set | + +Overrides, highest precedence first: + +```bash +HALPRADIO_NO_ART=1 halpradio # no artwork for this run +HALPRADIO_ART_PROTOCOL=halfblock halpradio # force a transport for this run +``` + +```yaml +album_art_protocol: sixel # force a transport permanently +``` + +Sixel detection is deliberately conservative: there is no reliable positive +signal for it beyond a handful of terminal names, and a Sixel payload sent to a +terminal that does not understand it prints as garbage. Set `HALPRADIO_SIXEL=1` +or `album_art_protocol: sixel` when you know your terminal supports it. + +### The layout contract + +Every renderer returns **exactly** the requested number of rows, and every +returned line reports the requested column count through `lipgloss.Width`. The +escape-sequence transports place the image at the cursor and pad their rows +with spaces, so Kitty, iTerm2 and Sixel output cannot shift the surrounding +Bubble Tea frame. This invariant is asserted per protocol in +`pkg/art/render_test.go`. + +The cell-approximation renderers work on the pixels directly: + +- **Half-block** packs two vertical pixels into one cell using `▀` with the + upper pixel as foreground and the lower as background, emitted as truecolor + SGR and reset at every line end. +- **Braille** maps a 2×4 pixel block to one Braille cell from a luminance + threshold, coloured with the block's average colour. + +Images are letterboxed to the target cell grid rather than stretched, so covers +stay square on every terminal. + +Artwork is cached under `~/.cache/halpradio/art/` as the encoded image plus a +JSON sidecar holding the provider, origin URL and fetch time. + +--- + +## 🖥️ How it fits the TUI + +```text +┌─ 📻 CATALOG ──────────────────┬─ 📜 LIVE LYRICS ───────────────┐ +│ ▶ SomaFM Groove Salad │ ▄▄▄▄▄▄▄▄▄▄▄▄ │ +│ Nightwave Plaza │ █ ALBUM ART █ │ +│ Radio Paradise │ ▀▀▀▀▀▀▀▀▀▀▀▀ │ +│ KEXP 90.3 │ 🖼 iTunes │ +│ │ Tycho - A Walk │ +│ │ I've been wandering │ +│ │ ► Searching for a signal ◄ │ +│ │ Everything is quiet │ +│ │ ━━━━━━━━━━━─────── │ +│ │ ⏱ Synced via LRCLIB │ +│ │ L close · , . sync │ +└───────────────────────────────┴────────────────────────────────┘ +``` + +- The drawer **takes its own columns** rather than overlapping the station + list, so the list keeps its own layout and selection. +- It needs an 80 column terminal, because the station list will not render + below 28 columns beside an 18 column sidebar. Narrower terminals get a status + message instead of a broken frame. +- Every lookup runs as a Bubble Tea command off the update loop, so a slow + provider never blocks the keyboard. Results that arrive after the track has + changed are dropped by comparing the track key. +- Artwork is rasterised in `pkg/ui/update.go` when a cover arrives, when the + window is resized and when the drawer or modal opens, because the pixel grid + depends on the surface size. Component views stay pure. +- While the analog frequency tuner is live, `L` keeps its existing meaning of + sweeping the dial. Everywhere else it toggles the drawer. + +### Keybindings + +| Key | Action | +|---|---| +| `L` | Toggle the lyrics drawer and move focus into it | +| `A` | Toggle the full-size album art viewer | +| `j` / `k` | Scroll an unsynced sheet while the drawer has focus | +| `,` / `.` | Nudge the lyric sync back / forward by 0.5s | +| `h` | Return focus to the station list, leaving the drawer open | +| `Esc` | Close the drawer | + +### Configuration + +```yaml +lyrics_enabled: true # LRCLIB / NetEase synced lyrics engine +lyrics_auto_open: false # open the drawer on startup +lyrics_offset_ms: 0 # persistent sync correction in milliseconds +album_art_enabled: true # terminal cover art renderer +album_art_protocol: auto # auto | kitty | iterm2 | sixel | halfblock | braille | off +lastfm_api_key: "" # optional extra cover art provider +``` + +--- + +## 🔐 Privacy & network behaviour + +- Nothing is uploaded. Both engines send only the artist, title, album and + duration already broadcast by the station, as query parameters. +- Requests identify themselves with a `halpradio/…` user agent, which LRCLIB + and MusicBrainz both ask for. +- With both features disabled in `config.yaml`, neither engine is constructed + and no request is ever made. diff --git a/pkg/art/braille.go b/pkg/art/braille.go new file mode 100644 index 0000000..3a03b71 --- /dev/null +++ b/pkg/art/braille.go @@ -0,0 +1,140 @@ +package art + +import ( + "image" + "strings" +) + +// brailleBase is U+2800, the blank braille pattern. Dots are added as a +// bitmask on top of it. +const brailleBase = rune(0x2800) + +// brailleDotBits maps a (dx, dy) position inside a 2x4 braille cell onto its +// Unicode dot bit. +var brailleDotBits = [2][4]byte{ + {0x01, 0x02, 0x04, 0x40}, // left column: dots 1, 2, 3, 7 + {0x08, 0x10, 0x20, 0x80}, // right column: dots 4, 5, 6, 8 +} + +// renderBraille converts a prepared (cols*2) x (rows*4) pixel grid into rows +// lines of braille cells. A dot is set where the pixel is strictly brighter +// than a global Otsu threshold and each cell is tinted with the average colour +// of the pixels it lit up. +func renderBraille(img *image.RGBA, cols, rows int) []string { + threshold := otsuThreshold(img, cols*2, rows*4) + + lines := make([]string, 0, rows) + var sb strings.Builder + + for row := 0; row < rows; row++ { + sb.Reset() + sb.Grow(cols * 24) + lastFg := -1 + + for col := 0; col < cols; col++ { + var mask byte + var onR, onG, onB, onN int + var allR, allG, allB int + + for dx := 0; dx < 2; dx++ { + for dy := 0; dy < 4; dy++ { + r, g, b := pixelAt(img, col*2+dx, row*4+dy) + allR += int(r) + allG += int(g) + allB += int(b) + if luminance(r, g, b) > threshold { + mask |= brailleDotBits[dx][dy] + onR += int(r) + onG += int(g) + onB += int(b) + onN++ + } + } + } + + var cr, cg, cb uint8 + if onN > 0 { + cr, cg, cb = uint8(onR/onN), uint8(onG/onN), uint8(onB/onN) + } else { + cr, cg, cb = uint8(allR/8), uint8(allG/8), uint8(allB/8) + } + + if key := rgbKey(cr, cg, cb); key != lastFg { + writeSGRColor(&sb, 38, cr, cg, cb) + lastFg = key + } + sb.WriteRune(brailleBase + rune(mask)) + } + sb.WriteString(sgrReset) + lines = append(lines, sb.String()) + } + return lines +} + +// otsuThreshold picks a luminance cut-off that maximises between-class +// variance over the sampled region. Callers treat pixels strictly brighter +// than the result as foreground, so a flat non-black image lights every dot +// and a flat black image lights none. +func otsuThreshold(img *image.RGBA, w, h int) int { + if w <= 0 || h <= 0 { + return 128 + } + + var hist [256]int + total := 0 + lo, hi := 255, 0 + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, b := pixelAt(img, x, y) + l := luminance(r, g, b) + hist[l]++ + total++ + if l < lo { + lo = l + } + if l > hi { + hi = l + } + } + } + if total == 0 { + return 128 + } + if hi <= lo { + // Completely flat: light every dot unless the image is pure black. + if hi == 0 { + return 0 + } + return lo - 1 + } + + sum := 0.0 + for i := 0; i < 256; i++ { + sum += float64(i) * float64(hist[i]) + } + + var ( + sumB, wB float64 + best = -1.0 + bestThresh = (lo + hi) / 2 + ) + for i := 0; i < 256; i++ { + wB += float64(hist[i]) + if wB == 0 { + continue + } + wF := float64(total) - wB + if wF == 0 { + break + } + sumB += float64(i) * float64(hist[i]) + mB := sumB / wB + mF := (sum - sumB) / wF + variance := wB * wF * (mB - mF) * (mB - mF) + if variance > best { + best = variance + bestThresh = i + } + } + return clampInt(bestThresh, lo, hi-1) +} diff --git a/pkg/art/cache.go b/pkg/art/cache.go new file mode 100644 index 0000000..f3b9934 --- /dev/null +++ b/pkg/art/cache.go @@ -0,0 +1,281 @@ +package art + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "image" + "os" + "path/filepath" + "strings" + "time" + "unicode" +) + +// Cache tuning. Cover payloads are large, so the RAM cache stays small while +// negative lookups are remembered long enough to stop stations without a +// match from hammering the providers. +const ( + memTTL = 6 * time.Hour + negativeTTL = time.Hour + memCapacity = 32 + diskTTL = 30 * 24 * time.Hour +) + +// memEntry is one in-memory cache slot. A negative entry records that no +// provider had artwork for the key. +type memEntry struct { + cover *Cover + negative bool + storedAt time.Time +} + +// expired reports whether the entry has outlived its time-to-live. +func (e memEntry) expired(now time.Time) bool { + ttl := memTTL + if e.negative { + ttl = negativeTTL + } + return now.Sub(e.storedAt) > ttl +} + +// diskMeta is the sidecar JSON written next to every cached image. +type diskMeta struct { + Source string `json:"source"` + URL string `json:"url"` + Artist string `json:"artist"` + Title string `json:"title"` + Album string `json:"album"` + CachedAt time.Time `json:"cached_at"` +} + +// cacheKey builds the normalised lookup key for a track. It is never used as a +// filename directly; see hashKey. +func cacheKey(artist, title, album string) string { + return normalizeText(artist) + "|" + normalizeText(title) + "|" + normalizeText(album) +} + +// hashKey returns the hex SHA-256 of a cache key, used as the on-disk basename. +func hashKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +// noisyGroupPrefixes are parenthesised suffixes that carry no identity, such +// as "(feat. X)" or "(Official Video)". +var noisyGroupPrefixes = []string{"feat", "ft.", "ft ", "featuring", "official", "with ", "prod.", "prod "} + +// normalizeText lowercases a track field, drops bracketed noise such as +// "[Remastered]", "(feat. …)" and "(Official Video)", and collapses runs of +// whitespace so equivalent titles hash to the same key. +func normalizeText(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "" + } + s = stripGroups(s, '[', ']', func(string) bool { return true }) + s = stripGroups(s, '(', ')', func(inner string) bool { + inner = strings.TrimSpace(inner) + for _, p := range noisyGroupPrefixes { + if strings.HasPrefix(inner, p) { + return true + } + } + return false + }) + return collapseSpace(s) +} + +// stripGroups removes balanced open/close groups for which drop reports true. +// Unbalanced input is returned with the dangling opener kept verbatim. +func stripGroups(s string, open, closeCh byte, drop func(inner string) bool) string { + var out strings.Builder + out.Grow(len(s)) + for i := 0; i < len(s); { + if s[i] != open { + out.WriteByte(s[i]) + i++ + continue + } + depth := 0 + end := -1 + for j := i; j < len(s); j++ { + switch s[j] { + case open: + depth++ + case closeCh: + depth-- + if depth == 0 { + end = j + } + } + if end >= 0 { + break + } + } + if end < 0 { + out.WriteByte(s[i]) + i++ + continue + } + inner := s[i+1 : end] + if !drop(inner) { + out.WriteString(s[i : end+1]) + } else { + out.WriteByte(' ') + } + i = end + 1 + } + return out.String() +} + +// collapseSpace trims the string and reduces internal whitespace runs to one +// space each. +func collapseSpace(s string) string { + var out strings.Builder + out.Grow(len(s)) + space := true + for _, r := range s { + if unicode.IsSpace(r) { + if !space { + out.WriteByte(' ') + space = true + } + continue + } + out.WriteRune(r) + space = false + } + return strings.TrimSpace(out.String()) +} + +// memGet returns a cached cover for key. The second result reports whether the +// key was cached at all; a cached negative yields (nil, true). +func (c *Client) memGet(key string) (*Cover, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, ok := c.mem[key] + if !ok { + return nil, false + } + if entry.expired(time.Now()) { + delete(c.mem, key) + return nil, false + } + if entry.negative { + return nil, true + } + return entry.cover.clone(), true +} + +// memPut stores a cover (or a negative marker when cover is nil) and evicts +// the oldest slot once the cache is full. +func (c *Client) memPut(key string, cover *Cover) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.mem == nil { + c.mem = make(map[string]*memEntry, memCapacity) + } + now := time.Now() + for k, e := range c.mem { + if e.expired(now) { + delete(c.mem, k) + } + } + if _, exists := c.mem[key]; !exists && len(c.mem) >= memCapacity { + oldestKey := "" + var oldest time.Time + for k, e := range c.mem { + if oldestKey == "" || e.storedAt.Before(oldest) { + oldestKey, oldest = k, e.storedAt + } + } + if oldestKey != "" { + delete(c.mem, oldestKey) + } + } + c.mem[key] = &memEntry{cover: cover.clone(), negative: cover == nil, storedAt: now} +} + +// diskPaths returns the image and metadata paths for a cache key, or false +// when disk caching is disabled. +func (c *Client) diskPaths(key string) (string, string, bool) { + if c.cacheDir == "" { + return "", "", false + } + h := hashKey(key) + return filepath.Join(c.cacheDir, h+".img"), filepath.Join(c.cacheDir, h+".json"), true +} + +// diskGet loads a cached cover from disk. Every failure — missing, stale, +// corrupt or unreadable — is treated as a cache miss and never surfaced as an +// error. +func (c *Client) diskGet(key string) (*Cover, bool) { + imgPath, metaPath, ok := c.diskPaths(key) + if !ok { + return nil, false + } + + metaRaw, err := os.ReadFile(metaPath) + if err != nil { + return nil, false + } + var meta diskMeta + if err := json.Unmarshal(metaRaw, &meta); err != nil { + return nil, false + } + if meta.CachedAt.IsZero() || time.Since(meta.CachedAt) > diskTTL { + return nil, false + } + + data, err := os.ReadFile(imgPath) + if err != nil || len(data) == 0 { + return nil, false + } + if _, _, err := image.DecodeConfig(bytes.NewReader(data)); err != nil { + return nil, false + } + + return &Cover{ + Data: data, + Source: meta.Source, + Artist: meta.Artist, + Title: meta.Title, + Album: meta.Album, + URL: meta.URL, + }, true +} + +// diskPut writes a cover and its metadata sidecar. Disk errors are returned so +// callers can log them, but they are never fatal to a fetch. +func (c *Client) diskPut(key string, cover *Cover) error { + imgPath, metaPath, ok := c.diskPaths(key) + if !ok || cover == nil || len(cover.Data) == 0 { + return nil + } + if err := os.MkdirAll(c.cacheDir, 0o700); err != nil { + return fmt.Errorf("art: create cache dir: %w", err) + } + if err := os.WriteFile(imgPath, cover.Data, 0o600); err != nil { + return fmt.Errorf("art: write cache image: %w", err) + } + meta, err := json.Marshal(diskMeta{ + Source: cover.Source, + URL: cover.URL, + Artist: cover.Artist, + Title: cover.Title, + Album: cover.Album, + CachedAt: time.Now().UTC(), + }) + if err != nil { + return fmt.Errorf("art: encode cache metadata: %w", err) + } + if err := os.WriteFile(metaPath, meta, 0o600); err != nil { + return fmt.Errorf("art: write cache metadata: %w", err) + } + return nil +} diff --git a/pkg/art/cache_test.go b/pkg/art/cache_test.go new file mode 100644 index 0000000..e338dc1 --- /dev/null +++ b/pkg/art/cache_test.go @@ -0,0 +1,331 @@ +package art + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNormalizeText(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"", ""}, + {" Spaced Out ", "spaced out"}, + {"MiXeD CaSe", "mixed case"}, + {"Song (feat. Someone)", "song"}, + {"Song (Feat. Someone Else)", "song"}, + {"Song (ft. Guest)", "song"}, + {"Song (featuring Guest)", "song"}, + {"Song (Official Video)", "song"}, + {"Song (Official Music Video)", "song"}, + {"Song [Remastered 2011]", "song"}, + {"Song [Explicit] (feat. X)", "song"}, + {"Song (Live at Wembley)", "song (live at wembley)"}, // meaningful, kept + {"Song (Remix)", "song (remix)"}, // meaningful, kept + {"Song (prod. Someone)", "song"}, + {"Unbalanced (open", "unbalanced (open"}, + {"Nested (feat. A (and B)) tail", "nested tail"}, + {"Tabs\tand\nnewlines", "tabs and newlines"}, + } + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + if got := normalizeText(tc.in); got != tc.want { + t.Fatalf("normalizeText(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestCacheKeyNormalises(t *testing.T) { + tests := []struct { + name string + a1, t1, al1 string + a2, t2, al2 string + wantSame bool + }{ + {"identical", "A", "B", "C", "A", "B", "C", true}, + {"case and space", " Daft Punk ", "One More Time", "Discovery", + "daft punk", "one more time", "discovery", true}, + {"feat noise", "Artist", "Track (feat. Guest)", "", "Artist", "Track", "", true}, + {"official video noise", "Artist", "Track [Official Video]", "", "Artist", "Track", "", true}, + {"different track", "Artist", "Track A", "", "Artist", "Track B", "", false}, + {"different album", "Artist", "Track", "Album A", "Artist", "Track", "Album B", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + k1 := cacheKey(tc.a1, tc.t1, tc.al1) + k2 := cacheKey(tc.a2, tc.t2, tc.al2) + if (k1 == k2) != tc.wantSame { + t.Fatalf("cacheKey equality was %v (%q vs %q), want %v", k1 == k2, k1, k2, tc.wantSame) + } + }) + } +} + +func TestHashKeyIsFilenameSafe(t *testing.T) { + tests := []string{ + "", + "artist|title|album", + "../../etc/passwd", + "weird/\\:*?\"<>| name", + strings.Repeat("x", 4096), + } + for _, in := range tests { + got := hashKey(in) + if len(got) != 64 { + t.Fatalf("hashKey(%q) length = %d, want 64", in, len(got)) + } + if strings.ContainsAny(got, "/\\.:") { + t.Fatalf("hashKey(%q) = %q contains path characters", in, got) + } + if got != hashKey(in) { + t.Fatal("hashKey is not stable") + } + } +} + +func TestMemCache(t *testing.T) { + c := NewClient("", "") + cover := &Cover{Data: []byte("bytes"), Source: "iTunes", Title: "T"} + + if _, ok := c.memGet("missing"); ok { + t.Fatal("unexpected hit on an empty cache") + } + + c.memPut("k", cover) + got, ok := c.memGet("k") + if !ok || got == nil { + t.Fatal("expected a positive cache hit") + } + if got == cover { + t.Fatal("memGet returned the caller's own pointer") + } + if got.Source != "iTunes" { + t.Fatalf("Source = %q", got.Source) + } + + c.memPut("neg", nil) + got, ok = c.memGet("neg") + if !ok { + t.Fatal("expected the negative entry to be cached") + } + if got != nil { + t.Fatal("negative entry should yield a nil cover") + } +} + +func TestMemCacheExpiry(t *testing.T) { + tests := []struct { + name string + negative bool + age time.Duration + wantHit bool + }{ + {"fresh positive", false, time.Minute, true}, + {"stale positive", false, memTTL + time.Minute, false}, + {"fresh negative", true, time.Minute, true}, + {"stale negative", true, negativeTTL + time.Minute, false}, + {"positive TTL outlives negative TTL", true, memTTL - time.Minute, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := NewClient("", "") + entry := &memEntry{ + negative: tc.negative, + storedAt: time.Now().Add(-tc.age), + } + if !tc.negative { + entry.cover = &Cover{Data: []byte("x")} + } + c.mem["k"] = entry + if _, ok := c.memGet("k"); ok != tc.wantHit { + t.Fatalf("memGet hit = %v, want %v", ok, tc.wantHit) + } + }) + } +} + +func TestMemCacheEviction(t *testing.T) { + c := NewClient("", "") + for i := 0; i < memCapacity*2; i++ { + c.memPut(string(rune('a'+i%26))+hashKey(string(rune(i))), &Cover{Data: []byte("x")}) + } + c.mu.Lock() + size := len(c.mem) + c.mu.Unlock() + if size > memCapacity { + t.Fatalf("cache holds %d entries, want at most %d", size, memCapacity) + } +} + +func TestDiskCacheRoundTrip(t *testing.T) { + dir := filepath.Join(t.TempDir(), "covers") + c := NewClient(dir, "") + if c.CacheDir() != dir { + t.Fatalf("CacheDir() = %q, want %q", c.CacheDir(), dir) + } + + key := cacheKey("Artist", "Title", "Album") + if _, ok := c.diskGet(key); ok { + t.Fatal("unexpected hit on an empty disk cache") + } + + cover := &Cover{ + Data: testPNG(t, 8, 8), + Source: "Deezer", + Artist: "Artist", + Title: "Title", + Album: "Album", + URL: "https://example.test/cover.png", + } + if err := c.diskPut(key, cover); err != nil { + t.Fatalf("diskPut: %v", err) + } + + got, ok := c.diskGet(key) + if !ok { + t.Fatal("expected a disk cache hit") + } + if got.Source != "Deezer" || got.URL != cover.URL || got.Album != "Album" { + t.Fatalf("metadata not round-tripped: %+v", got) + } + if len(got.Data) != len(cover.Data) { + t.Fatalf("payload length = %d, want %d", len(got.Data), len(cover.Data)) + } + + // Permissions: 0700 directory, 0600 files. + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Fatalf("dir mode = %o, want 700", perm) + } + imgPath, metaPath, _ := c.diskPaths(key) + for _, p := range []string{imgPath, metaPath} { + fi, err := os.Stat(p) + if err != nil { + t.Fatalf("stat %s: %v", p, err) + } + if perm := fi.Mode().Perm(); perm != 0o600 { + t.Fatalf("%s mode = %o, want 600", p, perm) + } + } + // Filenames must be derived from the hash, never from the track text. + if !strings.HasPrefix(filepath.Base(imgPath), hashKey(key)) { + t.Fatalf("image filename %q is not hash derived", filepath.Base(imgPath)) + } +} + +func TestDiskCacheMisses(t *testing.T) { + key := cacheKey("Artist", "Title", "") + + tests := []struct { + name string + setup func(t *testing.T, c *Client) + }{ + {"no metadata", func(t *testing.T, c *Client) { + imgPath, _, _ := c.diskPaths(key) + mustMkdirAll(t, c.cacheDir) + mustWrite(t, imgPath, testPNG(t, 4, 4)) + }}, + {"no image", func(t *testing.T, c *Client) { + _, metaPath, _ := c.diskPaths(key) + mustMkdirAll(t, c.cacheDir) + mustWrite(t, metaPath, mustJSON(t, diskMeta{CachedAt: time.Now()})) + }}, + {"corrupt metadata", func(t *testing.T, c *Client) { + imgPath, metaPath, _ := c.diskPaths(key) + mustMkdirAll(t, c.cacheDir) + mustWrite(t, imgPath, testPNG(t, 4, 4)) + mustWrite(t, metaPath, []byte("{not json")) + }}, + {"stale entry", func(t *testing.T, c *Client) { + imgPath, metaPath, _ := c.diskPaths(key) + mustMkdirAll(t, c.cacheDir) + mustWrite(t, imgPath, testPNG(t, 4, 4)) + mustWrite(t, metaPath, mustJSON(t, diskMeta{CachedAt: time.Now().Add(-diskTTL - time.Hour)})) + }}, + {"zero timestamp", func(t *testing.T, c *Client) { + imgPath, metaPath, _ := c.diskPaths(key) + mustMkdirAll(t, c.cacheDir) + mustWrite(t, imgPath, testPNG(t, 4, 4)) + mustWrite(t, metaPath, mustJSON(t, diskMeta{})) + }}, + {"payload is not an image", func(t *testing.T, c *Client) { + imgPath, metaPath, _ := c.diskPaths(key) + mustMkdirAll(t, c.cacheDir) + mustWrite(t, imgPath, []byte("definitely not an image")) + mustWrite(t, metaPath, mustJSON(t, diskMeta{CachedAt: time.Now()})) + }}, + {"empty payload", func(t *testing.T, c *Client) { + imgPath, metaPath, _ := c.diskPaths(key) + mustMkdirAll(t, c.cacheDir) + mustWrite(t, imgPath, []byte{}) + mustWrite(t, metaPath, mustJSON(t, diskMeta{CachedAt: time.Now()})) + }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := NewClient(filepath.Join(t.TempDir(), "covers"), "") + tc.setup(t, c) + if _, ok := c.diskGet(key); ok { + t.Fatal("expected a cache miss") + } + }) + } +} + +func TestDiskCacheDisabled(t *testing.T) { + c := NewClient("", "") + if _, _, ok := c.diskPaths("k"); ok { + t.Fatal("disk paths should be unavailable without a cache dir") + } + if _, ok := c.diskGet("k"); ok { + t.Fatal("unexpected disk hit") + } + if err := c.diskPut("k", &Cover{Data: []byte("x")}); err != nil { + t.Fatalf("diskPut with caching disabled should be a no-op: %v", err) + } +} + +func TestCoverClone(t *testing.T) { + if (*Cover)(nil).clone() != nil { + t.Fatal("nil clone should stay nil") + } + orig := &Cover{Data: []byte("x"), Source: "iTunes"} + dup := orig.clone() + dup.Source = "Deezer" + if orig.Source != "iTunes" { + t.Fatal("clone shares the struct with the original") + } +} + +func mustMkdirAll(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } +} + +func mustWrite(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + out, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return out +} diff --git a/pkg/art/client.go b/pkg/art/client.go new file mode 100644 index 0000000..9b55d79 --- /dev/null +++ b/pkg/art/client.go @@ -0,0 +1,527 @@ +package art + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "image" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// Default provider endpoints. Each is overridable on Client so tests can point +// at an httptest server. +const ( + DefaultMusicBrainzBaseURL = "https://musicbrainz.org" + DefaultCoverArtBaseURL = "https://coverartarchive.org" + DefaultITunesBaseURL = "https://itunes.apple.com" + DefaultDeezerBaseURL = "https://api.deezer.com" + DefaultLastFMBaseURL = "https://ws.audioscrobbler.com" +) + +const ( + // userAgent identifies halpradio to the providers. MusicBrainz rejects + // requests without a descriptive User-Agent. + userAgent = "halpradio/0.5 (https://github.com/halpworld/halpradio)" + // maxImageBytes caps a downloaded cover so a hostile or misconfigured + // host cannot exhaust memory. + maxImageBytes = 8 << 20 + // maxJSONBytes caps a provider metadata response. + maxJSONBytes = 2 << 20 +) + +// ErrNotFound is returned when no provider has artwork for the track. +var ErrNotFound = errors.New("art: no cover art found") + +// Cover is fetched album artwork. +type Cover struct { + Data []byte // raw encoded image bytes (PNG or JPEG) + Source string // "Cover Art Archive", "iTunes", "Deezer", "Last.fm" + Artist string + Title string + Album string + URL string // origin URL of the image +} + +// clone returns a shallow copy so cached entries cannot be mutated by callers. +// The Data slice is shared and must be treated as read-only. +func (c *Cover) clone() *Cover { + if c == nil { + return nil + } + dup := *c + return &dup +} + +// Client fetches cover art from multiple providers with RAM and disk caches. +// +// The zero value is not usable; construct one with NewClient. Base URL fields +// may be overridden afterwards (for example to point at a test server); an +// empty field falls back to the matching Default…BaseURL constant. +type Client struct { + MusicBrainzBaseURL string // default "https://musicbrainz.org" + CoverArtBaseURL string // default "https://coverartarchive.org" + ITunesBaseURL string // default "https://itunes.apple.com" + DeezerBaseURL string // default "https://api.deezer.com" + LastFMBaseURL string // default "https://ws.audioscrobbler.com" + HTTPClient *http.Client + + lastFMKey string + cacheDir string + + mu sync.Mutex + mem map[string]*memEntry +} + +// NewClient returns a Client caching artwork under cacheDir (created lazily). +// An empty cacheDir disables disk caching. lastFMKey may be empty, which skips +// the Last.fm provider. +func NewClient(cacheDir, lastFMKey string) *Client { + return &Client{ + MusicBrainzBaseURL: DefaultMusicBrainzBaseURL, + CoverArtBaseURL: DefaultCoverArtBaseURL, + ITunesBaseURL: DefaultITunesBaseURL, + DeezerBaseURL: DefaultDeezerBaseURL, + LastFMBaseURL: DefaultLastFMBaseURL, + HTTPClient: &http.Client{Timeout: 10 * time.Second}, + lastFMKey: strings.TrimSpace(lastFMKey), + cacheDir: strings.TrimSpace(cacheDir), + mem: make(map[string]*memEntry, memCapacity), + } +} + +// CacheDir reports the directory used for the disk cache, or "" when disk +// caching is disabled. +func (c *Client) CacheDir() string { + if c == nil { + return "" + } + return c.cacheDir +} + +// Fetch resolves cover art for a track, trying each provider in turn and +// returning ErrNotFound when none has artwork. +// +// Results (including negative lookups) are cached in RAM and, when a cache +// directory is configured, on disk. The returned Cover's Data must be treated +// as read-only because it may be shared with the cache. +func (c *Client) Fetch(ctx context.Context, artist, title, album string) (*Cover, error) { + if c == nil { + return nil, ErrNotFound + } + if ctx == nil { + ctx = context.Background() + } + + cleanArtist := normalizeText(artist) + cleanTitle := normalizeText(title) + cleanAlbum := normalizeText(album) + if cleanArtist == "" && cleanTitle == "" { + return nil, ErrNotFound + } + + key := cacheKey(artist, title, album) + if cover, ok := c.memGet(key); ok { + if cover == nil { + return nil, ErrNotFound + } + return cover, nil + } + if cover, ok := c.diskGet(key); ok { + c.memPut(key, cover) + return cover.clone(), nil + } + + providers := []struct { + name string + fn func(context.Context, string, string) (*Cover, error) + }{ + {"iTunes", c.fetchITunes}, + {"Deezer", c.fetchDeezer}, + {"Cover Art Archive", c.fetchCoverArtArchive}, + {"Last.fm", c.fetchLastFM}, + } + + for _, p := range providers { + cover, err := p.fn(ctx, cleanArtist, cleanTitle) + if err != nil { + if ctx.Err() != nil { + // The caller gave up; do not poison the cache with a + // negative result for a cancelled lookup. + return nil, ctx.Err() + } + continue + } + if cover == nil || len(cover.Data) == 0 { + continue + } + cover.Artist = firstNonEmpty(cover.Artist, artist) + cover.Title = firstNonEmpty(cover.Title, title) + cover.Album = firstNonEmpty(album, cover.Album, cleanAlbum) + c.memPut(key, cover) + _ = c.diskPut(key, cover) + return cover.clone(), nil + } + + c.memPut(key, nil) + return nil, ErrNotFound +} + +// fetchITunes queries the iTunes Search API and upgrades the 100px artwork URL +// it returns to 600px. +func (c *Client) fetchITunes(ctx context.Context, artist, title string) (*Cover, error) { + var payload struct { + Results []struct { + ArtistName string `json:"artistName"` + TrackName string `json:"trackName"` + CollectionName string `json:"collectionName"` + ArtworkURL100 string `json:"artworkUrl100"` + } `json:"results"` + } + + q := url.Values{} + q.Set("term", strings.TrimSpace(artist+" "+title)) + q.Set("media", "music") + q.Set("entity", "song") + q.Set("limit", "1") + + endpoint := baseOr(c.ITunesBaseURL, DefaultITunesBaseURL) + "/search?" + q.Encode() + if err := c.getJSON(ctx, endpoint, &payload); err != nil { + return nil, err + } + if len(payload.Results) == 0 || payload.Results[0].ArtworkURL100 == "" { + return nil, ErrNotFound + } + + hit := payload.Results[0] + imgURL := strings.Replace(hit.ArtworkURL100, "100x100bb", "600x600bb", 1) + data, err := c.download(ctx, imgURL) + if err != nil { + return nil, err + } + return &Cover{ + Data: data, + Source: "iTunes", + Artist: hit.ArtistName, + Title: hit.TrackName, + Album: hit.CollectionName, + URL: imgURL, + }, nil +} + +// fetchDeezer queries the Deezer search API for the album cover of the best +// matching track. +func (c *Client) fetchDeezer(ctx context.Context, artist, title string) (*Cover, error) { + var payload struct { + Data []struct { + Title string `json:"title"` + Artist struct { + Name string `json:"name"` + } `json:"artist"` + Album struct { + Title string `json:"title"` + CoverXL string `json:"cover_xl"` + CoverBig string `json:"cover_big"` + } `json:"album"` + } `json:"data"` + } + + q := url.Values{} + q.Set("q", strings.TrimSpace(artist+" "+title)) + q.Set("limit", "1") + + endpoint := baseOr(c.DeezerBaseURL, DefaultDeezerBaseURL) + "/search?" + q.Encode() + if err := c.getJSON(ctx, endpoint, &payload); err != nil { + return nil, err + } + if len(payload.Data) == 0 { + return nil, ErrNotFound + } + + hit := payload.Data[0] + imgURL := firstNonEmpty(hit.Album.CoverXL, hit.Album.CoverBig) + if imgURL == "" { + return nil, ErrNotFound + } + data, err := c.download(ctx, imgURL) + if err != nil { + return nil, err + } + return &Cover{ + Data: data, + Source: "Deezer", + Artist: hit.Artist.Name, + Title: hit.Title, + Album: hit.Album.Title, + URL: imgURL, + }, nil +} + +// fetchCoverArtArchive resolves a release MBID through MusicBrainz and then +// pulls the 500px front cover from the Cover Art Archive. +func (c *Client) fetchCoverArtArchive(ctx context.Context, artist, title string) (*Cover, error) { + var payload struct { + Recordings []struct { + Title string `json:"title"` + ArtistCredit []struct { + Name string `json:"name"` + } `json:"artist-credit"` + Releases []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"releases"` + } `json:"recordings"` + } + + q := url.Values{} + q.Set("query", fmt.Sprintf("artist:%q AND recording:%q", artist, title)) + q.Set("fmt", "json") + q.Set("limit", "1") + + endpoint := baseOr(c.MusicBrainzBaseURL, DefaultMusicBrainzBaseURL) + "/ws/2/recording?" + q.Encode() + if err := c.getJSON(ctx, endpoint, &payload); err != nil { + return nil, err + } + if len(payload.Recordings) == 0 || len(payload.Recordings[0].Releases) == 0 { + return nil, ErrNotFound + } + + rec := payload.Recordings[0] + mbid := rec.Releases[0].ID + if !isMBID(mbid) { + return nil, ErrNotFound + } + + credited := "" + if len(rec.ArtistCredit) > 0 { + credited = rec.ArtistCredit[0].Name + } + + imgURL := baseOr(c.CoverArtBaseURL, DefaultCoverArtBaseURL) + "/release/" + mbid + "/front-500" + data, err := c.download(ctx, imgURL) + if err != nil { + return nil, err + } + return &Cover{ + Data: data, + Source: "Cover Art Archive", + Artist: credited, + Title: rec.Title, + Album: rec.Releases[0].Title, + URL: imgURL, + }, nil +} + +// fetchLastFM queries track.getInfo and takes the largest album image. It is +// skipped entirely when no API key was configured. +func (c *Client) fetchLastFM(ctx context.Context, artist, title string) (*Cover, error) { + if c.lastFMKey == "" { + return nil, ErrNotFound + } + + var payload struct { + Track struct { + Name string `json:"name"` + Album struct { + Artist string `json:"artist"` + Title string `json:"title"` + Image []struct { + Text string `json:"#text"` + Size string `json:"size"` + } `json:"image"` + } `json:"album"` + } `json:"track"` + } + + q := url.Values{} + q.Set("method", "track.getInfo") + q.Set("api_key", c.lastFMKey) + q.Set("artist", artist) + q.Set("track", title) + q.Set("format", "json") + + endpoint := baseOr(c.LastFMBaseURL, DefaultLastFMBaseURL) + "/2.0/?" + q.Encode() + if err := c.getJSON(ctx, endpoint, &payload); err != nil { + return nil, err + } + + imgURL, best := "", -1 + for _, im := range payload.Track.Album.Image { + if strings.TrimSpace(im.Text) == "" { + continue + } + if rank := lastFMSizeRank(im.Size); rank > best { + imgURL, best = im.Text, rank + } + } + if imgURL == "" { + return nil, ErrNotFound + } + + data, err := c.download(ctx, imgURL) + if err != nil { + return nil, err + } + return &Cover{ + Data: data, + Source: "Last.fm", + Artist: payload.Track.Album.Artist, + Title: payload.Track.Name, + Album: payload.Track.Album.Title, + URL: imgURL, + }, nil +} + +// lastFMSizeRank orders the Last.fm image size labels from smallest to +// largest. Unknown labels rank lowest. +func lastFMSizeRank(size string) int { + switch strings.ToLower(strings.TrimSpace(size)) { + case "small": + return 1 + case "medium": + return 2 + case "large": + return 3 + case "extralarge": + return 4 + case "mega": + return 5 + default: + return 0 + } +} + +// getJSON performs a GET against rawURL and decodes the (size-capped) JSON +// body into out. +func (c *Client) getJSON(ctx context.Context, rawURL string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return fmt.Errorf("art: build request: %w", err) + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("art: request %s: %w", rawURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return fmt.Errorf("art: provider returned status %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(out); err != nil { + return fmt.Errorf("art: decode response: %w", err) + } + return nil +} + +// download retrieves an image, capping the body at 8 MB, rejecting non-image +// content types and verifying that the bytes actually decode as an image. +func (c *Client) download(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, fmt.Errorf("art: build image request: %w", err) + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "image/*") + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("art: download %s: %w", rawURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("art: image download returned status %d", resp.StatusCode) + } + if ct := contentType(resp.Header.Get("Content-Type")); ct != "" && !isImageContentType(ct) { + return nil, fmt.Errorf("art: unexpected content type %q for cover art", ct) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes)) + if err != nil { + return nil, fmt.Errorf("art: read image body: %w", err) + } + if len(data) == 0 { + return nil, fmt.Errorf("art: empty image body") + } + if _, _, err := image.Decode(bytes.NewReader(data)); err != nil { + return nil, fmt.Errorf("art: downloaded bytes are not a usable image: %w", err) + } + return data, nil +} + +// httpClient returns the configured client or a sane default. +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: 10 * time.Second} +} + +// contentType strips parameters from a Content-Type header value. +func contentType(v string) string { + v = strings.TrimSpace(strings.ToLower(v)) + if i := strings.IndexByte(v, ';'); i >= 0 { + v = strings.TrimSpace(v[:i]) + } + return v +} + +// isImageContentType reports whether a media type may carry image bytes. +func isImageContentType(ct string) bool { + return strings.HasPrefix(ct, "image/") || + ct == "application/octet-stream" || + ct == "binary/octet-stream" +} + +// isMBID reports whether s looks like a MusicBrainz UUID, so it can safely be +// interpolated into a Cover Art Archive path. +func isMBID(s string) bool { + if len(s) != 36 { + return false + } + for i := 0; i < len(s); i++ { + ch := s[i] + switch i { + case 8, 13, 18, 23: + if ch != '-' { + return false + } + default: + isHex := (ch >= '0' && ch <= '9') || + (ch >= 'a' && ch <= 'f') || + (ch >= 'A' && ch <= 'F') + if !isHex { + return false + } + } + } + return true +} + +// baseOr trims a configured base URL, falling back to def when it is empty. +func baseOr(configured, def string) string { + v := strings.TrimRight(strings.TrimSpace(configured), "/") + if v == "" { + return strings.TrimRight(def, "/") + } + return v +} + +// firstNonEmpty returns the first argument that is not blank. +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/pkg/art/client_test.go b/pkg/art/client_test.go new file mode 100644 index 0000000..f112ef3 --- /dev/null +++ b/pkg/art/client_test.go @@ -0,0 +1,706 @@ +package art + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// stubProviders is an httptest-backed stand-in for all four cover providers. +// Every base URL on the Client is pointed at a distinct path prefix on the +// same server so the test can tell the providers apart. +type stubProviders struct { + t *testing.T + srv *httptest.Server + + mu sync.Mutex + hits map[string]int + + // itunesArtwork is returned verbatim as artworkUrl100; empty means the + // provider reports no results. + itunesArtwork string + deezerCover string + mbid string + lastFMImage string + + // imageBody/imageType control what the image endpoint serves. + imageBody []byte + imageType string + imageStatus int + + // lastArtworkPath records the path the client actually requested for the + // image, so the 600x600 rewrite can be asserted. + lastArtworkPath string + lastQueries map[string]url.Values +} + +func newStubProviders(t *testing.T) *stubProviders { + t.Helper() + s := &stubProviders{ + t: t, + hits: map[string]int{}, + lastQueries: map[string]url.Values{}, + imageBody: testPNG(t, 32, 32), + imageType: "image/png", + imageStatus: http.StatusOK, + } + mux := http.NewServeMux() + mux.HandleFunc("/itunes/search", s.handleITunes) + mux.HandleFunc("/deezer/search", s.handleDeezer) + mux.HandleFunc("/mb/ws/2/recording", s.handleMusicBrainz) + mux.HandleFunc("/caa/release/", s.handleImage) + mux.HandleFunc("/lastfm/2.0/", s.handleLastFM) + mux.HandleFunc("/img/", s.handleImage) + s.srv = httptest.NewServer(mux) + t.Cleanup(s.srv.Close) + return s +} + +func (s *stubProviders) record(name string, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.hits[name]++ + s.lastQueries[name] = r.URL.Query() + if ua := r.Header.Get("User-Agent"); ua != userAgent { + s.t.Errorf("%s: User-Agent = %q, want %q", name, ua, userAgent) + } +} + +func (s *stubProviders) count(name string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.hits[name] +} + +func (s *stubProviders) query(name string) url.Values { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastQueries[name] +} + +func (s *stubProviders) handleITunes(w http.ResponseWriter, r *http.Request) { + s.record("itunes", r) + w.Header().Set("Content-Type", "application/json") + if s.itunesArtwork == "" { + fmt.Fprint(w, `{"resultCount":0,"results":[]}`) + return + } + fmt.Fprintf(w, `{"resultCount":1,"results":[{"artistName":"Stub Artist","trackName":"Stub Track","collectionName":"Stub Album","artworkUrl100":%q}]}`, s.itunesArtwork) +} + +func (s *stubProviders) handleDeezer(w http.ResponseWriter, r *http.Request) { + s.record("deezer", r) + w.Header().Set("Content-Type", "application/json") + if s.deezerCover == "" { + fmt.Fprint(w, `{"data":[]}`) + return + } + fmt.Fprintf(w, `{"data":[{"title":"Deezer Track","artist":{"name":"Deezer Artist"},"album":{"title":"Deezer Album","cover_xl":%q,"cover_big":"ignored"}}]}`, s.deezerCover) +} + +func (s *stubProviders) handleMusicBrainz(w http.ResponseWriter, r *http.Request) { + s.record("musicbrainz", r) + w.Header().Set("Content-Type", "application/json") + if s.mbid == "" { + fmt.Fprint(w, `{"recordings":[]}`) + return + } + fmt.Fprintf(w, `{"recordings":[{"title":"MB Track","artist-credit":[{"name":"MB Artist"}],"releases":[{"id":%q,"title":"MB Album"}]}]}`, s.mbid) +} + +func (s *stubProviders) handleLastFM(w http.ResponseWriter, r *http.Request) { + s.record("lastfm", r) + w.Header().Set("Content-Type", "application/json") + if s.lastFMImage == "" { + fmt.Fprint(w, `{"track":{}}`) + return + } + fmt.Fprintf(w, `{"track":{"name":"LFM Track","album":{"artist":"LFM Artist","title":"LFM Album","image":[{"#text":"small.png","size":"small"},{"#text":%q,"size":"mega"},{"#text":"med.png","size":"medium"}]}}}`, s.lastFMImage) +} + +func (s *stubProviders) handleImage(w http.ResponseWriter, r *http.Request) { + s.record("image", r) + s.mu.Lock() + s.lastArtworkPath = r.URL.Path + s.mu.Unlock() + if s.imageType != "" { + w.Header().Set("Content-Type", s.imageType) + } + w.WriteHeader(s.imageStatus) + _, _ = w.Write(s.imageBody) +} + +// client wires a Client to the stub server, with disk caching in dir. +func (s *stubProviders) client(dir, lastFMKey string) *Client { + c := NewClient(dir, lastFMKey) + c.ITunesBaseURL = s.srv.URL + "/itunes" + c.DeezerBaseURL = s.srv.URL + "/deezer" + c.MusicBrainzBaseURL = s.srv.URL + "/mb" + c.CoverArtBaseURL = s.srv.URL + "/caa" + c.LastFMBaseURL = s.srv.URL + "/lastfm" + c.HTTPClient = s.srv.Client() + return c +} + +func TestFetchProviderChain(t *testing.T) { + tests := []struct { + name string + configure func(s *stubProviders) + lastFMKey string + wantSource string + wantAlbum string + wantErr error + wantHits map[string]int + }{ + { + name: "itunes wins first", + configure: func(s *stubProviders) { + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + }, + wantSource: "iTunes", + wantAlbum: "Stub Album", + wantHits: map[string]int{"itunes": 1, "deezer": 0, "musicbrainz": 0, "lastfm": 0}, + }, + { + name: "falls through to deezer", + configure: func(s *stubProviders) { + s.deezerCover = s.srv.URL + "/img/deezer.png" + }, + wantSource: "Deezer", + wantAlbum: "Deezer Album", + wantHits: map[string]int{"itunes": 1, "deezer": 1, "musicbrainz": 0}, + }, + { + name: "falls through to cover art archive", + configure: func(s *stubProviders) { + s.mbid = "11111111-2222-3333-4444-555555555555" + }, + wantSource: "Cover Art Archive", + wantAlbum: "MB Album", + wantHits: map[string]int{"itunes": 1, "deezer": 1, "musicbrainz": 1}, + }, + { + name: "falls through to last.fm when a key is set", + configure: func(s *stubProviders) { + s.lastFMImage = s.srv.URL + "/img/lastfm.png" + }, + lastFMKey: "secret-key", + wantSource: "Last.fm", + wantAlbum: "LFM Album", + wantHits: map[string]int{"itunes": 1, "deezer": 1, "musicbrainz": 1, "lastfm": 1}, + }, + { + name: "last.fm skipped without a key", + configure: func(s *stubProviders) { + s.lastFMImage = s.srv.URL + "/img/lastfm.png" + }, + wantErr: ErrNotFound, + wantHits: map[string]int{"itunes": 1, "deezer": 1, "musicbrainz": 1, "lastfm": 0}, + }, + { + name: "no provider has artwork", + configure: func(s *stubProviders) {}, + wantErr: ErrNotFound, + wantHits: map[string]int{"itunes": 1, "deezer": 1, "musicbrainz": 1, "lastfm": 0}, + }, + { + name: "invalid mbid is rejected before the CAA request", + configure: func(s *stubProviders) { + s.mbid = "../../etc/passwd" + }, + wantErr: ErrNotFound, + wantHits: map[string]int{"musicbrainz": 1, "image": 0}, + }, + { + name: "non-image content type falls through", + configure: func(s *stubProviders) { + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + s.imageType = "text/html" + }, + wantErr: ErrNotFound, + wantHits: map[string]int{"itunes": 1, "deezer": 1, "musicbrainz": 1}, + }, + { + name: "undecodable payload falls through", + configure: func(s *stubProviders) { + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + s.imageBody = []byte("nope") + }, + wantErr: ErrNotFound, + wantHits: map[string]int{"itunes": 1, "deezer": 1}, + }, + { + name: "image error status falls through", + configure: func(s *stubProviders) { + s.deezerCover = s.srv.URL + "/img/deezer.png" + s.imageStatus = http.StatusNotFound + }, + wantErr: ErrNotFound, + wantHits: map[string]int{"deezer": 1, "musicbrainz": 1}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := newStubProviders(t) + tc.configure(s) + c := s.client(filepath.Join(t.TempDir(), "covers"), tc.lastFMKey) + + cover, err := c.Fetch(context.Background(), "Daft Punk", "One More Time", "Discovery") + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + if cover != nil { + t.Fatalf("expected a nil cover alongside %v", tc.wantErr) + } + } else { + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if cover.Source != tc.wantSource { + t.Fatalf("Source = %q, want %q", cover.Source, tc.wantSource) + } + if len(cover.Data) == 0 { + t.Fatal("cover has no data") + } + // The caller-supplied album wins over the provider's. + if cover.Album != "Discovery" { + t.Fatalf("Album = %q, want the caller's %q", cover.Album, "Discovery") + } + if cover.URL == "" { + t.Fatal("cover has no origin URL") + } + } + + for name, want := range tc.wantHits { + if got := s.count(name); got != want { + t.Errorf("%s hits = %d, want %d", name, got, want) + } + } + }) + } +} + +func TestFetchITunesUpgradesArtworkSize(t *testing.T) { + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/cover/100x100bb.jpg" + c := s.client("", "") + + if _, err := c.Fetch(context.Background(), "Artist", "Title", ""); err != nil { + t.Fatalf("Fetch: %v", err) + } + s.mu.Lock() + got := s.lastArtworkPath + s.mu.Unlock() + if !strings.HasSuffix(got, "/600x600bb.jpg") { + t.Fatalf("artwork path = %q, want the 600x600bb rewrite", got) + } +} + +func TestFetchProviderQueries(t *testing.T) { + s := newStubProviders(t) + s.mbid = "11111111-2222-3333-4444-555555555555" + c := s.client("", "lfm-key") + + // Noise in the track fields must be stripped before it reaches a provider. + if _, err := c.Fetch(context.Background(), "Daft Punk", "Aerodynamic (Official Video)", ""); err != nil { + t.Fatalf("Fetch: %v", err) + } + + tests := []struct { + provider string + param string + want string + }{ + {"itunes", "term", "daft punk aerodynamic"}, + {"itunes", "media", "music"}, + {"itunes", "entity", "song"}, + {"itunes", "limit", "1"}, + {"deezer", "q", "daft punk aerodynamic"}, + {"deezer", "limit", "1"}, + {"musicbrainz", "query", `artist:"daft punk" AND recording:"aerodynamic"`}, + {"musicbrainz", "fmt", "json"}, + {"musicbrainz", "limit", "1"}, + } + for _, tc := range tests { + t.Run(tc.provider+"_"+tc.param, func(t *testing.T) { + if got := s.query(tc.provider).Get(tc.param); got != tc.want { + t.Fatalf("%s %s = %q, want %q", tc.provider, tc.param, got, tc.want) + } + }) + } +} + +func TestFetchLastFMPicksLargestImage(t *testing.T) { + s := newStubProviders(t) + s.lastFMImage = s.srv.URL + "/img/mega.png" + c := s.client("", "lfm-key") + + cover, err := c.Fetch(context.Background(), "Artist", "Title", "") + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if !strings.HasSuffix(cover.URL, "/img/mega.png") { + t.Fatalf("URL = %q, want the mega-sized image", cover.URL) + } + if got := s.query("lastfm").Get("api_key"); got != "lfm-key" { + t.Fatalf("api_key = %q", got) + } + if got := s.query("lastfm").Get("method"); got != "track.getInfo" { + t.Fatalf("method = %q", got) + } +} + +func TestFetchUsesMemoryCache(t *testing.T) { + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + c := s.client("", "") + + for i := 0; i < 3; i++ { + if _, err := c.Fetch(context.Background(), "Artist", "Title (feat. X)", ""); err != nil { + t.Fatalf("Fetch %d: %v", i, err) + } + } + // The feat. suffix normalises away, so this is the same cache key. + if _, err := c.Fetch(context.Background(), " artist ", "title", ""); err != nil { + t.Fatalf("Fetch normalised: %v", err) + } + if got := s.count("itunes"); got != 1 { + t.Fatalf("itunes was queried %d times, want 1", got) + } +} + +func TestFetchCachesNegativeLookups(t *testing.T) { + s := newStubProviders(t) + c := s.client("", "") + + for i := 0; i < 3; i++ { + if _, err := c.Fetch(context.Background(), "Artist", "Title", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("Fetch %d err = %v, want ErrNotFound", i, err) + } + } + if got := s.count("itunes"); got != 1 { + t.Fatalf("itunes was queried %d times, want 1 (negative cache)", got) + } +} + +func TestFetchUsesDiskCacheAcrossClients(t *testing.T) { + dir := filepath.Join(t.TempDir(), "covers") + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + + first := s.client(dir, "") + want, err := first.Fetch(context.Background(), "Artist", "Title", "Album") + if err != nil { + t.Fatalf("first Fetch: %v", err) + } + + // A brand new client with an empty RAM cache must read from disk. + second := s.client(dir, "") + got, err := second.Fetch(context.Background(), "Artist", "Title", "Album") + if err != nil { + t.Fatalf("second Fetch: %v", err) + } + if got.Source != want.Source || got.URL != want.URL || len(got.Data) != len(want.Data) { + t.Fatalf("disk cache returned %+v, want %+v", got, want) + } + if hits := s.count("itunes"); hits != 1 { + t.Fatalf("itunes was queried %d times, want 1", hits) + } +} + +func TestFetchEmptyQuery(t *testing.T) { + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + c := s.client("", "") + + tests := []struct{ artist, title string }{ + {"", ""}, + {" ", " "}, + {"(Official Video)", "[Explicit]"}, + } + for _, tc := range tests { + t.Run(tc.artist+"/"+tc.title, func(t *testing.T) { + if _, err := c.Fetch(context.Background(), tc.artist, tc.title, ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + } + if got := s.count("itunes"); got != 0 { + t.Fatalf("providers were queried %d times for an empty track", got) + } +} + +func TestFetchRespectsContext(t *testing.T) { + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + c := s.client("", "") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := c.Fetch(ctx, "Artist", "Title", ""); !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + // A cancelled lookup must not be remembered as a negative result. + if _, err := c.Fetch(context.Background(), "Artist", "Title", ""); err != nil { + t.Fatalf("second Fetch: %v", err) + } +} + +func TestFetchNilReceiverAndContext(t *testing.T) { + if _, err := (*Client)(nil).Fetch(context.Background(), "a", "b", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("nil client err = %v, want ErrNotFound", err) + } + + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + c := s.client("", "") + //nolint:staticcheck // deliberately exercising the nil-context guard + if _, err := c.Fetch(nil, "Artist", "Title", ""); err != nil { + t.Fatalf("nil context Fetch: %v", err) + } +} + +func TestFetchIsConcurrencySafe(t *testing.T) { + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + c := s.client(filepath.Join(t.TempDir(), "covers"), "") + + var wg sync.WaitGroup + for i := 0; i < 24; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, _ = c.Fetch(context.Background(), "Artist", fmt.Sprintf("Title %d", i%4), "") + }(i) + } + wg.Wait() +} + +func TestNewClientDefaults(t *testing.T) { + c := NewClient("/tmp/does-not-need-to-exist", "key") + tests := []struct{ got, want string }{ + {c.MusicBrainzBaseURL, DefaultMusicBrainzBaseURL}, + {c.CoverArtBaseURL, DefaultCoverArtBaseURL}, + {c.ITunesBaseURL, DefaultITunesBaseURL}, + {c.DeezerBaseURL, DefaultDeezerBaseURL}, + {c.LastFMBaseURL, DefaultLastFMBaseURL}, + } + for _, tc := range tests { + if tc.got != tc.want { + t.Errorf("base URL = %q, want %q", tc.got, tc.want) + } + } + if c.HTTPClient == nil || c.HTTPClient.Timeout <= 0 { + t.Fatal("expected a default HTTP client with a timeout") + } + if c.lastFMKey != "key" { + t.Fatalf("lastFMKey = %q", c.lastFMKey) + } + if (*Client)(nil).CacheDir() != "" { + t.Fatal("nil client CacheDir should be empty") + } +} + +func TestBaseOr(t *testing.T) { + tests := []struct { + configured, def, want string + }{ + {"", "https://d.example", "https://d.example"}, + {" ", "https://d.example", "https://d.example"}, + {"https://x.example/", "https://d.example", "https://x.example"}, + {"https://x.example///", "https://d.example", "https://x.example"}, + {" https://x.example/sub ", "https://d.example", "https://x.example/sub"}, + {"", "https://d.example/", "https://d.example"}, + } + for _, tc := range tests { + if got := baseOr(tc.configured, tc.def); got != tc.want { + t.Errorf("baseOr(%q, %q) = %q, want %q", tc.configured, tc.def, got, tc.want) + } + } +} + +func TestIsMBID(t *testing.T) { + tests := map[string]bool{ + "11111111-2222-3333-4444-555555555555": true, + "AABBCCDD-2222-3333-4444-555555555555": true, + "": false, + "not-a-uuid": false, + "11111111222233334444555555555555": false, + "11111111-2222-3333-4444-55555555555g": false, + "../../../etc/passwd": false, + "11111111-2222-3333-4444-5555555555555": false, + } + for in, want := range tests { + if got := isMBID(in); got != want { + t.Errorf("isMBID(%q) = %v, want %v", in, got, want) + } + } +} + +func TestContentTypeHelpers(t *testing.T) { + tests := []struct { + raw string + norm string + isImage bool + }{ + {"image/png", "image/png", true}, + {"image/jpeg; charset=binary", "image/jpeg", true}, + {" IMAGE/PNG ", "image/png", true}, + {"application/octet-stream", "application/octet-stream", true}, + {"binary/octet-stream", "binary/octet-stream", true}, + {"text/html; charset=utf-8", "text/html", false}, + {"application/json", "application/json", false}, + {"", "", false}, + } + for _, tc := range tests { + t.Run(tc.raw, func(t *testing.T) { + norm := contentType(tc.raw) + if norm != tc.norm { + t.Fatalf("contentType(%q) = %q, want %q", tc.raw, norm, tc.norm) + } + if got := isImageContentType(norm); got != tc.isImage { + t.Fatalf("isImageContentType(%q) = %v, want %v", norm, got, tc.isImage) + } + }) + } +} + +func TestFirstNonEmpty(t *testing.T) { + tests := []struct { + in []string + want string + }{ + {nil, ""}, + {[]string{"", " ", "x"}, "x"}, + {[]string{"a", "b"}, "a"}, + {[]string{"", ""}, ""}, + } + for _, tc := range tests { + if got := firstNonEmpty(tc.in...); got != tc.want { + t.Errorf("firstNonEmpty(%v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestLastFMSizeRank(t *testing.T) { + order := []string{"unknown", "small", "medium", "large", "extralarge", "mega"} + for i := 1; i < len(order); i++ { + if lastFMSizeRank(order[i]) <= lastFMSizeRank(order[i-1]) { + t.Fatalf("%q should rank above %q", order[i], order[i-1]) + } + } + if lastFMSizeRank(" MEGA ") != lastFMSizeRank("mega") { + t.Fatal("size rank should be case and space insensitive") + } +} + +func TestGetJSONErrors(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + }{ + {"server error", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }}, + {"rate limited", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + }}, + {"malformed json", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"results":`) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(tc.handler) + defer srv.Close() + + c := NewClient("", "") + c.HTTPClient = srv.Client() + var out struct{} + if err := c.getJSON(context.Background(), srv.URL, &out); err == nil { + t.Fatal("expected an error") + } + }) + } +} + +func TestDownloadCapsBody(t *testing.T) { + // Serve far more than the cap; the client must stop reading and the + // truncated payload must fail to decode rather than blow up. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + chunk := make([]byte, 1<<20) + for i := 0; i < 12; i++ { + if _, err := w.Write(chunk); err != nil { + return + } + } + })) + defer srv.Close() + + c := NewClient("", "") + c.HTTPClient = &http.Client{Timeout: 30 * time.Second} + data, err := c.download(context.Background(), srv.URL) + if err == nil { + t.Fatalf("expected a decode failure, got %d bytes", len(data)) + } + if len(data) != 0 { + t.Fatalf("expected no data on error, got %d bytes", len(data)) + } +} + +func TestDownloadAcceptsOctetStream(t *testing.T) { + body := testJPEG(t, 16, 16) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(body) + })) + defer srv.Close() + + c := NewClient("", "") + c.HTTPClient = srv.Client() + data, err := c.download(context.Background(), srv.URL) + if err != nil { + t.Fatalf("download: %v", err) + } + if len(data) != len(body) { + t.Fatalf("got %d bytes, want %d", len(data), len(body)) + } +} + +// TestFetchThenRender exercises the whole pipeline: fetch a cover from the +// stub providers and render it through every protocol. +func TestFetchThenRender(t *testing.T) { + s := newStubProviders(t) + s.itunesArtwork = s.srv.URL + "/img/a/100x100bb.jpg" + c := s.client(filepath.Join(t.TempDir(), "covers"), "") + + cover, err := c.Fetch(context.Background(), "Artist", "Title", "Album") + if err != nil { + t.Fatalf("Fetch: %v", err) + } + + for _, proto := range []Protocol{ + ProtocolHalfBlock, ProtocolBraille, ProtocolKitty, ProtocolITerm2, ProtocolSixel, + } { + t.Run(string(proto), func(t *testing.T) { + lines, err := NewRenderer(proto).Render(cover.Data, 18, 9) + if err != nil { + t.Fatalf("Render: %v", err) + } + if len(lines) != 9 { + t.Fatalf("got %d lines, want 9", len(lines)) + } + }) + } +} diff --git a/pkg/art/detect.go b/pkg/art/detect.go new file mode 100644 index 0000000..b6b9854 --- /dev/null +++ b/pkg/art/detect.go @@ -0,0 +1,178 @@ +// Package art renders album artwork inside a terminal. +// +// It has two halves that can be used independently: a Client that resolves +// cover art for a track from several public providers (with RAM and disk +// caches), and a Renderer that turns encoded image bytes into terminal output +// using the best transport the host terminal supports — the Kitty graphics +// protocol, iTerm2 inline images, Sixel, truecolor half-blocks or braille. +// +// Every Renderer result is exactly the number of rows requested and every +// line reports the requested width through lipgloss.Width, so artwork can be +// dropped into a Bubble Tea layout without disturbing the surrounding frame. +package art + +import ( + "os" + "strings" +) + +// Protocol identifies a terminal image transport. +type Protocol string + +// Supported terminal image transports. +const ( + ProtocolKitty Protocol = "kitty" + ProtocolITerm2 Protocol = "iterm2" + ProtocolSixel Protocol = "sixel" + ProtocolHalfBlock Protocol = "halfblock" + ProtocolBraille Protocol = "braille" + ProtocolNone Protocol = "none" +) + +// Environment variables understood by DetectEnv. +const ( + // EnvProtocol forces a specific protocol, bypassing detection. + EnvProtocol = "HALPRADIO_ART_PROTOCOL" + // EnvNoArt disables artwork rendering entirely. + EnvNoArt = "HALPRADIO_NO_ART" + // EnvSixel declares that the terminal understands Sixel graphics. + EnvSixel = "HALPRADIO_SIXEL" +) + +// Label returns a short human-readable name for the protocol. +func (p Protocol) Label() string { + switch p { + case ProtocolKitty: + return "Kitty Graphics" + case ProtocolITerm2: + return "iTerm2 Inline" + case ProtocolSixel: + return "Sixel" + case ProtocolHalfBlock: + return "Truecolor Half-Block" + case ProtocolBraille: + return "Braille" + case ProtocolNone: + return "Disabled" + default: + return string(p) + } +} + +// Graphical reports whether the protocol uses a real pixel transport rather +// than character-cell approximation. +func (p Protocol) Graphical() bool { + switch p { + case ProtocolKitty, ProtocolITerm2, ProtocolSixel: + return true + default: + return false + } +} + +// DetectEnv resolves the best available protocol from the supplied environment +// lookup function. It is the testable core of Detect. +func DetectEnv(getenv func(string) string) Protocol { + if getenv == nil { + return ProtocolNone + } + + // An explicit override always wins, provided it names a real protocol. + if p, ok := parseProtocol(getenv(EnvProtocol)); ok { + return p + } + if truthy(getenv("NO_GRAPHICS")) || truthy(getenv(EnvNoArt)) { + return ProtocolNone + } + + term := strings.ToLower(strings.TrimSpace(getenv("TERM"))) + termProgram := strings.TrimSpace(getenv("TERM_PROGRAM")) + colorTerm := strings.ToLower(strings.TrimSpace(getenv("COLORTERM"))) + + // 1. Kitty graphics protocol: kitty, ghostty and WezTerm all speak it. + if strings.EqualFold(termProgram, "ghostty") || + strings.EqualFold(termProgram, "WezTerm") || + strings.Contains(term, "kitty") || + strings.TrimSpace(getenv("KITTY_WINDOW_ID")) != "" { + return ProtocolKitty + } + + // 2. iTerm2 inline images. + if strings.EqualFold(termProgram, "iTerm.app") || + strings.TrimSpace(getenv("ITERM_SESSION_ID")) != "" { + return ProtocolITerm2 + } + + // 3. Sixel, but only on an explicit positive signal. A generic + // xterm-256color plus COLORTERM is deliberately not enough. + for _, name := range []string{"foot", "mlterm", "yaft", "sixel"} { + if strings.Contains(term, name) { + return ProtocolSixel + } + } + if truthy(getenv(EnvSixel)) { + return ProtocolSixel + } + + // 4. Truecolor half-blocks. + if colorTerm == "truecolor" || colorTerm == "24bit" || strings.Contains(term, "256color") { + return ProtocolHalfBlock + } + + // 5. No usable terminal at all. + if term == "" || term == "dumb" { + return ProtocolNone + } + + // 6. Monochrome fallback. + return ProtocolBraille +} + +// Detect resolves the best protocol supported by the current terminal from +// process environment variables. +func Detect() Protocol { + return DetectEnv(os.Getenv) +} + +// Resolve maps a user preference ("auto", "kitty", "iterm2", "sixel", +// "halfblock", "braille", "off"/"none") to a Protocol. "auto" delegates to +// Detect. An unknown value falls back to Detect. +func Resolve(pref string) Protocol { + if p, ok := parseProtocol(pref); ok { + return p + } + return Detect() +} + +// parseProtocol maps a preference string onto a Protocol. It reports false for +// the empty string, "auto" and anything it does not recognise, so callers can +// fall through to detection. +func parseProtocol(pref string) (Protocol, bool) { + switch strings.ToLower(strings.TrimSpace(pref)) { + case "kitty": + return ProtocolKitty, true + case "iterm", "iterm2": + return ProtocolITerm2, true + case "sixel": + return ProtocolSixel, true + case "halfblock", "half-block", "half_block", "blocks": + return ProtocolHalfBlock, true + case "braille": + return ProtocolBraille, true + case "off", "none", "no", "disabled": + return ProtocolNone, true + default: + return "", false + } +} + +// truthy reports whether an environment value should be read as "enabled". +// Any non-empty value except an explicit negative counts as enabled. +func truthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", "0", "false", "no", "off": + return false + default: + return true + } +} diff --git a/pkg/art/detect_test.go b/pkg/art/detect_test.go new file mode 100644 index 0000000..b1cf093 --- /dev/null +++ b/pkg/art/detect_test.go @@ -0,0 +1,160 @@ +package art + +import "testing" + +// envMap adapts a map to the getenv function DetectEnv expects. +func envMap(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func TestDetectEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + want Protocol + }{ + {"nil lookup", nil, ProtocolNone}, + {"explicit override wins", map[string]string{ + EnvProtocol: "braille", "TERM": "xterm-kitty", + }, ProtocolBraille}, + {"override off", map[string]string{EnvProtocol: "off", "TERM": "xterm-kitty"}, ProtocolNone}, + {"override auto falls through", map[string]string{ + EnvProtocol: "auto", "TERM": "xterm-kitty", + }, ProtocolKitty}, + {"override garbage falls through", map[string]string{ + EnvProtocol: "banana", "TERM": "xterm-kitty", + }, ProtocolKitty}, + {"NO_GRAPHICS disables", map[string]string{ + "NO_GRAPHICS": "1", "TERM": "xterm-kitty", + }, ProtocolNone}, + {"NO_GRAPHICS=0 is not truthy", map[string]string{ + "NO_GRAPHICS": "0", "TERM": "xterm-kitty", + }, ProtocolKitty}, + {"HALPRADIO_NO_ART disables", map[string]string{ + EnvNoArt: "true", "TERM": "xterm-256color", + }, ProtocolNone}, + {"ghostty", map[string]string{"TERM_PROGRAM": "ghostty", "TERM": "xterm-256color"}, ProtocolKitty}, + {"wezterm", map[string]string{"TERM_PROGRAM": "WezTerm"}, ProtocolKitty}, + {"term kitty", map[string]string{"TERM": "xterm-kitty"}, ProtocolKitty}, + {"kitty window id", map[string]string{"KITTY_WINDOW_ID": "3", "TERM": "screen"}, ProtocolKitty}, + {"iterm program", map[string]string{"TERM_PROGRAM": "iTerm.app", "TERM": "xterm-256color"}, ProtocolITerm2}, + {"iterm session", map[string]string{"ITERM_SESSION_ID": "w0t0p0", "TERM": "screen"}, ProtocolITerm2}, + {"kitty beats iterm", map[string]string{ + "TERM": "xterm-kitty", "ITERM_SESSION_ID": "w0t0p0", + }, ProtocolKitty}, + {"foot", map[string]string{"TERM": "foot"}, ProtocolSixel}, + {"mlterm", map[string]string{"TERM": "mlterm"}, ProtocolSixel}, + {"yaft", map[string]string{"TERM": "yaft-256color"}, ProtocolSixel}, + {"term says sixel", map[string]string{"TERM": "xterm-sixel"}, ProtocolSixel}, + {"sixel env flag", map[string]string{"TERM": "xterm-256color", EnvSixel: "1"}, ProtocolSixel}, + {"256color plus colorterm is not sixel", map[string]string{ + "TERM": "xterm-256color", "COLORTERM": "truecolor", + }, ProtocolHalfBlock}, + {"truecolor", map[string]string{"TERM": "screen", "COLORTERM": "truecolor"}, ProtocolHalfBlock}, + {"24bit", map[string]string{"TERM": "screen", "COLORTERM": "24bit"}, ProtocolHalfBlock}, + {"256color term", map[string]string{"TERM": "tmux-256color"}, ProtocolHalfBlock}, + {"dumb terminal", map[string]string{"TERM": "dumb"}, ProtocolNone}, + {"empty term", map[string]string{}, ProtocolNone}, + {"plain vt100 falls back to braille", map[string]string{"TERM": "vt100"}, ProtocolBraille}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var getenv func(string) string + if tc.env != nil { + getenv = envMap(tc.env) + } + if got := DetectEnv(getenv); got != tc.want { + t.Fatalf("DetectEnv() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestResolve(t *testing.T) { + t.Setenv("TERM", "xterm-kitty") + t.Setenv("HALPRADIO_ART_PROTOCOL", "") + t.Setenv("HALPRADIO_NO_ART", "") + t.Setenv("NO_GRAPHICS", "") + + tests := []struct { + pref string + want Protocol + }{ + {"kitty", ProtocolKitty}, + {"iterm2", ProtocolITerm2}, + {"iterm", ProtocolITerm2}, + {"sixel", ProtocolSixel}, + {"halfblock", ProtocolHalfBlock}, + {"half-block", ProtocolHalfBlock}, + {"braille", ProtocolBraille}, + {"off", ProtocolNone}, + {"none", ProtocolNone}, + {" KITTY ", ProtocolKitty}, + {"auto", ProtocolKitty}, // delegates to Detect + {"", ProtocolKitty}, // empty delegates to Detect + {"nonsense", ProtocolKitty}, // unknown falls back to Detect + } + + for _, tc := range tests { + t.Run(tc.pref, func(t *testing.T) { + if got := Resolve(tc.pref); got != tc.want { + t.Fatalf("Resolve(%q) = %q, want %q", tc.pref, got, tc.want) + } + }) + } +} + +func TestDetectUsesProcessEnv(t *testing.T) { + t.Setenv("HALPRADIO_ART_PROTOCOL", "sixel") + if got := Detect(); got != ProtocolSixel { + t.Fatalf("Detect() = %q, want %q", got, ProtocolSixel) + } +} + +func TestProtocolLabelAndGraphical(t *testing.T) { + tests := []struct { + proto Protocol + label string + graphical bool + }{ + {ProtocolKitty, "Kitty Graphics", true}, + {ProtocolITerm2, "iTerm2 Inline", true}, + {ProtocolSixel, "Sixel", true}, + {ProtocolHalfBlock, "Truecolor Half-Block", false}, + {ProtocolBraille, "Braille", false}, + {ProtocolNone, "Disabled", false}, + {Protocol("weird"), "weird", false}, + } + + for _, tc := range tests { + t.Run(string(tc.proto), func(t *testing.T) { + if got := tc.proto.Label(); got != tc.label { + t.Errorf("Label() = %q, want %q", got, tc.label) + } + if got := tc.proto.Graphical(); got != tc.graphical { + t.Errorf("Graphical() = %v, want %v", got, tc.graphical) + } + }) + } +} + +func TestTruthy(t *testing.T) { + tests := map[string]bool{ + "": false, + "0": false, + "false": false, + "NO": false, + " off ": false, + "1": true, + "true": true, + "yes": true, + "on": true, + "x": true, + } + for in, want := range tests { + if got := truthy(in); got != want { + t.Errorf("truthy(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/pkg/art/halfblock.go b/pkg/art/halfblock.go new file mode 100644 index 0000000..e0f0a46 --- /dev/null +++ b/pkg/art/halfblock.go @@ -0,0 +1,69 @@ +package art + +import ( + "image" + "strconv" + "strings" +) + +// upperHalfBlock paints the top half of a cell, letting the cell background +// show through as the bottom half. One cell therefore carries two pixels. +const upperHalfBlock = "▀" + +// sgrReset returns the terminal to its default colours. +const sgrReset = "\x1b[0m" + +// renderHalfBlock converts a prepared cols x (rows*2) pixel grid into rows +// lines of truecolor half-block cells. Each line is exactly cols cells wide +// and ends with an SGR reset so no colour state escapes the line. +func renderHalfBlock(img *image.RGBA, cols, rows int) []string { + lines := make([]string, 0, rows) + var sb strings.Builder + + for row := 0; row < rows; row++ { + sb.Reset() + sb.Grow(cols * 24) + + // -1 forces the first cell of every line to emit both colours, so a + // line never depends on the SGR state left by the previous one. + lastFg, lastBg := -1, -1 + for col := 0; col < cols; col++ { + tr, tg, tb := pixelAt(img, col, row*2) + br, bg, bb := pixelAt(img, col, row*2+1) + + fg := rgbKey(tr, tg, tb) + bgk := rgbKey(br, bg, bb) + if fg != lastFg { + writeSGRColor(&sb, 38, tr, tg, tb) + lastFg = fg + } + if bgk != lastBg { + writeSGRColor(&sb, 48, br, bg, bb) + lastBg = bgk + } + sb.WriteString(upperHalfBlock) + } + sb.WriteString(sgrReset) + lines = append(lines, sb.String()) + } + return lines +} + +// writeSGRColor appends a truecolor SGR sequence for layer 38 (foreground) or +// 48 (background). +func writeSGRColor(sb *strings.Builder, layer int, r, g, b uint8) { + sb.WriteString("\x1b[") + sb.WriteString(strconv.Itoa(layer)) + sb.WriteString(";2;") + sb.WriteString(strconv.Itoa(int(r))) + sb.WriteByte(';') + sb.WriteString(strconv.Itoa(int(g))) + sb.WriteByte(';') + sb.WriteString(strconv.Itoa(int(b))) + sb.WriteByte('m') +} + +// rgbKey packs an RGB triple into a comparable integer. +func rgbKey(r, g, b uint8) int { + return int(r)<<16 | int(g)<<8 | int(b) +} diff --git a/pkg/art/halfblock_test.go b/pkg/art/halfblock_test.go new file mode 100644 index 0000000..c5a47f8 --- /dev/null +++ b/pkg/art/halfblock_test.go @@ -0,0 +1,111 @@ +package art + +import ( + "bytes" + "image" + "image/color" + "image/png" + "strings" + "testing" +) + +// solidPNG encodes a w x h image of a single colour. +func solidPNG(t *testing.T, w, h int, c color.RGBA) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode solid png: %v", err) + } + return buf.Bytes() +} + +func TestRenderHalfBlockColours(t *testing.T) { + tests := []struct { + name string + colour color.RGBA + wantFg string + wantBg string + }{ + {"red", color.RGBA{R: 0xff, A: 0xff}, "\x1b[38;2;255;0;0m", "\x1b[48;2;255;0;0m"}, + {"white", color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, "\x1b[38;2;255;255;255m", "\x1b[48;2;255;255;255m"}, + {"black", color.RGBA{A: 0xff}, "\x1b[38;2;0;0;0m", "\x1b[48;2;0;0;0m"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // A square source in a square cell block fills the block, so + // every cell carries the source colour. + lines, err := NewRenderer(ProtocolHalfBlock).Render(solidPNG(t, 32, 32, tc.colour), 8, 4) + if err != nil { + t.Fatalf("Render: %v", err) + } + for i, line := range lines { + if !strings.Contains(line, tc.wantFg) { + t.Fatalf("line %d missing fg %q: %q", i, tc.wantFg, line) + } + if !strings.Contains(line, tc.wantBg) { + t.Fatalf("line %d missing bg %q: %q", i, tc.wantBg, line) + } + if got := strings.Count(line, upperHalfBlock); got != 8 { + t.Fatalf("line %d has %d block glyphs, want 8", i, got) + } + } + }) + } +} + +func TestRenderBrailleDotCoverage(t *testing.T) { + tests := []struct { + name string + colour color.RGBA + want rune + }{ + {"black lights no dots", color.RGBA{A: 0xff}, brailleBase}, + {"white lights every dot", color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, brailleBase + 0xff}, + {"mid grey lights every dot", color.RGBA{R: 0x80, G: 0x80, B: 0x80, A: 0xff}, brailleBase + 0xff}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + lines, err := NewRenderer(ProtocolBraille).Render(solidPNG(t, 64, 64, tc.colour), 8, 4) + if err != nil { + t.Fatalf("Render: %v", err) + } + for i, line := range lines { + body := strings.TrimSuffix(line, sgrReset) + for _, r := range body { + if r == '\x1b' || r == '[' || r == ';' || r == 'm' || (r >= '0' && r <= '9') { + continue + } + if r != tc.want { + t.Fatalf("line %d: got rune %U, want %U", i, r, tc.want) + } + } + } + }) + } +} + +func TestRGBKeyDistinguishesColours(t *testing.T) { + tests := []struct { + a, b [3]uint8 + same bool + }{ + {[3]uint8{1, 2, 3}, [3]uint8{1, 2, 3}, true}, + {[3]uint8{1, 2, 3}, [3]uint8{3, 2, 1}, false}, + {[3]uint8{0, 0, 0}, [3]uint8{0, 0, 1}, false}, + } + for _, tc := range tests { + ka := rgbKey(tc.a[0], tc.a[1], tc.a[2]) + kb := rgbKey(tc.b[0], tc.b[1], tc.b[2]) + if (ka == kb) != tc.same { + t.Fatalf("rgbKey(%v)==rgbKey(%v) was %v, want %v", tc.a, tc.b, ka == kb, tc.same) + } + } +} diff --git a/pkg/art/iterm2.go b/pkg/art/iterm2.go new file mode 100644 index 0000000..e147af0 --- /dev/null +++ b/pkg/art/iterm2.go @@ -0,0 +1,19 @@ +package art + +import ( + "encoding/base64" + "fmt" +) + +// renderITerm2 encodes a PNG payload as an iTerm2 inline image (OSC 1337) +// sized to cols x rows terminal cells. +func renderITerm2(png []byte, cols, rows int) string { + if len(png) == 0 { + return "" + } + payload := base64.StdEncoding.EncodeToString(png) + return fmt.Sprintf( + "\x1b]1337;File=inline=1;width=%d;height=%d;preserveAspectRatio=1;size=%d:%s\x07", + cols, rows, len(png), payload, + ) +} diff --git a/pkg/art/kitty.go b/pkg/art/kitty.go new file mode 100644 index 0000000..d7acb1e --- /dev/null +++ b/pkg/art/kitty.go @@ -0,0 +1,61 @@ +package art + +import ( + "encoding/base64" + "fmt" + "strings" +) + +const ( + // kittyImageID is the placement id every halpradio cover uses. Keeping it + // stable means a redraw replaces the previous cover instead of stacking. + kittyImageID = 4242 + // kittyChunkSize is the maximum base64 payload per APC escape, as + // mandated by the Kitty graphics protocol. + kittyChunkSize = 4096 +) + +// renderKitty encodes a PNG payload as a Kitty graphics protocol placement +// scaled to cols x rows terminal cells. The payload is base64 encoded and +// split into 4096 byte chunks wrapped in APC escapes. +func renderKitty(png []byte, cols, rows int) string { + payload := base64.StdEncoding.EncodeToString(png) + if payload == "" { + return "" + } + + var sb strings.Builder + first := true + for len(payload) > 0 { + size := kittyChunkSize + if len(payload) < size { + size = len(payload) + } + chunk := payload[:size] + payload = payload[size:] + + more := 0 + if len(payload) > 0 { + more = 1 + } + + sb.WriteString("\x1b_G") + if first { + // q=2 suppresses the terminal's success and error replies. Without + // it kitty and ghostty answer on stdin, which a TUI input reader + // surfaces as junk keypresses. + fmt.Fprintf(&sb, "a=T,f=100,i=%d,c=%d,r=%d,q=2,m=%d", kittyImageID, cols, rows, more) + first = false + } else { + fmt.Fprintf(&sb, "m=%d", more) + } + sb.WriteByte(';') + sb.WriteString(chunk) + // APC sequences must be terminated with ST; BEL is not recognised. + sb.WriteString("\x1b\\") + } + return sb.String() +} + +// kittyClear is the Kitty escape that deletes every placed image. +const kittyClear = "\x1b_Ga=d,d=A\x1b\\" diff --git a/pkg/art/render.go b/pkg/art/render.go new file mode 100644 index 0000000..0054bf5 --- /dev/null +++ b/pkg/art/render.go @@ -0,0 +1,118 @@ +package art + +import ( + "bytes" + "fmt" + "image" + "image/png" + "strings" +) + +// Renderer converts encoded images into terminal-ready output. +type Renderer struct { + Protocol Protocol + // CellAspect is the pixel height/width ratio of one terminal cell, + // used to keep artwork square. Defaults to 2.0 when zero. + CellAspect float64 +} + +// NewRenderer returns a Renderer for the given protocol. +func NewRenderer(p Protocol) *Renderer { + return &Renderer{Protocol: p, CellAspect: defaultCellAspect} +} + +// Render decodes img and returns exactly rows lines of terminal output, each +// padded so that lipgloss.Width reports cols. Escape-sequence protocols place +// the image at the cursor and pad the remaining rows with spaces so the +// surrounding Bubble Tea layout stays intact. +// +// It returns an error for a non-positive size, for ProtocolNone, for an +// unknown protocol and for image bytes it cannot decode. It never panics. +func (r *Renderer) Render(img []byte, cols, rows int) ([]string, error) { + if cols <= 0 || rows <= 0 { + return nil, fmt.Errorf("art: invalid render size %dx%d cells", cols, rows) + } + + aspect := defaultCellAspect + if r != nil && r.CellAspect > 0 { + aspect = r.CellAspect + } + proto := ProtocolNone + if r != nil { + proto = r.Protocol + } + + switch proto { + case ProtocolNone: + return nil, fmt.Errorf("art: artwork rendering is disabled") + case ProtocolHalfBlock, ProtocolBraille, ProtocolKitty, ProtocolITerm2, ProtocolSixel: + default: + return nil, fmt.Errorf("art: unsupported protocol %q", string(proto)) + } + + canvas, err := prepare(img, proto, cols, rows, aspect) + if err != nil { + return nil, err + } + + switch proto { + case ProtocolHalfBlock: + return renderHalfBlock(canvas, cols, rows), nil + case ProtocolBraille: + return renderBraille(canvas, cols, rows), nil + case ProtocolKitty: + data, err := encodePNG(canvas) + if err != nil { + return nil, err + } + return escapeLines(renderKitty(data, cols, rows), cols, rows), nil + case ProtocolITerm2: + data, err := encodePNG(canvas) + if err != nil { + return nil, err + } + return escapeLines(renderITerm2(data, cols, rows), cols, rows), nil + case ProtocolSixel: + b := canvas.Bounds() + return escapeLines(renderSixel(canvas, b.Dx(), b.Dy()), cols, rows), nil + } + + // Unreachable: the switch above validates every accepted protocol. + return nil, fmt.Errorf("art: unsupported protocol %q", string(proto)) +} + +// Clear returns the escape sequence that removes any previously placed images +// for this protocol, or "" when the protocol leaves no residue. +func (r *Renderer) Clear() string { + if r == nil { + return "" + } + if r.Protocol == ProtocolKitty { + return kittyClear + } + return "" +} + +// escapeLines wraps a single escape sequence into the rows x cols block shape +// every protocol must honour: the sequence is emitted at the cursor on the +// first line and the whole block is padded with spaces so the caller's layout +// arithmetic holds. +func escapeLines(escape string, cols, rows int) []string { + pad := strings.Repeat(" ", cols) + lines := make([]string, rows) + lines[0] = escape + pad + for i := 1; i < rows; i++ { + lines[i] = pad + } + return lines +} + +// encodePNG re-encodes a prepared canvas as PNG for the escape-based +// transports. +func encodePNG(img image.Image) ([]byte, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("art: encode png: %w", err) + } + return buf.Bytes(), nil +} diff --git a/pkg/art/render_test.go b/pkg/art/render_test.go new file mode 100644 index 0000000..41b6176 --- /dev/null +++ b/pkg/art/render_test.go @@ -0,0 +1,306 @@ +package art + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +// testPNG builds a deterministic w x h gradient PNG in memory. +func testPNG(t *testing.T, w, h int) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, testImage(w, h)); err != nil { + t.Fatalf("encode test png: %v", err) + } + return buf.Bytes() +} + +// testJPEG builds a deterministic w x h gradient JPEG in memory. +func testJPEG(t *testing.T, w, h int) []byte { + t.Helper() + var buf bytes.Buffer + if err := jpeg.Encode(&buf, testImage(w, h), nil); err != nil { + t.Fatalf("encode test jpeg: %v", err) + } + return buf.Bytes() +} + +func testImage(w, h int) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + dw, dh := maxInt(w-1, 1), maxInt(h-1, 1) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{ + R: uint8(x * 255 / dw), + G: uint8(y * 255 / dh), + B: uint8((x + y) * 127 / (dw + dh)), + A: 0xff, + }) + } + } + return img +} + +// TestRenderBlockGeometry is the load-bearing invariant of this package: every +// protocol must return exactly rows lines, each measuring cols columns as far +// as lipgloss is concerned, so the caller's Bubble Tea layout arithmetic holds. +func TestRenderBlockGeometry(t *testing.T) { + protocols := []Protocol{ + ProtocolHalfBlock, + ProtocolBraille, + ProtocolKitty, + ProtocolITerm2, + ProtocolSixel, + } + sizes := []struct{ cols, rows int }{ + {1, 1}, + {1, 8}, + {8, 1}, + {20, 10}, + {24, 12}, + {40, 7}, + } + images := map[string][]byte{ + "png_square": testPNG(t, 64, 64), + "png_wide": testPNG(t, 120, 40), + "png_tall": testPNG(t, 40, 120), + "png_tiny": testPNG(t, 1, 1), + "jpeg_square": testJPEG(t, 64, 64), + "png_oversized": testPNG(t, 600, 600), + } + + for _, proto := range protocols { + for name, data := range images { + for _, size := range sizes { + t.Run(string(proto)+"/"+name+"/"+sizeName(size.cols, size.rows), func(t *testing.T) { + r := NewRenderer(proto) + lines, err := r.Render(data, size.cols, size.rows) + if err != nil { + t.Fatalf("Render: %v", err) + } + if len(lines) != size.rows { + t.Fatalf("got %d lines, want %d", len(lines), size.rows) + } + for i, line := range lines { + if w := lipgloss.Width(line); w != size.cols { + t.Fatalf("line %d: lipgloss.Width = %d, want %d", i, w, size.cols) + } + if strings.ContainsAny(line, "\n\r") { + t.Fatalf("line %d contains an embedded newline", i) + } + } + }) + } + } + } +} + +func sizeName(cols, rows int) string { + return fmt.Sprintf("%dx%d", cols, rows) +} + +// TestRenderCellProtocolsResetSGR guards against colour state leaking past a +// line boundary in the character-cell renderers. +func TestRenderCellProtocolsResetSGR(t *testing.T) { + for _, proto := range []Protocol{ProtocolHalfBlock, ProtocolBraille} { + t.Run(string(proto), func(t *testing.T) { + lines, err := NewRenderer(proto).Render(testPNG(t, 64, 64), 12, 6) + if err != nil { + t.Fatalf("Render: %v", err) + } + for i, line := range lines { + if !strings.HasSuffix(line, sgrReset) { + t.Fatalf("line %d does not end with an SGR reset: %q", i, line) + } + if !strings.HasPrefix(line, "\x1b[38;2;") { + t.Fatalf("line %d does not open with its own colour: %q", i, line) + } + } + }) + } +} + +// TestRenderEscapeProtocolsShape checks the escape-based contract: the payload +// lands on the first line and the rest of the block is plain padding. +func TestRenderEscapeProtocolsShape(t *testing.T) { + tests := []struct { + proto Protocol + prefix string + }{ + {ProtocolKitty, "\x1b_G"}, + {ProtocolITerm2, "\x1b]1337;File=inline=1;"}, + {ProtocolSixel, "\x1bPq\"1;1;"}, + } + + const cols, rows = 16, 8 + for _, tc := range tests { + t.Run(string(tc.proto), func(t *testing.T) { + lines, err := NewRenderer(tc.proto).Render(testPNG(t, 64, 64), cols, rows) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !strings.HasPrefix(lines[0], tc.prefix) { + t.Fatalf("first line does not start with %q", tc.prefix) + } + if !strings.HasSuffix(lines[0], strings.Repeat(" ", cols)) { + t.Fatal("first line is not padded to cols") + } + for i := 1; i < rows; i++ { + if lines[i] != strings.Repeat(" ", cols) { + t.Fatalf("line %d is not pure padding: %q", i, lines[i]) + } + } + }) + } +} + +func TestRenderErrors(t *testing.T) { + good := testPNG(t, 32, 32) + + tests := []struct { + name string + proto Protocol + data []byte + cols int + rows int + }{ + {"zero cols", ProtocolHalfBlock, good, 0, 4}, + {"zero rows", ProtocolHalfBlock, good, 4, 0}, + {"negative cols", ProtocolHalfBlock, good, -3, 4}, + {"negative rows", ProtocolHalfBlock, good, 4, -3}, + {"protocol none", ProtocolNone, good, 8, 4}, + {"unknown protocol", Protocol("ascii"), good, 8, 4}, + {"empty protocol", Protocol(""), good, 8, 4}, + {"nil data", ProtocolHalfBlock, nil, 8, 4}, + {"garbage data", ProtocolHalfBlock, []byte("this is not an image"), 8, 4}, + {"truncated png", ProtocolKitty, good[:12], 8, 4}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + lines, err := (&Renderer{Protocol: tc.proto}).Render(tc.data, tc.cols, tc.rows) + if err == nil { + t.Fatalf("expected an error, got %d lines", len(lines)) + } + if lines != nil { + t.Fatalf("expected nil lines alongside the error, got %d", len(lines)) + } + }) + } +} + +func TestRendererClear(t *testing.T) { + tests := []struct { + proto Protocol + want string + }{ + {ProtocolKitty, "\x1b_Ga=d,d=A\x1b\\"}, + {ProtocolITerm2, ""}, + {ProtocolSixel, ""}, + {ProtocolHalfBlock, ""}, + {ProtocolBraille, ""}, + {ProtocolNone, ""}, + } + for _, tc := range tests { + t.Run(string(tc.proto), func(t *testing.T) { + if got := NewRenderer(tc.proto).Clear(); got != tc.want { + t.Fatalf("Clear() = %q, want %q", got, tc.want) + } + }) + } + if got := (*Renderer)(nil).Clear(); got != "" { + t.Fatalf("nil Renderer Clear() = %q, want empty", got) + } + // The Kitty clear sequence must not consume the layout's width. + if w := lipgloss.Width(NewRenderer(ProtocolKitty).Clear() + "abc"); w != 3 { + t.Fatalf("Kitty clear is not zero-width: %d", w) + } +} + +func TestNewRendererDefaults(t *testing.T) { + r := NewRenderer(ProtocolHalfBlock) + if r.Protocol != ProtocolHalfBlock { + t.Fatalf("Protocol = %q", r.Protocol) + } + if r.CellAspect != defaultCellAspect { + t.Fatalf("CellAspect = %v, want %v", r.CellAspect, defaultCellAspect) + } + // A zero CellAspect must still render correctly. + lines, err := (&Renderer{Protocol: ProtocolHalfBlock}).Render(testPNG(t, 32, 32), 10, 5) + if err != nil { + t.Fatalf("Render with zero CellAspect: %v", err) + } + if len(lines) != 5 { + t.Fatalf("got %d lines, want 5", len(lines)) + } + for i, line := range lines { + if w := lipgloss.Width(line); w != 10 { + t.Fatalf("line %d width = %d, want 10", i, w) + } + } +} + +// TestRenderKittyChunking verifies the APC chunking rules for a payload large +// enough to need several escapes. +func TestRenderKittyChunking(t *testing.T) { + lines, err := NewRenderer(ProtocolKitty).Render(testPNG(t, 400, 400), 40, 20) + if err != nil { + t.Fatalf("Render: %v", err) + } + out := lines[0] + chunks := strings.Split(strings.TrimSuffix(out, strings.Repeat(" ", 40)), "\x1b\\") + chunks = chunks[:len(chunks)-1] // trailing empty element after the final ST + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks, got %d", len(chunks)) + } + for i, chunk := range chunks { + if !strings.HasPrefix(chunk, "\x1b_G") { + t.Fatalf("chunk %d missing APC introducer", i) + } + keys, payload, ok := strings.Cut(strings.TrimPrefix(chunk, "\x1b_G"), ";") + if !ok { + t.Fatalf("chunk %d has no key/payload separator", i) + } + if len(payload) > kittyChunkSize { + t.Fatalf("chunk %d payload is %d bytes, over the %d limit", i, len(payload), kittyChunkSize) + } + wantMore := "m=1" + if i == len(chunks)-1 { + wantMore = "m=0" + } + if !strings.Contains(keys, wantMore) { + t.Fatalf("chunk %d keys %q missing %q", i, keys, wantMore) + } + if i == 0 { + for _, want := range []string{"a=T", "f=100", "c=40", "r=20", "i=", "q=2"} { + if !strings.Contains(keys, want) { + t.Fatalf("first chunk keys %q missing %q", keys, want) + } + } + } else if strings.Contains(keys, "a=T") { + t.Fatalf("continuation chunk %d repeats the action key: %q", i, keys) + } + } +} + +func TestRenderITerm2Payload(t *testing.T) { + lines, err := NewRenderer(ProtocolITerm2).Render(testPNG(t, 64, 64), 12, 6) + if err != nil { + t.Fatalf("Render: %v", err) + } + seq := strings.TrimSuffix(lines[0], strings.Repeat(" ", 12)) + if !strings.HasPrefix(seq, "\x1b]1337;File=inline=1;width=12;height=6;preserveAspectRatio=1;size=") { + t.Fatalf("unexpected OSC header: %q", seq[:min(80, len(seq))]) + } + if !strings.HasSuffix(seq, "\x07") { + t.Fatal("OSC sequence is not BEL terminated") + } +} diff --git a/pkg/art/resize.go b/pkg/art/resize.go new file mode 100644 index 0000000..efaee56 --- /dev/null +++ b/pkg/art/resize.go @@ -0,0 +1,207 @@ +package art + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/draw" + + // Registered for their image.Decode side effects only. + _ "image/gif" + _ "image/jpeg" + _ "image/png" +) + +// Nominal pixel size of a single terminal cell for the graphical transports. +// The height is derived from Renderer.CellAspect so unusual fonts stay square. +const ( + graphicalCellWidth = 10 + defaultCellAspect = 2.0 +) + +// decodeImage decodes PNG, JPEG or GIF bytes into an image.Image. +func decodeImage(data []byte) (image.Image, error) { + if len(data) == 0 { + return nil, fmt.Errorf("art: empty image data") + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("art: decode image: %w", err) + } + if img.Bounds().Dx() <= 0 || img.Bounds().Dy() <= 0 { + return nil, fmt.Errorf("art: image has no pixels") + } + return img, nil +} + +// subCellGrid reports how many pixels one terminal cell contributes to the +// sampling grid for a protocol, together with the cell aspect the fit should +// assume. Character-cell protocols have a fixed sub-cell layout (half-blocks +// are 1x2, braille cells are 2x4) so the aspect correction is applied when +// fitting rather than by stretching the grid. +func subCellGrid(p Protocol, aspect float64) (cellW, cellH int) { + switch p { + case ProtocolHalfBlock: + return 1, 2 + case ProtocolBraille: + return 2, 4 + default: + h := int(float64(graphicalCellWidth)*aspect + 0.5) + if h < 1 { + h = 1 + } + return graphicalCellWidth, h + } +} + +// fitDimensions computes the pixel size the artwork should occupy inside a +// gridW x gridH sampling grid while keeping the source aspect ratio visually +// intact. cellW/cellH describe the sub-cell pixel layout and aspect is the +// pixel height/width ratio of one terminal cell. +func fitDimensions(srcW, srcH, gridW, gridH, cellW, cellH int, aspect float64) (int, int) { + if srcW <= 0 || srcH <= 0 || gridW <= 0 || gridH <= 0 || cellW <= 0 || cellH <= 0 { + return maxInt(gridW, 1), maxInt(gridH, 1) + } + if aspect <= 0 { + aspect = defaultCellAspect + } + + // Convert the grid into "cell units" so the two axes are comparable. + maxW := float64(gridW) / float64(cellW) + maxH := float64(gridH) * aspect / float64(cellH) + srcRatio := float64(srcW) / float64(srcH) + + w := maxW + h := w / srcRatio + if h > maxH { + h = maxH + w = h * srcRatio + } + + dw := int(w*float64(cellW) + 0.5) + dh := int(h*float64(cellH)/aspect + 0.5) + return clampInt(dw, 1, gridW), clampInt(dh, 1, gridH) +} + +// resampleBox downscales (or upscales) src into a dw x dh image using an +// area-average box filter over alpha-premultiplied samples. +func resampleBox(src image.Image, dw, dh int) *image.RGBA { + if dw < 1 { + dw = 1 + } + if dh < 1 { + dh = 1 + } + dst := image.NewRGBA(image.Rect(0, 0, dw, dh)) + + b := src.Bounds() + sw, sh := b.Dx(), b.Dy() + if sw <= 0 || sh <= 0 { + return dst + } + + for y := 0; y < dh; y++ { + y0 := b.Min.Y + y*sh/dh + y1 := b.Min.Y + (y+1)*sh/dh + if y1 <= y0 { + y1 = y0 + 1 + } + for x := 0; x < dw; x++ { + x0 := b.Min.X + x*sw/dw + x1 := b.Min.X + (x+1)*sw/dw + if x1 <= x0 { + x1 = x0 + 1 + } + + var sr, sg, sb, sa, n uint64 + for yy := y0; yy < y1; yy++ { + for xx := x0; xx < x1; xx++ { + r, g, bb, a := src.At(xx, yy).RGBA() + sr += uint64(r) + sg += uint64(g) + sb += uint64(bb) + sa += uint64(a) + n++ + } + } + if n == 0 { + continue + } + off := dst.PixOffset(x, y) + dst.Pix[off+0] = uint8(sr / n / 257) + dst.Pix[off+1] = uint8(sg / n / 257) + dst.Pix[off+2] = uint8(sb / n / 257) + dst.Pix[off+3] = uint8(sa / n / 257) + } + } + return dst +} + +// prepare decodes data and lays it out on an opaque sampling grid sized for +// cols x rows terminal cells under the given protocol. The artwork is centred +// and letterboxed against black so it stays square instead of stretching. +func prepare(data []byte, p Protocol, cols, rows int, aspect float64) (*image.RGBA, error) { + src, err := decodeImage(data) + if err != nil { + return nil, err + } + return prepareImage(src, p, cols, rows, aspect), nil +} + +// prepareImage performs the resize/letterbox step for an already decoded image. +func prepareImage(src image.Image, p Protocol, cols, rows int, aspect float64) *image.RGBA { + if aspect <= 0 { + aspect = defaultCellAspect + } + cellW, cellH := subCellGrid(p, aspect) + + gridW := maxInt(cols*cellW, 1) + gridH := maxInt(rows*cellH, 1) + + canvas := image.NewRGBA(image.Rect(0, 0, gridW, gridH)) + draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.RGBA{A: 0xff}), image.Point{}, draw.Src) + + b := src.Bounds() + dw, dh := fitDimensions(b.Dx(), b.Dy(), gridW, gridH, cellW, cellH, aspect) + scaled := resampleBox(src, dw, dh) + + offX := (gridW - dw) / 2 + offY := (gridH - dh) / 2 + draw.Draw(canvas, image.Rect(offX, offY, offX+dw, offY+dh), scaled, image.Point{}, draw.Over) + return canvas +} + +// pixelAt returns the opaque RGB triple at (x, y), clamped to the image. +func pixelAt(img *image.RGBA, x, y int) (uint8, uint8, uint8) { + b := img.Bounds() + x = clampInt(x, b.Min.X, b.Max.X-1) + y = clampInt(y, b.Min.Y, b.Max.Y-1) + off := img.PixOffset(x, y) + return img.Pix[off], img.Pix[off+1], img.Pix[off+2] +} + +// luminance returns the perceptual brightness of an RGB triple in 0..255. +func luminance(r, g, b uint8) int { + return int((299*uint32(r) + 587*uint32(g) + 114*uint32(b)) / 1000) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func clampInt(v, lo, hi int) int { + if hi < lo { + return lo + } + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/pkg/art/resize_test.go b/pkg/art/resize_test.go new file mode 100644 index 0000000..d6cacd2 --- /dev/null +++ b/pkg/art/resize_test.go @@ -0,0 +1,214 @@ +package art + +import ( + "image" + "image/color" + "testing" +) + +func TestDecodeImageErrors(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + {"nil", nil}, + {"empty", []byte{}}, + {"text", []byte("not an image at all")}, + {"png magic only", []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := decodeImage(tc.data); err == nil { + t.Fatal("expected an error") + } + }) + } +} + +func TestDecodeImageFormats(t *testing.T) { + pngData := testPNG(t, 16, 9) + jpegData := testJPEG(t, 16, 9) + for name, data := range map[string][]byte{"png": pngData, "jpeg": jpegData} { + t.Run(name, func(t *testing.T) { + img, err := decodeImage(data) + if err != nil { + t.Fatalf("decodeImage: %v", err) + } + if got := img.Bounds().Dx(); got != 16 { + t.Fatalf("width = %d, want 16", got) + } + }) + } +} + +func TestSubCellGrid(t *testing.T) { + tests := []struct { + proto Protocol + aspect float64 + wantW, wantH int + }{ + {ProtocolHalfBlock, 2.0, 1, 2}, + {ProtocolHalfBlock, 1.7, 1, 2}, + {ProtocolBraille, 2.0, 2, 4}, + {ProtocolBraille, 3.0, 2, 4}, + {ProtocolKitty, 2.0, 10, 20}, + {ProtocolITerm2, 2.4, 10, 24}, + {ProtocolSixel, 0.05, 10, 1}, + } + for _, tc := range tests { + t.Run(string(tc.proto), func(t *testing.T) { + w, h := subCellGrid(tc.proto, tc.aspect) + if w != tc.wantW || h != tc.wantH { + t.Fatalf("subCellGrid = %dx%d, want %dx%d", w, h, tc.wantW, tc.wantH) + } + }) + } +} + +func TestFitDimensions(t *testing.T) { + tests := []struct { + name string + srcW, srcH int + gridW, gridH int + cellW, cellH int + aspect float64 + wantW, wantH int + }{ + // A square source stays square: 20 cells wide is 20 units, so it + // occupies 20 of the 40 vertical units available. + {"square into square", 100, 100, 20, 40, 1, 2, 2.0, 20, 20}, + // Wide source: full width, letterboxed vertically. + {"wide into square", 200, 100, 20, 40, 1, 2, 2.0, 20, 10}, + // Tall source: full height, letterboxed horizontally. + {"tall into square", 100, 200, 20, 40, 1, 2, 2.0, 20, 40}, + // Graphical cells. + {"square graphical", 600, 600, 200, 400, 10, 20, 2.0, 200, 200}, + {"wide graphical", 1200, 600, 200, 400, 10, 20, 2.0, 200, 100}, + // Degenerate inputs never divide by zero and never return 0. + {"zero source", 0, 0, 10, 10, 1, 2, 2.0, 10, 10}, + {"zero grid", 10, 10, 0, 0, 1, 2, 2.0, 1, 1}, + {"single cell", 64, 64, 1, 2, 1, 2, 2.0, 1, 1}, + {"single column", 64, 64, 1, 16, 1, 2, 2.0, 1, 1}, + {"single row", 64, 64, 16, 2, 1, 2, 2.0, 2, 2}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w, h := fitDimensions(tc.srcW, tc.srcH, tc.gridW, tc.gridH, tc.cellW, tc.cellH, tc.aspect) + if w != tc.wantW || h != tc.wantH { + t.Fatalf("fitDimensions = %dx%d, want %dx%d", w, h, tc.wantW, tc.wantH) + } + if tc.gridW > 0 && tc.gridH > 0 { + if w < 1 || h < 1 || w > tc.gridW || h > tc.gridH { + t.Fatalf("result %dx%d escapes grid %dx%d", w, h, tc.gridW, tc.gridH) + } + } + }) + } +} + +func TestResampleBoxAverages(t *testing.T) { + // A 2x2 image with known colours must average to a single pixel. + src := image.NewRGBA(image.Rect(0, 0, 2, 2)) + src.Set(0, 0, color.RGBA{R: 0xff, A: 0xff}) + src.Set(1, 0, color.RGBA{G: 0xff, A: 0xff}) + src.Set(0, 1, color.RGBA{B: 0xff, A: 0xff}) + src.Set(1, 1, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}) + + out := resampleBox(src, 1, 1) + r, g, b := pixelAt(out, 0, 0) + // Each channel is 0xff in two of the four pixels. + for name, got := range map[string]uint8{"r": r, "g": g, "b": b} { + if got < 125 || got > 130 { + t.Fatalf("channel %s = %d, want ~127", name, got) + } + } +} + +func TestResampleBoxSizes(t *testing.T) { + src := testImage(9, 7) + tests := []struct{ w, h int }{ + {1, 1}, {3, 3}, {9, 7}, {20, 20}, {0, 5}, {5, 0}, {-4, -4}, + } + for _, tc := range tests { + t.Run(sizeName(tc.w, tc.h), func(t *testing.T) { + out := resampleBox(src, tc.w, tc.h) + wantW, wantH := maxInt(tc.w, 1), maxInt(tc.h, 1) + if out.Bounds().Dx() != wantW || out.Bounds().Dy() != wantH { + t.Fatalf("bounds = %v, want %dx%d", out.Bounds(), wantW, wantH) + } + }) + } +} + +func TestPrepareImageGridSize(t *testing.T) { + tests := []struct { + proto Protocol + cols, rows int + wantW, wantH int + }{ + {ProtocolHalfBlock, 10, 5, 10, 10}, + {ProtocolBraille, 10, 5, 20, 20}, + {ProtocolKitty, 10, 5, 100, 100}, + {ProtocolHalfBlock, 1, 1, 1, 2}, + {ProtocolBraille, 1, 1, 2, 4}, + } + for _, tc := range tests { + t.Run(string(tc.proto)+"_"+sizeName(tc.cols, tc.rows), func(t *testing.T) { + canvas := prepareImage(testImage(40, 40), tc.proto, tc.cols, tc.rows, 0) + if canvas.Bounds().Dx() != tc.wantW || canvas.Bounds().Dy() != tc.wantH { + t.Fatalf("canvas = %v, want %dx%d", canvas.Bounds(), tc.wantW, tc.wantH) + } + // Letterbox padding must be fully opaque so terminal output has + // concrete colours everywhere. + for y := 0; y < tc.wantH; y++ { + for x := 0; x < tc.wantW; x++ { + if a := canvas.Pix[canvas.PixOffset(x, y)+3]; a != 0xff { + t.Fatalf("pixel (%d,%d) alpha = %d, want 255", x, y, a) + } + } + } + }) + } +} + +func TestPixelAtClamps(t *testing.T) { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 0xff}) + img.Set(1, 1, color.RGBA{R: 40, G: 50, B: 60, A: 0xff}) + + tests := []struct { + x, y int + r, g, b uint8 + }{ + {0, 0, 10, 20, 30}, + {-5, -5, 10, 20, 30}, + {99, 99, 40, 50, 60}, + {1, 1, 40, 50, 60}, + } + for _, tc := range tests { + r, g, b := pixelAt(img, tc.x, tc.y) + if r != tc.r || g != tc.g || b != tc.b { + t.Fatalf("pixelAt(%d,%d) = %d,%d,%d want %d,%d,%d", tc.x, tc.y, r, g, b, tc.r, tc.g, tc.b) + } + } +} + +func TestLuminanceAndClamp(t *testing.T) { + if got := luminance(0, 0, 0); got != 0 { + t.Fatalf("luminance(black) = %d", got) + } + if got := luminance(255, 255, 255); got < 254 { + t.Fatalf("luminance(white) = %d", got) + } + tests := []struct{ v, lo, hi, want int }{ + {5, 0, 10, 5}, + {-1, 0, 10, 0}, + {11, 0, 10, 10}, + {5, 10, 0, 10}, // inverted bounds collapse to lo + } + for _, tc := range tests { + if got := clampInt(tc.v, tc.lo, tc.hi); got != tc.want { + t.Fatalf("clampInt(%d,%d,%d) = %d, want %d", tc.v, tc.lo, tc.hi, got, tc.want) + } + } +} diff --git a/pkg/art/sixel.go b/pkg/art/sixel.go new file mode 100644 index 0000000..40d6772 --- /dev/null +++ b/pkg/art/sixel.go @@ -0,0 +1,330 @@ +package art + +import ( + "image" + "image/color" + "sort" + "strconv" + "strings" +) + +// sixelMaxColors is the size of the Sixel colour register bank we target. +const sixelMaxColors = 256 + +// sixelRLEThreshold is the run length at which "!" becomes +// shorter than repeating the character. +const sixelRLEThreshold = 4 + +// histEntry is one bucket of the 5-bit-per-channel colour histogram used as +// the input to median-cut quantization. +type histEntry struct { + key uint16 + r, g, b uint8 // bucket centre, 8-bit + sr, sg, sb uint64 // population-weighted channel sums + count uint64 + paletteIndex uint8 + hasPaletteSlot bool +} + +// renderSixel encodes a prepared pixel grid as a DCS Sixel image. +func renderSixel(img *image.RGBA, w, h int) string { + if w <= 0 || h <= 0 { + return "" + } + + palette, indices := quantizeSixel(img, w, h, sixelMaxColors) + if len(palette) == 0 { + return "" + } + + var sb strings.Builder + sb.Grow(w*h/4 + 1024) + + sb.WriteString("\x1bPq\"1;1;") + sb.WriteString(strconv.Itoa(w)) + sb.WriteByte(';') + sb.WriteString(strconv.Itoa(h)) + + // Colour registers, as 0-100 percentages per the Sixel spec. + for i, c := range palette { + sb.WriteByte('#') + sb.WriteString(strconv.Itoa(i)) + sb.WriteString(";2;") + sb.WriteString(strconv.Itoa(int(c.R) * 100 / 255)) + sb.WriteByte(';') + sb.WriteString(strconv.Itoa(int(c.G) * 100 / 255)) + sb.WriteByte(';') + sb.WriteString(strconv.Itoa(int(c.B) * 100 / 255)) + } + + bands := (h + 5) / 6 + row := make([]byte, w) + for band := 0; band < bands; band++ { + if band > 0 { + sb.WriteByte('-') // advance to the next six-pixel band + } + + var present [sixelMaxColors]bool + usedCount := 0 + top := band * 6 + for dy := 0; dy < 6 && top+dy < h; dy++ { + base := (top + dy) * w + for x := 0; x < w; x++ { + ci := indices[base+x] + if !present[ci] { + present[ci] = true + usedCount++ + } + } + } + + emitted := 0 + for ci := 0; ci < len(palette); ci++ { + if !present[ci] { + continue + } + if emitted > 0 { + sb.WriteByte('$') // carriage return within the band + } + emitted++ + + for x := range row { + row[x] = 0 + } + for dy := 0; dy < 6 && top+dy < h; dy++ { + base := (top + dy) * w + bit := byte(1) << uint(dy) + for x := 0; x < w; x++ { + if int(indices[base+x]) == ci { + row[x] |= bit + } + } + } + + sb.WriteByte('#') + sb.WriteString(strconv.Itoa(ci)) + writeSixelRun(&sb, row) + + if emitted == usedCount { + break + } + } + } + + sb.WriteString("\x1b\\") + return sb.String() +} + +// writeSixelRun run-length encodes one colour pass across a band. +func writeSixelRun(sb *strings.Builder, row []byte) { + // Trailing empty sixels are a no-op, so drop them. + end := len(row) + for end > 0 && row[end-1] == 0 { + end-- + } + for i := 0; i < end; { + v := row[i] + n := 1 + for i+n < end && row[i+n] == v { + n++ + } + ch := byte(0x3F) + v + if n >= sixelRLEThreshold { + sb.WriteByte('!') + sb.WriteString(strconv.Itoa(n)) + sb.WriteByte(ch) + } else { + for k := 0; k < n; k++ { + sb.WriteByte(ch) + } + } + i += n + } +} + +// quantizeSixel reduces the grid to at most maxColors colours using median-cut +// over a 5-bit-per-channel histogram. It returns the palette plus a per-pixel +// palette index buffer of length w*h. +func quantizeSixel(img *image.RGBA, w, h, maxColors int) ([]color.RGBA, []uint8) { + if maxColors < 1 { + maxColors = 1 + } + if maxColors > sixelMaxColors { + maxColors = sixelMaxColors + } + + byKey := make(map[uint16]*histEntry, 1024) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, b := pixelAt(img, x, y) + key := quantKey(r, g, b) + e := byKey[key] + if e == nil { + e = &histEntry{key: key} + byKey[key] = e + } + e.sr += uint64(r) + e.sg += uint64(g) + e.sb += uint64(b) + e.count++ + } + } + if len(byKey) == 0 { + return nil, nil + } + + entries := make([]*histEntry, 0, len(byKey)) + for _, e := range byKey { + e.r, e.g, e.b = uint8(e.sr/e.count), uint8(e.sg/e.count), uint8(e.sb/e.count) + entries = append(entries, e) + } + // Deterministic ordering: map iteration order must not leak into output. + sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key }) + + palette := make([]color.RGBA, 0, maxColors) + if len(entries) <= maxColors { + for i, e := range entries { + e.paletteIndex = uint8(i) + e.hasPaletteSlot = true + palette = append(palette, color.RGBA{R: e.r, G: e.g, B: e.b, A: 0xff}) + } + } else { + for _, box := range medianCut(entries, maxColors) { + idx := uint8(len(palette)) + var sr, sg, sb, n uint64 + for _, e := range box { + e.paletteIndex = idx + e.hasPaletteSlot = true + sr += e.sr + sg += e.sg + sb += e.sb + n += e.count + } + if n == 0 { + n = 1 + } + palette = append(palette, color.RGBA{ + R: uint8(sr / n), G: uint8(sg / n), B: uint8(sb / n), A: 0xff, + }) + } + } + + indices := make([]uint8, w*h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, b := pixelAt(img, x, y) + if e := byKey[quantKey(r, g, b)]; e != nil && e.hasPaletteSlot { + indices[y*w+x] = e.paletteIndex + } + } + } + return palette, indices +} + +// medianCut partitions entries into at most want boxes, repeatedly splitting +// the box with the greatest population x longest-axis product. +func medianCut(entries []*histEntry, want int) [][]*histEntry { + boxes := [][]*histEntry{entries} + for len(boxes) < want { + target, axis, score := -1, 0, uint64(0) + for i, box := range boxes { + if len(box) < 2 { + continue + } + ax, span := widestAxis(box) + if span == 0 { + continue + } + var pop uint64 + for _, e := range box { + pop += e.count + } + if s := pop * uint64(span); s > score { + target, axis, score = i, ax, s + } + } + if target < 0 { + break + } + + box := boxes[target] + sortByAxis(box, axis) + + var pop uint64 + for _, e := range box { + pop += e.count + } + half := pop / 2 + var acc uint64 + split := 1 + for i, e := range box { + acc += e.count + if acc >= half && i+1 < len(box) { + split = i + 1 + break + } + } + if split < 1 { + split = 1 + } + if split >= len(box) { + split = len(box) - 1 + } + + boxes[target] = box[:split] + boxes = append(boxes, box[split:]) + } + return boxes +} + +// widestAxis reports which channel (0=R, 1=G, 2=B) spans the widest range in +// the box, along with that span. +func widestAxis(box []*histEntry) (int, int) { + lo := [3]int{255, 255, 255} + hi := [3]int{0, 0, 0} + for _, e := range box { + v := [3]int{int(e.r), int(e.g), int(e.b)} + for c := 0; c < 3; c++ { + if v[c] < lo[c] { + lo[c] = v[c] + } + if v[c] > hi[c] { + hi[c] = v[c] + } + } + } + axis, span := 0, hi[0]-lo[0] + if hi[1]-lo[1] > span { + axis, span = 1, hi[1]-lo[1] + } + if hi[2]-lo[2] > span { + axis, span = 2, hi[2]-lo[2] + } + return axis, span +} + +// sortByAxis orders a box along one colour channel, tie-broken by key so the +// result is deterministic. +func sortByAxis(box []*histEntry, axis int) { + sort.Slice(box, func(i, j int) bool { + a, b := box[i], box[j] + var av, bv uint8 + switch axis { + case 0: + av, bv = a.r, b.r + case 1: + av, bv = a.g, b.g + default: + av, bv = a.b, b.b + } + if av != bv { + return av < bv + } + return a.key < b.key + }) +} + +// quantKey packs an RGB triple into a 15-bit 5-bit-per-channel bucket key. +func quantKey(r, g, b uint8) uint16 { + return uint16(r>>3)<<10 | uint16(g>>3)<<5 | uint16(b>>3) +} diff --git a/pkg/art/sixel_test.go b/pkg/art/sixel_test.go new file mode 100644 index 0000000..f334497 --- /dev/null +++ b/pkg/art/sixel_test.go @@ -0,0 +1,242 @@ +package art + +import ( + "image" + "image/color" + "strings" + "testing" +) + +func TestQuantizeSixel(t *testing.T) { + tests := []struct { + name string + build func() *image.RGBA + maxColors int + wantMax int + }{ + { + name: "flat image collapses to one colour", + build: func() *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for y := 0; y < 8; y++ { + for x := 0; x < 8; x++ { + img.Set(x, y, color.RGBA{R: 0x20, G: 0x40, B: 0x60, A: 0xff}) + } + } + return img + }, + maxColors: 256, + wantMax: 1, + }, + { + name: "gradient is capped at the requested palette size", + build: func() *image.RGBA { return testImage(64, 64) }, + maxColors: 16, + wantMax: 16, + }, + { + name: "full palette request stays within the register bank", + build: func() *image.RGBA { return testImage(128, 128) }, + maxColors: 1000, // clamped down to sixelMaxColors + wantMax: sixelMaxColors, + }, + { + name: "single pixel", + build: func() *image.RGBA { return testImage(1, 1) }, + maxColors: 256, + wantMax: 1, + }, + { + name: "zero palette request is raised to one", + build: func() *image.RGBA { return testImage(16, 16) }, + maxColors: 0, + wantMax: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + img := tc.build() + w, h := img.Bounds().Dx(), img.Bounds().Dy() + palette, indices := quantizeSixel(img, w, h, tc.maxColors) + if len(palette) == 0 { + t.Fatal("empty palette") + } + if len(palette) > tc.wantMax { + t.Fatalf("palette has %d colours, want at most %d", len(palette), tc.wantMax) + } + if len(indices) != w*h { + t.Fatalf("index buffer has %d entries, want %d", len(indices), w*h) + } + for i, idx := range indices { + if int(idx) >= len(palette) { + t.Fatalf("index %d points outside the palette (%d >= %d)", i, idx, len(palette)) + } + } + }) + } +} + +// TestQuantizeSixelDeterministic guards against Go map iteration order leaking +// into the encoded output. +func TestQuantizeSixelDeterministic(t *testing.T) { + img := testImage(48, 48) + first := renderSixel(img, 48, 48) + for i := 0; i < 5; i++ { + if got := renderSixel(img, 48, 48); got != first { + t.Fatalf("run %d produced different output", i) + } + } +} + +func TestRenderSixelStructure(t *testing.T) { + tests := []struct{ w, h int }{ + {6, 6}, {1, 1}, {12, 7}, {20, 13}, {0, 4}, {4, 0}, + } + for _, tc := range tests { + t.Run(sizeName(tc.w, tc.h), func(t *testing.T) { + img := testImage(maxInt(tc.w, 1), maxInt(tc.h, 1)) + out := renderSixel(img, tc.w, tc.h) + if tc.w <= 0 || tc.h <= 0 { + if out != "" { + t.Fatalf("expected empty output for %dx%d", tc.w, tc.h) + } + return + } + if !strings.HasPrefix(out, "\x1bPq\"1;1;") { + t.Fatalf("missing DCS header: %q", out[:min(40, len(out))]) + } + if !strings.Contains(out, "#0;2;") { + t.Fatal("no colour register definition emitted") + } + if !strings.HasSuffix(out, "\x1b\\") { + t.Fatal("DCS sequence is not ST terminated") + } + // Band separators must never exceed the number of bands - 1. + bands := (tc.h + 5) / 6 + if got := strings.Count(out, "-"); got > bands { + t.Fatalf("%d band separators for %d bands", got, bands) + } + }) + } +} + +func TestWriteSixelRun(t *testing.T) { + tests := []struct { + name string + row []byte + want string + }{ + {"empty row is elided", []byte{0, 0, 0, 0}, ""}, + {"short run written literally", []byte{1, 1, 1}, "@@@"}, + {"long run is RLE encoded", []byte{1, 1, 1, 1, 1}, "!5@"}, + {"trailing blanks trimmed", []byte{63, 0, 0, 0, 0}, "~"}, + {"mixed", []byte{1, 1, 1, 1, 2}, "!4@A"}, + {"nil", nil, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var sb strings.Builder + writeSixelRun(&sb, tc.row) + if got := sb.String(); got != tc.want { + t.Fatalf("writeSixelRun = %q, want %q", got, tc.want) + } + }) + } +} + +func TestQuantKeyAndWidestAxis(t *testing.T) { + if quantKey(0, 0, 0) != 0 { + t.Fatal("black should map to bucket 0") + } + if quantKey(255, 255, 255) != 0x7fff { + t.Fatalf("white bucket = %#x", quantKey(255, 255, 255)) + } + // Values inside the same 5-bit bucket must collide. + if quantKey(8, 8, 8) != quantKey(15, 15, 15) { + t.Fatal("expected 5-bit bucket collision") + } + + box := []*histEntry{ + {r: 0, g: 10, b: 100}, + {r: 5, g: 20, b: 0}, + } + axis, span := widestAxis(box) + if axis != 2 || span != 100 { + t.Fatalf("widestAxis = (%d, %d), want (2, 100)", axis, span) + } +} + +func TestMedianCutSplits(t *testing.T) { + entries := make([]*histEntry, 0, 32) + for i := 0; i < 32; i++ { + entries = append(entries, &histEntry{ + key: uint16(i), + r: uint8(i * 8), + count: 1, + sr: uint64(i * 8), + }) + } + tests := []int{1, 2, 4, 8, 32, 64} + for _, want := range tests { + boxes := medianCut(entries, want) + if len(boxes) > want && want <= 32 { + t.Fatalf("medianCut(%d) produced %d boxes", want, len(boxes)) + } + total := 0 + for _, b := range boxes { + total += len(b) + } + if total != len(entries) { + t.Fatalf("medianCut(%d) lost entries: %d of %d", want, total, len(entries)) + } + } +} + +func TestOtsuThreshold(t *testing.T) { + tests := []struct { + name string + build func() *image.RGBA + w, h int + wantLo int + wantHi int + }{ + { + name: "bimodal black and white", + build: func() *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + c := color.RGBA{A: 0xff} + if x >= 2 { + c = color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} + } + img.Set(x, y, c) + } + } + return img + }, + w: 4, h: 4, wantLo: 0, wantHi: 254, + }, + { + name: "flat black", + build: func() *image.RGBA { + return image.NewRGBA(image.Rect(0, 0, 4, 4)) + }, + w: 4, h: 4, wantLo: 0, wantHi: 0, + }, + { + name: "degenerate size", + build: func() *image.RGBA { return image.NewRGBA(image.Rect(0, 0, 1, 1)) }, + w: 0, h: 0, wantLo: 128, wantHi: 128, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := otsuThreshold(tc.build(), tc.w, tc.h) + if got < tc.wantLo || got > tc.wantHi { + t.Fatalf("otsuThreshold = %d, want within [%d,%d]", got, tc.wantLo, tc.wantHi) + } + }) + } +} diff --git a/pkg/lyrics/cache.go b/pkg/lyrics/cache.go new file mode 100644 index 0000000..276f96f --- /dev/null +++ b/pkg/lyrics/cache.go @@ -0,0 +1,319 @@ +package lyrics + +import ( + "container/list" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + // memCapacity bounds the in-memory sheet cache. + memCapacity = 128 + // memTTL is how long a successful lookup stays in memory. + memTTL = 6 * time.Hour + // negativeTTL is how long a "no lyrics" marker stays in memory. Negative + // results are never written to disk. + negativeTTL = time.Hour + // diskTTL is how long a cached sheet on disk is considered fresh. + diskTTL = 30 * 24 * time.Hour + + cacheDirPerm os.FileMode = 0o700 + cacheFilePerm os.FileMode = 0o600 +) + +// memResult is what the in-memory cache hands back: either a sheet or a +// negative marker meaning "no provider had lyrics for this key". +type memResult struct { + sheet *Sheet + negative bool +} + +type memEntry struct { + key string + result memResult + storedAt time.Time +} + +// memCache is a small thread-safe LRU with separate TTLs for positive and +// negative entries. +type memCache struct { + mu sync.Mutex + capacity int + ttl time.Duration + negativeTTL time.Duration + items map[string]*list.Element + order *list.List + now func() time.Time +} + +// newMemCache creates a memCache with the given capacity and time-to-live values. +func newMemCache(capacity int, ttl, negTTL time.Duration) *memCache { + if capacity <= 0 { + capacity = memCapacity + } + if ttl <= 0 { + ttl = memTTL + } + if negTTL <= 0 { + negTTL = negativeTTL + } + return &memCache{ + capacity: capacity, + ttl: ttl, + negativeTTL: negTTL, + items: make(map[string]*list.Element), + order: list.New(), + now: time.Now, + } +} + +// get returns the cached result for key when present and still fresh. +func (c *memCache) get(key string) (memResult, bool) { + if c == nil || key == "" { + return memResult{}, false + } + + c.mu.Lock() + defer c.mu.Unlock() + + elem, ok := c.items[key] + if !ok { + return memResult{}, false + } + + entry := elem.Value.(*memEntry) + ttl := c.ttl + if entry.result.negative { + ttl = c.negativeTTL + } + if c.now().Sub(entry.storedAt) > ttl { + c.order.Remove(elem) + delete(c.items, key) + return memResult{}, false + } + + c.order.MoveToFront(elem) + return entry.result, true +} + +// putSheet memoises a successful lookup. +func (c *memCache) putSheet(key string, sheet *Sheet) { + if sheet == nil { + return + } + c.put(key, memResult{sheet: sheet.clone()}) +} + +// putNegative memoises a "no lyrics" outcome for a shorter window so a station +// without matches does not hammer the providers on every track. +func (c *memCache) putNegative(key string) { + c.put(key, memResult{negative: true}) +} + +func (c *memCache) put(key string, res memResult) { + if c == nil || key == "" { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + if elem, ok := c.items[key]; ok { + entry := elem.Value.(*memEntry) + entry.result = res + entry.storedAt = c.now() + c.order.MoveToFront(elem) + return + } + + for c.order.Len() >= c.capacity { + oldest := c.order.Back() + if oldest == nil { + break + } + c.order.Remove(oldest) + delete(c.items, oldest.Value.(*memEntry).key) + } + + c.items[key] = c.order.PushFront(&memEntry{ + key: key, + result: res, + storedAt: c.now(), + }) +} + +// len reports how many entries are currently held. +func (c *memCache) len() int { + if c == nil { + return 0 + } + c.mu.Lock() + defer c.mu.Unlock() + return len(c.items) +} + +// clear drops every entry. +func (c *memCache) clear() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.items = make(map[string]*list.Element) + c.order.Init() +} + +// diskLine is the on-disk form of a lyric line, storing offsets in +// milliseconds so cache files stay human-readable. +type diskLine struct { + AtMS int64 `json:"at_ms"` + Text string `json:"text"` +} + +// diskSheet is the on-disk form of a Sheet. +type diskSheet struct { + Artist string `json:"artist"` + Title string `json:"title"` + Album string `json:"album,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + Synced bool `json:"synced"` + Instrumental bool `json:"instrumental,omitempty"` + Source string `json:"source"` + Lines []diskLine `json:"lines"` +} + +// diskEntry wraps a cached sheet with the time it was written. +type diskEntry struct { + CachedAt time.Time `json:"cached_at"` + Sheet *diskSheet `json:"sheet"` +} + +func newDiskSheet(s *Sheet) *diskSheet { + if s == nil { + return nil + } + out := &diskSheet{ + Artist: s.Artist, + Title: s.Title, + Album: s.Album, + DurationMS: s.Duration.Milliseconds(), + Synced: s.Synced, + Instrumental: s.Instrumental, + Source: s.Source, + Lines: make([]diskLine, 0, len(s.Lines)), + } + for _, l := range s.Lines { + out.Lines = append(out.Lines, diskLine{AtMS: l.At.Milliseconds(), Text: l.Text}) + } + return out +} + +func (d *diskSheet) toSheet() *Sheet { + if d == nil { + return nil + } + out := &Sheet{ + Artist: d.Artist, + Title: d.Title, + Album: d.Album, + Duration: time.Duration(d.DurationMS) * time.Millisecond, + Synced: d.Synced, + Instrumental: d.Instrumental, + Source: d.Source, + } + if len(d.Lines) > 0 { + out.Lines = make([]Line, 0, len(d.Lines)) + for _, l := range d.Lines { + out.Lines = append(out.Lines, Line{At: time.Duration(l.AtMS) * time.Millisecond, Text: l.Text}) + } + } + return out +} + +// diskPath maps a cache key to its cache file. The key is hashed so raw track +// metadata, which may contain path separators, never reaches the filesystem. +func (c *Client) diskPath(key string) string { + sum := sha256.Sum256([]byte(key)) + return filepath.Join(c.cacheDir, hex.EncodeToString(sum[:])+".json") +} + +// readDisk returns the cached sheet for key, or nil when it is missing, stale, +// or unreadable. Disk problems are never fatal. +func (c *Client) readDisk(key string) *Sheet { + if c.cacheDir == "" || key == "" { + return nil + } + + c.diskMu.Lock() + defer c.diskMu.Unlock() + + raw, err := os.ReadFile(c.diskPath(key)) + if err != nil { + return nil + } + + var entry diskEntry + if err := json.Unmarshal(raw, &entry); err != nil { + return nil + } + if entry.Sheet == nil { + return nil + } + if entry.CachedAt.IsZero() || time.Since(entry.CachedAt) > diskTTL { + return nil + } + + sheet := entry.Sheet.toSheet() + if sheet.IsEmpty() { + return nil + } + return sheet +} + +// writeDisk persists a sheet, creating the cache directory lazily. Failures are +// silently ignored: the cache is an optimisation, not a requirement. +func (c *Client) writeDisk(key string, sheet *Sheet) { + if c.cacheDir == "" || key == "" || sheet == nil { + return + } + + c.diskMu.Lock() + defer c.diskMu.Unlock() + + if err := os.MkdirAll(c.cacheDir, cacheDirPerm); err != nil { + return + } + + payload, err := json.Marshal(diskEntry{CachedAt: time.Now().UTC(), Sheet: newDiskSheet(sheet)}) + if err != nil { + return + } + + path := c.diskPath(key) + tmp, err := os.CreateTemp(c.cacheDir, "sheet-*.tmp") + if err != nil { + // Fall back to a direct write; still non-fatal on failure. + _ = os.WriteFile(path, payload, cacheFilePerm) + return + } + tmpName := tmp.Name() + + if _, err := tmp.Write(payload); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return + } + // os.CreateTemp already creates the file with 0600. + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return + } + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + } +} diff --git a/pkg/lyrics/cache_test.go b/pkg/lyrics/cache_test.go new file mode 100644 index 0000000..5ea8947 --- /dev/null +++ b/pkg/lyrics/cache_test.go @@ -0,0 +1,428 @@ +package lyrics + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strconv" + "sync" + "testing" + "time" +) + +func TestMemCachePositiveAndNegative(t *testing.T) { + c := newMemCache(4, time.Hour, time.Minute) + + if _, ok := c.get("missing"); ok { + t.Error("empty cache reported a hit") + } + if _, ok := c.get(""); ok { + t.Error("empty key reported a hit") + } + + sheet := syncedSheet() + c.putSheet("k", sheet) + + res, ok := c.get("k") + if !ok { + t.Fatal("expected a hit after putSheet") + } + if res.negative { + t.Error("positive entry reported as negative") + } + if !reflect.DeepEqual(res.sheet, sheet) { + t.Errorf("cached sheet = %+v, want %+v", res.sheet, sheet) + } + + c.putNegative("n") + res, ok = c.get("n") + if !ok || !res.negative || res.sheet != nil { + t.Errorf("negative entry = %+v, ok=%v", res, ok) + } + + c.putSheet("nil-sheet", nil) + if _, ok := c.get("nil-sheet"); ok { + t.Error("nil sheets must not be cached") + } +} + +func TestMemCacheStoresCopies(t *testing.T) { + c := newMemCache(4, time.Hour, time.Minute) + sheet := syncedSheet() + c.putSheet("k", sheet) + + sheet.Lines[0].Text = "mutated after put" + sheet.Title = "mutated after put" + + res, _ := c.get("k") + if res.sheet.Lines[0].Text != "first" || res.sheet.Title != "Voyager" { + t.Error("cache kept a reference to the caller's sheet") + } +} + +func TestMemCacheTTL(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + c := newMemCache(4, time.Hour, time.Minute) + c.now = func() time.Time { return now } + + c.putSheet("pos", syncedSheet()) + c.putNegative("neg") + + tests := []struct { + name string + advance time.Duration + wantPosHit bool + wantNegHit bool + }{ + {name: "fresh", advance: 0, wantPosHit: true, wantNegHit: true}, + {name: "after 30s", advance: 30 * time.Second, wantPosHit: true, wantNegHit: true}, + {name: "negative expired", advance: 2 * time.Minute, wantPosHit: true, wantNegHit: false}, + {name: "both expired", advance: 2 * time.Hour, wantPosHit: false, wantNegHit: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now = time.Unix(1_700_000_000, 0).Add(tt.advance) + if _, ok := c.get("pos"); ok != tt.wantPosHit { + t.Errorf("positive hit = %v, want %v", ok, tt.wantPosHit) + } + if _, ok := c.get("neg"); ok != tt.wantNegHit { + t.Errorf("negative hit = %v, want %v", ok, tt.wantNegHit) + } + }) + } +} + +func TestMemCacheExpiredEntryIsEvicted(t *testing.T) { + now := time.Unix(0, 0) + c := newMemCache(4, time.Minute, time.Minute) + c.now = func() time.Time { return now } + + c.putSheet("k", syncedSheet()) + now = now.Add(2 * time.Minute) + + if _, ok := c.get("k"); ok { + t.Fatal("expected a miss for the expired entry") + } + if c.len() != 0 { + t.Errorf("expired entry was not dropped, len = %d", c.len()) + } +} + +func TestMemCacheLRUEviction(t *testing.T) { + c := newMemCache(3, time.Hour, time.Hour) + for i := 0; i < 3; i++ { + c.putSheet(strconv.Itoa(i), syncedSheet()) + } + + // Touch "0" so that "1" becomes the least recently used entry. + if _, ok := c.get("0"); !ok { + t.Fatal("expected key 0 to be cached") + } + c.putSheet("3", syncedSheet()) + + if c.len() != 3 { + t.Errorf("len = %d, want 3", c.len()) + } + if _, ok := c.get("1"); ok { + t.Error("least recently used entry was not evicted") + } + for _, key := range []string{"0", "2", "3"} { + if _, ok := c.get(key); !ok { + t.Errorf("key %q should still be cached", key) + } + } +} + +func TestMemCacheOverwriteAndClear(t *testing.T) { + c := newMemCache(4, time.Hour, time.Hour) + c.putSheet("k", syncedSheet()) + c.putNegative("k") + + res, ok := c.get("k") + if !ok || !res.negative { + t.Errorf("overwrite did not replace the entry: %+v", res) + } + if c.len() != 1 { + t.Errorf("len = %d, want 1", c.len()) + } + + c.clear() + if c.len() != 0 { + t.Errorf("len after clear = %d, want 0", c.len()) + } +} + +func TestMemCacheDefaults(t *testing.T) { + c := newMemCache(0, 0, 0) + if c.capacity != memCapacity || c.ttl != memTTL || c.negativeTTL != negativeTTL { + t.Errorf("defaults not applied: %d %v %v", c.capacity, c.ttl, c.negativeTTL) + } +} + +func TestMemCacheConcurrentAccess(t *testing.T) { + c := newMemCache(16, time.Hour, time.Hour) + var wg sync.WaitGroup + + for i := 0; i < runtime.NumCPU()+4; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + key := strconv.Itoa(n % 8) + for j := 0; j < 200; j++ { + c.putSheet(key, syncedSheet()) + c.get(key) + c.putNegative(key + "-neg") + c.len() + } + }(i) + } + wg.Wait() +} + +func TestDiskCacheRoundTrip(t *testing.T) { + dir := filepath.Join(t.TempDir(), "lyrics") + c := NewClient(dir) + + if got := c.readDisk("absent"); got != nil { + t.Errorf("readDisk on an empty cache = %+v, want nil", got) + } + + want := &Sheet{ + Artist: "Daft Punk", + Title: "Voyager", + Album: "Discovery", + Duration: 227 * time.Second, + Synced: true, + Source: SourceLRCLib, + Lines: []Line{ + {At: 1500 * time.Millisecond, Text: "one"}, + {At: 4 * time.Second, Text: "two"}, + }, + } + + c.writeDisk("key", want) + + got := c.readDisk("key") + if got == nil { + t.Fatal("readDisk returned nil after writeDisk") + } + if !reflect.DeepEqual(got, want) { + t.Errorf("round trip mismatch\n got: %+v\nwant: %+v", got, want) + } + + // The directory and file carry restrictive permissions. + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat cache dir: %v", err) + } + if perm := info.Mode().Perm(); perm != cacheDirPerm { + t.Errorf("cache dir perm = %o, want %o", perm, cacheDirPerm) + } + + fileInfo, err := os.Stat(c.diskPath("key")) + if err != nil { + t.Fatalf("stat cache file: %v", err) + } + if perm := fileInfo.Mode().Perm(); perm != cacheFilePerm { + t.Errorf("cache file perm = %o, want %o", perm, cacheFilePerm) + } + + // No temporary files are left behind. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read cache dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("cache dir holds %d entries, want 1", len(entries)) + } +} + +func TestDiskPathHashesTheKey(t *testing.T) { + c := NewClient(t.TempDir()) + nasty := "../../etc|pa/ss wd|../x|0" + + path := c.diskPath(nasty) + if filepath.Dir(path) != c.CacheDir() { + t.Errorf("diskPath escaped the cache dir: %q", path) + } + + name := filepath.Base(path) + if len(name) != 64+len(".json") { + t.Errorf("cache file name %q is not a sha256 hex digest", name) + } + if c.diskPath(nasty) != path { + t.Error("diskPath is not deterministic") + } + if c.diskPath("other") == path { + t.Error("different keys collided") + } +} + +func TestDiskCacheStaleAndCorrupt(t *testing.T) { + dir := t.TempDir() + c := NewClient(dir) + + writeEntry := func(key string, entry diskEntry) { + t.Helper() + payload, err := json.Marshal(entry) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(c.diskPath(key), payload, cacheFilePerm); err != nil { + t.Fatalf("write: %v", err) + } + } + + fresh := newDiskSheet(syncedSheet()) + + tests := []struct { + name string + key string + setup func(key string) + wantHit bool + }{ + { + name: "fresh entry", + key: "fresh", + setup: func(key string) { + writeEntry(key, diskEntry{CachedAt: time.Now(), Sheet: fresh}) + }, + wantHit: true, + }, + { + name: "just inside the window", + key: "recent", + setup: func(key string) { + writeEntry(key, diskEntry{CachedAt: time.Now().Add(-29 * 24 * time.Hour), Sheet: fresh}) + }, + wantHit: true, + }, + { + name: "stale entry", + key: "stale", + setup: func(key string) { + writeEntry(key, diskEntry{CachedAt: time.Now().Add(-31 * 24 * time.Hour), Sheet: fresh}) + }, + wantHit: false, + }, + { + name: "missing timestamp", + key: "no-time", + setup: func(key string) { + writeEntry(key, diskEntry{Sheet: fresh}) + }, + wantHit: false, + }, + { + name: "missing sheet", + key: "no-sheet", + setup: func(key string) { + writeEntry(key, diskEntry{CachedAt: time.Now()}) + }, + wantHit: false, + }, + { + name: "sheet without renderable lines", + key: "blank", + setup: func(key string) { + writeEntry(key, diskEntry{ + CachedAt: time.Now(), + Sheet: &diskSheet{Title: "x", Lines: []diskLine{{Text: " "}}}, + }) + }, + wantHit: false, + }, + { + name: "corrupt json", + key: "corrupt", + setup: func(key string) { + if err := os.WriteFile(c.diskPath(key), []byte("{not json"), cacheFilePerm); err != nil { + t.Fatalf("write: %v", err) + } + }, + wantHit: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup(tt.key) + got := c.readDisk(tt.key) + if (got != nil) != tt.wantHit { + t.Errorf("readDisk(%q) hit = %v, want %v", tt.key, got != nil, tt.wantHit) + } + }) + } +} + +func TestDiskCacheDisabledAndNonFatal(t *testing.T) { + // An empty cache dir disables disk caching entirely. + c := NewClient("") + if c.CacheDir() != "" { + t.Errorf("CacheDir = %q, want empty", c.CacheDir()) + } + c.writeDisk("k", syncedSheet()) + if got := c.readDisk("k"); got != nil { + t.Errorf("readDisk with caching disabled = %+v, want nil", got) + } + + // An unusable cache dir must not be fatal. + file := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + blocked := NewClient(filepath.Join(file, "cache")) + blocked.writeDisk("k", syncedSheet()) + if got := blocked.readDisk("k"); got != nil { + t.Errorf("readDisk on an unusable dir = %+v, want nil", got) + } + + // Empty keys and nil sheets are ignored. + ok := NewClient(t.TempDir()) + ok.writeDisk("", syncedSheet()) + ok.writeDisk("k", nil) + if got := ok.readDisk(""); got != nil { + t.Errorf("readDisk(\"\") = %+v, want nil", got) + } +} + +func TestDiskSheetConversionEdges(t *testing.T) { + if newDiskSheet(nil) != nil { + t.Error("newDiskSheet(nil) should be nil") + } + if (*diskSheet)(nil).toSheet() != nil { + t.Error("(*diskSheet)(nil).toSheet() should be nil") + } + + empty := newDiskSheet(&Sheet{Title: "t"}) + if empty == nil || len(empty.Lines) != 0 { + t.Fatalf("unexpected conversion: %+v", empty) + } + if back := empty.toSheet(); back.Lines != nil { + t.Errorf("empty lines should stay nil, got %v", back.Lines) + } +} + +func TestDiskCacheConcurrentWrites(t *testing.T) { + c := NewClient(t.TempDir()) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + key := "key-" + strconv.Itoa(n%3) + for j := 0; j < 20; j++ { + c.writeDisk(key, syncedSheet()) + c.readDisk(key) + } + }(i) + } + wg.Wait() + + if got := c.readDisk("key-0"); got == nil { + t.Error("expected key-0 to be cached on disk") + } +} diff --git a/pkg/lyrics/client.go b/pkg/lyrics/client.go new file mode 100644 index 0000000..3418944 --- /dev/null +++ b/pkg/lyrics/client.go @@ -0,0 +1,555 @@ +package lyrics + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // DefaultLRCLibBaseURL is the public LRCLIB endpoint. + DefaultLRCLibBaseURL = "https://lrclib.net" + // DefaultNetEaseBaseURL is the public NetEase Cloud Music endpoint. + DefaultNetEaseBaseURL = "https://music.163.com" + + // SourceLRCLib marks sheets resolved through LRCLIB. + SourceLRCLib = "LRCLIB" + // SourceNetEase marks sheets resolved through NetEase Cloud Music. + SourceNetEase = "NetEase" + + // InstrumentalMarker is the single line carried by instrumental sheets. + InstrumentalMarker = "♪ Instrumental ♪" + + // DefaultTimeout bounds a single provider request. + DefaultTimeout = 8 * time.Second + + lrclibUserAgent = "halpradio/0.5 (https://github.com/halpworld/halpradio)" + netEaseUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" +) + +// ErrNotFound is returned when no provider has lyrics for the track. +var ErrNotFound = errors.New("lyrics: no lyrics found") + +// errNoMatch signals that a provider answered correctly but has no lyrics. +var errNoMatch = errors.New("lyrics: provider has no match") + +// errBadShape signals that a provider answered with an unexpected payload. +var errBadShape = errors.New("lyrics: unexpected provider response") + +// Client fetches lyrics from LRCLIB with a NetEase fallback, memoised in RAM +// and on disk. +type Client struct { + LRCLibBaseURL string // default "https://lrclib.net" + NetEaseBaseURL string // default "https://music.163.com" + HTTPClient *http.Client + + cacheDir string + mem *memCache + memOnce sync.Once + diskMu sync.Mutex +} + +// NewClient returns a Client caching sheets under cacheDir (created lazily). +// An empty cacheDir disables disk caching. +func NewClient(cacheDir string) *Client { + return &Client{ + LRCLibBaseURL: DefaultLRCLibBaseURL, + NetEaseBaseURL: DefaultNetEaseBaseURL, + HTTPClient: &http.Client{Timeout: DefaultTimeout}, + cacheDir: strings.TrimSpace(cacheDir), + mem: newMemCache(memCapacity, memTTL, negativeTTL), + } +} + +// CacheDir reports the directory used for the on-disk sheet cache, or an empty +// string when disk caching is disabled. +func (c *Client) CacheDir() string { + if c == nil { + return "" + } + return c.cacheDir +} + +// Fetch resolves lyrics for a track. It tries the in-memory cache, the disk +// cache, LRCLIB's /api/get exact lookup, LRCLIB's /api/search, then NetEase. +// duration may be zero when unknown. It returns a nil Sheet and ErrNotFound +// when no provider has lyrics. +// +// When every provider failed to answer at all (network or server errors) the +// returned error wraps those failures instead of ErrNotFound, and the outcome +// is not negatively cached. +func (c *Client) Fetch(ctx context.Context, artist, title, album string, duration time.Duration) (*Sheet, error) { + if c == nil { + return nil, ErrNotFound + } + if ctx == nil { + ctx = context.Background() + } + + queryArtist := normalizeQuery(artist) + queryTitle := normalizeQuery(title) + queryAlbum := normalizeQuery(album) + if queryTitle == "" { + return nil, ErrNotFound + } + + key := cacheKey(artist, title, album, duration) + + if res, ok := c.memCacheRef().get(key); ok { + if res.negative { + return nil, ErrNotFound + } + return res.sheet.clone(), nil + } + + if sheet := c.readDisk(key); sheet != nil { + c.memCacheRef().putSheet(key, sheet) + return sheet.clone(), nil + } + + var failures []error + + providers := []struct { + name string + run func() (*Sheet, error) + }{ + {"lrclib.get", func() (*Sheet, error) { + return c.lrclibGet(ctx, queryArtist, queryTitle, queryAlbum, duration) + }}, + {"lrclib.search", func() (*Sheet, error) { + return c.lrclibSearch(ctx, queryArtist, queryTitle, duration) + }}, + {"netease", func() (*Sheet, error) { + return c.netEase(ctx, queryArtist, queryTitle, duration) + }}, + } + + for _, p := range providers { + if err := ctx.Err(); err != nil { + return nil, err + } + + sheet, err := p.run() + if err != nil { + failures = append(failures, fmt.Errorf("%s: %w", p.name, err)) + continue + } + if sheet == nil || sheet.IsEmpty() { + continue + } + + applyRequestMetadata(sheet, artist, title, album, duration) + c.memCacheRef().putSheet(key, sheet) + c.writeDisk(key, sheet) + return sheet.clone(), nil + } + + if len(failures) > 0 { + // Do not remember a negative result that was caused by a transport or + // server failure rather than by a genuine miss. + return nil, fmt.Errorf("lyrics: no provider answered: %w", errors.Join(failures...)) + } + + c.memCacheRef().putNegative(key) + return nil, ErrNotFound +} + +// applyRequestMetadata fills in any metadata the provider did not report, +// using the caller's original (un-normalised) strings. +func applyRequestMetadata(sheet *Sheet, artist, title, album string, duration time.Duration) { + if strings.TrimSpace(sheet.Artist) == "" { + sheet.Artist = strings.TrimSpace(artist) + } + if strings.TrimSpace(sheet.Title) == "" { + sheet.Title = strings.TrimSpace(title) + } + if strings.TrimSpace(sheet.Album) == "" { + sheet.Album = strings.TrimSpace(album) + } + if sheet.Duration <= 0 && duration > 0 { + sheet.Duration = duration + } +} + +// memCacheRef returns the in-memory cache, initialising it once for Clients +// that were built as a zero value rather than through NewClient. +func (c *Client) memCacheRef() *memCache { + c.memOnce.Do(func() { + if c.mem == nil { + c.mem = newMemCache(memCapacity, memTTL, negativeTTL) + } + }) + return c.mem +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return &http.Client{Timeout: DefaultTimeout} +} + +func (c *Client) lrclibBase() string { + if base := strings.TrimRight(strings.TrimSpace(c.LRCLibBaseURL), "/"); base != "" { + return base + } + return DefaultLRCLibBaseURL +} + +func (c *Client) netEaseBase() string { + if base := strings.TrimRight(strings.TrimSpace(c.NetEaseBaseURL), "/"); base != "" { + return base + } + return DefaultNetEaseBaseURL +} + +// getJSON performs a GET request and decodes the JSON body into out. A 404 or +// 204 response yields errNoMatch; an undecodable body yields errBadShape. +func (c *Client) getJSON(ctx context.Context, rawURL string, headers map[string]string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + switch { + case resp.StatusCode == http.StatusNotFound, resp.StatusCode == http.StatusNoContent: + return errNoMatch + case resp.StatusCode >= 400: + return fmt.Errorf("lyrics: %s returned HTTP %d", req.URL.Host, resp.StatusCode) + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("%w: %v", errBadShape, err) + } + return nil +} + +// lrcLibTrack mirrors one track object returned by the LRCLIB API. +type lrcLibTrack struct { + ID int64 `json:"id"` + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Duration float64 `json:"duration"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"syncedLyrics"` +} + +// hasLyrics reports whether the track carries anything renderable. +func (t lrcLibTrack) hasLyrics() bool { + return t.Instrumental || + strings.TrimSpace(t.SyncedLyrics) != "" || + strings.TrimSpace(t.PlainLyrics) != "" +} + +func (t lrcLibTrack) isSynced() bool { + return strings.TrimSpace(t.SyncedLyrics) != "" +} + +// toSheet converts an LRCLIB track into a Sheet, or nil when it has no lyrics. +func (t lrcLibTrack) toSheet() *Sheet { + sheet := &Sheet{ + Artist: strings.TrimSpace(t.ArtistName), + Title: strings.TrimSpace(t.TrackName), + Album: strings.TrimSpace(t.AlbumName), + Source: SourceLRCLib, + } + if t.Duration > 0 { + sheet.Duration = time.Duration(t.Duration * float64(time.Second)) + } + + if t.Instrumental { + sheet.Instrumental = true + sheet.Lines = []Line{{Text: InstrumentalMarker}} + return sheet + } + if lines := ParseLRC(t.SyncedLyrics); len(lines) > 0 { + sheet.Lines = lines + sheet.Synced = true + return sheet + } + if lines := plainToLines(t.PlainLyrics); len(lines) > 0 { + sheet.Lines = lines + return sheet + } + return nil +} + +// lrclibGet performs LRCLIB's exact /api/get lookup. +func (c *Client) lrclibGet(ctx context.Context, artist, title, album string, duration time.Duration) (*Sheet, error) { + if artist == "" || title == "" { + return nil, nil + } + + q := url.Values{} + q.Set("artist_name", artist) + q.Set("track_name", title) + if album != "" { + q.Set("album_name", album) + } + if duration > 0 { + q.Set("duration", strconv.FormatInt(int64(duration.Round(time.Second)/time.Second), 10)) + } + + var track lrcLibTrack + err := c.getJSON(ctx, c.lrclibBase()+"/api/get?"+q.Encode(), + map[string]string{"User-Agent": lrclibUserAgent}, &track) + switch { + case errors.Is(err, errNoMatch), errors.Is(err, errBadShape): + return nil, nil + case err != nil: + return nil, err + } + + return track.toSheet(), nil +} + +// lrclibSearch performs LRCLIB's fuzzy /api/search lookup and picks the best +// candidate: synced lyrics first, then the closest duration, then the first. +func (c *Client) lrclibSearch(ctx context.Context, artist, title string, duration time.Duration) (*Sheet, error) { + if title == "" { + return nil, nil + } + + q := url.Values{} + if artist != "" { + q.Set("artist_name", artist) + } + q.Set("track_name", title) + + var candidates []lrcLibTrack + err := c.getJSON(ctx, c.lrclibBase()+"/api/search?"+q.Encode(), + map[string]string{"User-Agent": lrclibUserAgent}, &candidates) + switch { + case errors.Is(err, errNoMatch), errors.Is(err, errBadShape): + return nil, nil + case err != nil: + return nil, err + } + + best := pickLRCLibCandidate(candidates, duration) + if best == nil { + return nil, nil + } + return best.toSheet(), nil +} + +// pickLRCLibCandidate returns the most suitable search result, or nil. +func pickLRCLibCandidate(candidates []lrcLibTrack, duration time.Duration) *lrcLibTrack { + best := -1 + for i := range candidates { + if !candidates[i].hasLyrics() { + continue + } + if best < 0 || betterLRCLibCandidate(candidates[i], candidates[best], duration) { + best = i + } + } + if best < 0 { + return nil + } + return &candidates[best] +} + +// betterLRCLibCandidate reports whether a should beat b. +func betterLRCLibCandidate(a, b lrcLibTrack, duration time.Duration) bool { + if a.isSynced() != b.isSynced() { + return a.isSynced() + } + if duration > 0 { + want := duration.Seconds() + return absFloat(a.Duration-want) < absFloat(b.Duration-want) + } + return false +} + +func absFloat(v float64) float64 { + if v < 0 { + return -v + } + return v +} + +// netEaseSong mirrors one song object from the NetEase search endpoint. +type netEaseSong struct { + ID int64 `json:"id"` + Name string `json:"name"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Album struct { + Name string `json:"name"` + } `json:"album"` + Duration int64 `json:"duration"` // milliseconds +} + +func (s netEaseSong) artistName() string { + names := make([]string, 0, len(s.Artists)) + for _, a := range s.Artists { + if n := strings.TrimSpace(a.Name); n != "" { + names = append(names, n) + } + } + return strings.Join(names, " & ") +} + +type netEaseSearchResponse struct { + Result struct { + Songs []netEaseSong `json:"songs"` + } `json:"result"` +} + +type netEaseLyricResponse struct { + LRC struct { + Lyric string `json:"lyric"` + } `json:"lrc"` +} + +// netEase resolves lyrics through NetEase Cloud Music. Unexpected payload +// shapes are treated as "no lyrics" rather than as errors. +func (c *Client) netEase(ctx context.Context, artist, title string, duration time.Duration) (*Sheet, error) { + if title == "" { + return nil, nil + } + + headers := map[string]string{ + "User-Agent": netEaseUserAgent, + "Referer": DefaultNetEaseBaseURL, + } + + terms := strings.TrimSpace(artist + " " + title) + q := url.Values{} + q.Set("s", terms) + q.Set("type", "1") + q.Set("limit", "5") + + var search netEaseSearchResponse + err := c.getJSON(ctx, c.netEaseBase()+"/api/search/get?"+q.Encode(), headers, &search) + switch { + case errors.Is(err, errNoMatch), errors.Is(err, errBadShape): + return nil, nil + case err != nil: + return nil, err + } + + song := pickNetEaseSong(search.Result.Songs, artist, title, duration) + if song == nil { + return nil, nil + } + + lq := url.Values{} + lq.Set("id", strconv.FormatInt(song.ID, 10)) + lq.Set("lv", "1") + lq.Set("kv", "1") + lq.Set("tv", "-1") + + var lyric netEaseLyricResponse + err = c.getJSON(ctx, c.netEaseBase()+"/api/song/lyric?"+lq.Encode(), headers, &lyric) + switch { + case errors.Is(err, errNoMatch), errors.Is(err, errBadShape): + return nil, nil + case err != nil: + return nil, err + } + + raw := lyric.LRC.Lyric + if strings.TrimSpace(raw) == "" { + return nil, nil + } + + sheet := &Sheet{ + Artist: song.artistName(), + Title: strings.TrimSpace(song.Name), + Album: strings.TrimSpace(song.Album.Name), + Source: SourceNetEase, + } + if song.Duration > 0 { + sheet.Duration = time.Duration(song.Duration) * time.Millisecond + } + + if lines := ParseLRC(raw); len(lines) > 0 && lines[len(lines)-1].At > 0 { + sheet.Lines = lines + sheet.Synced = true + return sheet, nil + } + if lines := plainToLines(raw); len(lines) > 0 { + sheet.Lines = lines + return sheet, nil + } + return nil, nil +} + +// pickNetEaseSong scores search hits by title and artist agreement, breaking +// ties with the closest duration. +func pickNetEaseSong(songs []netEaseSong, artist, title string, duration time.Duration) *netEaseSong { + wantTitle := normalizeKey(title) + wantArtist := normalizeKey(artist) + + best := -1 + bestScore := -1 + var bestDelta time.Duration + + for i := range songs { + if songs[i].ID == 0 { + continue + } + + score := 0 + gotTitle := normalizeKey(songs[i].Name) + if gotTitle != "" && wantTitle != "" { + switch { + case gotTitle == wantTitle: + score += 2 + case strings.Contains(gotTitle, wantTitle), strings.Contains(wantTitle, gotTitle): + score++ + } + } + gotArtist := normalizeKey(songs[i].artistName()) + if gotArtist != "" && wantArtist != "" && + (gotArtist == wantArtist || + strings.Contains(gotArtist, wantArtist) || + strings.Contains(wantArtist, gotArtist)) { + score += 2 + } + + delta := time.Duration(0) + if duration > 0 && songs[i].Duration > 0 { + delta = absDuration(time.Duration(songs[i].Duration)*time.Millisecond - duration) + } + + if best < 0 || score > bestScore || (score == bestScore && duration > 0 && delta < bestDelta) { + best, bestScore, bestDelta = i, score, delta + } + } + + if best < 0 { + return nil + } + return &songs[best] +} + +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} diff --git a/pkg/lyrics/client_test.go b/pkg/lyrics/client_test.go new file mode 100644 index 0000000..c1e121e --- /dev/null +++ b/pkg/lyrics/client_test.go @@ -0,0 +1,1122 @@ +package lyrics + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const testSyncedLRC = "[ar:Daft Punk]\n[00:10.00]first\n[00:20.00]second\n[00:30.00]third" + +// recordingServer is a test provider that records the requests it served. +type recordingServer struct { + *httptest.Server + + mu sync.Mutex + requests []*url.URL + headers []http.Header + hits atomic.Int64 +} + +func newRecordingServer(t *testing.T, routes map[string]http.HandlerFunc) *recordingServer { + t.Helper() + + rec := &recordingServer{} + mux := http.NewServeMux() + for pattern, handler := range routes { + h := handler + mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + rec.mu.Lock() + rec.requests = append(rec.requests, r.URL) + rec.headers = append(rec.headers, r.Header.Clone()) + rec.mu.Unlock() + rec.hits.Add(1) + h(w, r) + }) + } + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL) + http.NotFound(w, r) + }) + + rec.Server = httptest.NewServer(mux) + t.Cleanup(rec.Server.Close) + return rec +} + +func (r *recordingServer) lastQuery(t *testing.T) url.Values { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if len(r.requests) == 0 { + t.Fatal("no requests recorded") + } + return r.requests[len(r.requests)-1].Query() +} + +func (r *recordingServer) requestAt(t *testing.T, i int) *url.URL { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if i >= len(r.requests) { + t.Fatalf("request %d not recorded (have %d)", i, len(r.requests)) + } + return r.requests[i] +} + +func (r *recordingServer) headerAt(t *testing.T, i int) http.Header { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + if i >= len(r.headers) { + t.Fatalf("request %d not recorded (have %d)", i, len(r.headers)) + } + return r.headers[i] +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Errorf("encode response: %v", err) + } +} + +// notFoundHandler stands in for a provider that has no match. +func notFoundHandler(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "not found", http.StatusNotFound) +} + +// emptyArrayHandler stands in for an LRCLIB search with no results. +func emptyArrayHandler(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("[]")) +} + +// testClient wires a Client to the given fake providers and disables disk +// caching unless cacheDir is set. +func testClient(cacheDir string, lrclib, netease *recordingServer) *Client { + c := NewClient(cacheDir) + c.HTTPClient = &http.Client{Timeout: 5 * time.Second} + if lrclib != nil { + c.LRCLibBaseURL = lrclib.URL + } else { + c.LRCLibBaseURL = "http://127.0.0.1:1/unreachable" + } + if netease != nil { + c.NetEaseBaseURL = netease.URL + } else { + c.NetEaseBaseURL = "http://127.0.0.1:1/unreachable" + } + return c +} + +func TestNewClientDefaults(t *testing.T) { + c := NewClient(" /tmp/halpradio-lyrics ") + if c.LRCLibBaseURL != DefaultLRCLibBaseURL { + t.Errorf("LRCLibBaseURL = %q, want %q", c.LRCLibBaseURL, DefaultLRCLibBaseURL) + } + if c.NetEaseBaseURL != DefaultNetEaseBaseURL { + t.Errorf("NetEaseBaseURL = %q, want %q", c.NetEaseBaseURL, DefaultNetEaseBaseURL) + } + if c.HTTPClient == nil || c.HTTPClient.Timeout != DefaultTimeout { + t.Errorf("HTTPClient = %+v, want a %v timeout", c.HTTPClient, DefaultTimeout) + } + if c.CacheDir() != "/tmp/halpradio-lyrics" { + t.Errorf("CacheDir = %q, want the trimmed path", c.CacheDir()) + } + + empty := NewClient("") + if empty.CacheDir() != "" { + t.Errorf("CacheDir = %q, want empty", empty.CacheDir()) + } +} + +func TestClientBaseURLFallbacks(t *testing.T) { + c := &Client{} + if got := c.lrclibBase(); got != DefaultLRCLibBaseURL { + t.Errorf("lrclibBase = %q, want %q", got, DefaultLRCLibBaseURL) + } + if got := c.netEaseBase(); got != DefaultNetEaseBaseURL { + t.Errorf("netEaseBase = %q, want %q", got, DefaultNetEaseBaseURL) + } + if c.httpClient() == nil { + t.Error("httpClient must never be nil") + } + + trailing := &Client{LRCLibBaseURL: "http://example.test/ ", NetEaseBaseURL: " http://ne.test//"} + if got := trailing.lrclibBase(); got != "http://example.test" { + t.Errorf("lrclibBase = %q, want trailing slash trimmed", got) + } + if got := trailing.netEaseBase(); got != "http://ne.test" { + t.Errorf("netEaseBase = %q, want trailing slashes trimmed", got) + } +} + +func TestFetchLRCLibExactSynced(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, lrcLibTrack{ + ID: 1, + TrackName: "Voyager", + ArtistName: "Daft Punk", + AlbumName: "Discovery", + Duration: 227.5, + PlainLyrics: "first\nsecond\nthird", + SyncedLyrics: testSyncedLRC, + }) + }, + }) + + c := testClient("", lrclib, nil) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "Discovery", 227*time.Second) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + + if sheet.Source != SourceLRCLib { + t.Errorf("Source = %q, want %q", sheet.Source, SourceLRCLib) + } + if !sheet.Synced { + t.Error("Synced = false, want true when syncedLyrics are present") + } + if sheet.Instrumental { + t.Error("Instrumental = true, want false") + } + if len(sheet.Lines) != 3 { + t.Fatalf("len(Lines) = %d, want 3", len(sheet.Lines)) + } + if sheet.Lines[0].At != 10*time.Second || sheet.Lines[0].Text != "first" { + t.Errorf("first line = %+v", sheet.Lines[0]) + } + if sheet.Artist != "Daft Punk" || sheet.Title != "Voyager" || sheet.Album != "Discovery" { + t.Errorf("metadata = %q / %q / %q", sheet.Artist, sheet.Title, sheet.Album) + } + if sheet.Duration != 227500*time.Millisecond { + t.Errorf("Duration = %v, want 227.5s", sheet.Duration) + } + + q := lrclib.lastQuery(t) + for key, want := range map[string]string{ + "artist_name": "Daft Punk", + "track_name": "Voyager", + "album_name": "Discovery", + "duration": "227", + } { + if got := q.Get(key); got != want { + t.Errorf("query %s = %q, want %q", key, got, want) + } + } + + if ua := lrclib.headerAt(t, 0).Get("User-Agent"); !strings.Contains(ua, "halpradio") { + t.Errorf("User-Agent = %q, want a descriptive halpradio agent", ua) + } +} + +func TestFetchLRCLibNormalisesQueryTerms(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, lrcLibTrack{TrackName: "Halo", ArtistName: "Beyoncé", SyncedLyrics: testSyncedLRC}) + }, + }) + + c := testClient("", lrclib, nil) + sheet, err := c.Fetch(context.Background(), "Beyoncé", "Halo (feat. Jay-Z) [HQ]", "", 0) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + + q := lrclib.lastQuery(t) + if got := q.Get("track_name"); got != "Halo" { + t.Errorf("track_name = %q, want the normalised %q", got, "Halo") + } + if q.Has("duration") { + t.Error("duration must be omitted when unknown") + } + if q.Has("album_name") { + t.Error("album_name must be omitted when unknown") + } + if sheet.Title != "Halo" { + t.Errorf("Title = %q, want the provider value", sheet.Title) + } +} + +func TestFetchLRCLibPlainOnly(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, lrcLibTrack{ + TrackName: "Voyager", + ArtistName: "Daft Punk", + PlainLyrics: "\nline one\nline two\n\n", + }) + }, + }) + + c := testClient("", lrclib, nil) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if sheet.Synced { + t.Error("Synced = true, want false for plain lyrics") + } + want := []string{"line one", "line two"} + got := sheet.PlainLines() + if len(got) != len(want) { + t.Fatalf("PlainLines = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line %d = %q, want %q", i, got[i], want[i]) + } + } + if sheet.ActiveIndex(time.Minute) != -1 { + t.Error("unsynced sheets must report ActiveIndex -1") + } +} + +func TestFetchLRCLibInstrumental(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, lrcLibTrack{ + TrackName: "Nightcall", + ArtistName: "Kavinsky", + Instrumental: true, + }) + }, + }) + + c := testClient("", lrclib, nil) + sheet, err := c.Fetch(context.Background(), "Kavinsky", "Nightcall", "", 0) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if !sheet.Instrumental { + t.Error("Instrumental = false, want true") + } + if sheet.Synced { + t.Error("Synced = true, want false for an instrumental sheet") + } + if len(sheet.Lines) != 1 || sheet.Lines[0].Text != InstrumentalMarker { + t.Errorf("Lines = %+v, want a single %q line", sheet.Lines, InstrumentalMarker) + } + if sheet.IsEmpty() { + t.Error("an instrumental sheet is renderable") + } +} + +func TestFetchFallsBackToSearch(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, []lrcLibTrack{ + {TrackName: "Voyager", ArtistName: "Daft Punk", Duration: 100, PlainLyrics: "plain only"}, + {TrackName: "Voyager", ArtistName: "Daft Punk", Duration: 400, SyncedLyrics: testSyncedLRC}, + }) + }, + }) + + c := testClient("", lrclib, nil) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 120*time.Second) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if !sheet.Synced { + t.Error("search should prefer the synced candidate even when its duration is further away") + } + if got := lrclib.requestAt(t, 1).Path; got != "/api/search" { + t.Errorf("second request path = %q, want /api/search", got) + } + if q := lrclib.lastQuery(t); q.Get("track_name") != "Voyager" || q.Get("artist_name") != "Daft Punk" { + t.Errorf("search query = %v", q) + } +} + +func TestPickLRCLibCandidate(t *testing.T) { + synced := lrcLibTrack{TrackName: "s", Duration: 300, SyncedLyrics: testSyncedLRC} + plainNear := lrcLibTrack{TrackName: "near", Duration: 200, PlainLyrics: "x"} + plainFar := lrcLibTrack{TrackName: "far", Duration: 500, PlainLyrics: "x"} + instrumental := lrcLibTrack{TrackName: "inst", Duration: 199, Instrumental: true} + useless := lrcLibTrack{TrackName: "useless", Duration: 200} + + tests := []struct { + name string + candidates []lrcLibTrack + duration time.Duration + wantTitle string + }{ + {name: "no candidates", candidates: nil, wantTitle: ""}, + {name: "all useless", candidates: []lrcLibTrack{useless}, wantTitle: ""}, + {name: "single plain", candidates: []lrcLibTrack{plainFar}, wantTitle: "far"}, + { + name: "synced wins over plain", + candidates: []lrcLibTrack{plainNear, synced}, + duration: 200 * time.Second, + wantTitle: "s", + }, + { + name: "closest duration among plain", + candidates: []lrcLibTrack{plainFar, plainNear}, + duration: 210 * time.Second, + wantTitle: "near", + }, + { + name: "first wins without a duration hint", + candidates: []lrcLibTrack{plainFar, plainNear}, + wantTitle: "far", + }, + { + name: "useless candidates are skipped", + candidates: []lrcLibTrack{useless, plainNear}, + wantTitle: "near", + }, + { + name: "instrumental counts as lyrics", + candidates: []lrcLibTrack{useless, instrumental}, + wantTitle: "inst", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pickLRCLibCandidate(tt.candidates, tt.duration) + if tt.wantTitle == "" { + if got != nil { + t.Fatalf("picked %+v, want nil", got) + } + return + } + if got == nil { + t.Fatalf("picked nil, want %q", tt.wantTitle) + } + if got.TrackName != tt.wantTitle { + t.Errorf("picked %q, want %q", got.TrackName, tt.wantTitle) + } + }) + } +} + +func TestFetchFallsBackToNetEase(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": emptyArrayHandler, + }) + netease := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, map[string]any{ + "result": map[string]any{ + "songs": []map[string]any{ + { + "id": 7, + "name": "Voyager", + "artists": []map[string]any{{"name": "Daft Punk"}}, + "album": map[string]any{"name": "Discovery"}, + "duration": 227000, + }, + }, + }, + }) + }, + "/api/song/lyric": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, map[string]any{ + "lrc": map[string]any{"lyric": testSyncedLRC}, + "tlyric": map[string]any{"lyric": "[00:10.00]translated"}, + }) + }, + }) + + c := testClient("", lrclib, netease) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 227*time.Second) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + + if sheet.Source != SourceNetEase { + t.Errorf("Source = %q, want %q", sheet.Source, SourceNetEase) + } + if !sheet.Synced || len(sheet.Lines) != 3 { + t.Errorf("expected 3 synced lines, got synced=%v lines=%+v", sheet.Synced, sheet.Lines) + } + if sheet.Album != "Discovery" || sheet.Duration != 227*time.Second { + t.Errorf("metadata = %q / %v", sheet.Album, sheet.Duration) + } + for _, line := range sheet.Lines { + if line.Text == "translated" { + t.Error("the translation track must be ignored") + } + } + + search := netease.requestAt(t, 0) + if search.Path != "/api/search/get" { + t.Fatalf("first NetEase path = %q", search.Path) + } + q := search.Query() + if q.Get("s") != "Daft Punk Voyager" || q.Get("type") != "1" || q.Get("limit") != "5" { + t.Errorf("NetEase search query = %v", q) + } + + lyric := netease.requestAt(t, 1).Query() + if lyric.Get("id") != "7" || lyric.Get("lv") != "1" || lyric.Get("kv") != "1" || lyric.Get("tv") != "-1" { + t.Errorf("NetEase lyric query = %v", lyric) + } + + headers := netease.headerAt(t, 0) + if !strings.Contains(headers.Get("User-Agent"), "Mozilla") { + t.Errorf("NetEase User-Agent = %q, want a browser-like agent", headers.Get("User-Agent")) + } + if headers.Get("Referer") != DefaultNetEaseBaseURL { + t.Errorf("Referer = %q, want %q", headers.Get("Referer"), DefaultNetEaseBaseURL) + } +} + +func TestFetchNetEasePlainLyric(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": emptyArrayHandler, + }) + netease := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, map[string]any{"result": map[string]any{"songs": []map[string]any{ + {"id": 9, "name": "Voyager", "artists": []map[string]any{{"name": "Daft Punk"}}}, + }}}) + }, + "/api/song/lyric": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, map[string]any{"lrc": map[string]any{"lyric": "no timestamps here\nsecond line"}}) + }, + }) + + c := testClient("", lrclib, netease) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if sheet.Synced { + t.Error("Synced = true, want false for a lyric without timestamps") + } + if len(sheet.Lines) != 2 { + t.Errorf("Lines = %+v, want 2", sheet.Lines) + } +} + +func TestFetchNotFound(t *testing.T) { + tests := []struct { + name string + lrclib map[string]http.HandlerFunc + netease map[string]http.HandlerFunc + }{ + { + name: "every provider has no match", + lrclib: map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": emptyArrayHandler, + }, + netease: map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"result":{"songs":[]}}`)) + }, + }, + }, + { + name: "lrclib returns a track without lyrics", + lrclib: map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"trackName":"Voyager","artistName":"Daft Punk","plainLyrics":null,"syncedLyrics":null}`)) + }, + "/api/search": emptyArrayHandler, + }, + netease: map[string]http.HandlerFunc{ + "/api/search/get": notFoundHandler, + }, + }, + { + name: "netease answers with an unexpected shape", + lrclib: map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": emptyArrayHandler, + }, + netease: map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`nope`)) + }, + }, + }, + { + name: "netease lyric payload is empty", + lrclib: map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": emptyArrayHandler, + }, + netease: map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"result":{"songs":[{"id":3,"name":"Voyager"}]}}`)) + }, + "/api/song/lyric": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"lrc":{"lyric":" "}}`)) + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lrclib := newRecordingServer(t, tt.lrclib) + netease := newRecordingServer(t, tt.netease) + + c := testClient("", lrclib, netease) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + if sheet != nil { + t.Errorf("sheet = %+v, want nil", sheet) + } + }) + } +} + +func TestFetchEmptyTitle(t *testing.T) { + c := testClient("", nil, nil) + for _, title := range []string{"", " ", "(Official Video)"} { + sheet, err := c.Fetch(context.Background(), "Daft Punk", title, "", 0) + if !errors.Is(err, ErrNotFound) { + t.Errorf("Fetch(title=%q) err = %v, want ErrNotFound", title, err) + } + if sheet != nil { + t.Errorf("Fetch(title=%q) sheet = %+v, want nil", title, sheet) + } + } +} + +func TestFetchWithoutArtistSkipsExactLookup(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/search": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, []lrcLibTrack{{TrackName: "Voyager", SyncedLyrics: testSyncedLRC}}) + }, + }) + + c := testClient("", lrclib, nil) + sheet, err := c.Fetch(context.Background(), "", "Voyager", "", 0) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if !sheet.Synced { + t.Error("expected the search result to be used") + } + if got := lrclib.requestAt(t, 0).Path; got != "/api/search" { + t.Errorf("first request = %q, want /api/search (the exact lookup needs an artist)", got) + } +} + +func TestFetchProviderFailure(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }, + "/api/search": func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusBadGateway) + }, + }) + + c := testClient("", lrclib, nil) // NetEase points at an unreachable port. + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if err == nil { + t.Fatal("expected an error when every provider fails") + } + if errors.Is(err, ErrNotFound) { + t.Error("transport failures must not be reported as ErrNotFound") + } + if sheet != nil { + t.Errorf("sheet = %+v, want nil", sheet) + } + if !strings.Contains(err.Error(), "lrclib.get") || !strings.Contains(err.Error(), "netease") { + t.Errorf("error should name the failing providers: %v", err) + } + + // A failure is not cached negatively: the next call retries. + before := lrclib.hits.Load() + _, _ = c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if lrclib.hits.Load() == before { + t.Error("a failed lookup must not be negatively cached") + } +} + +func TestFetchMemoryCacheHit(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, lrcLibTrack{TrackName: "Voyager", ArtistName: "Daft Punk", SyncedLyrics: testSyncedLRC}) + }, + }) + + c := testClient("", lrclib, nil) + first, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if got := lrclib.hits.Load(); got != 1 { + t.Fatalf("hits = %d, want 1", got) + } + + // Noise that normalises away must still hit the same cache entry. + second, err := c.Fetch(context.Background(), "DAFT PUNK", "Voyager (Official Video)", "", 0) + if err != nil { + t.Fatalf("Fetch (cached): %v", err) + } + if got := lrclib.hits.Load(); got != 1 { + t.Errorf("hits = %d, want the second lookup served from memory", got) + } + if second.Source != SourceLRCLib { + t.Errorf("Source = %q, want the original provider name", second.Source) + } + + // Each call returns an independent copy. + if first == second { + t.Error("Fetch returned the same pointer twice") + } + second.Lines[0].Text = "mutated" + third, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if err != nil { + t.Fatalf("Fetch (cached): %v", err) + } + if third.Lines[0].Text != "first" { + t.Error("mutating a returned sheet corrupted the cache") + } +} + +func TestFetchNegativeCaching(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": emptyArrayHandler, + }) + netease := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"result":{"songs":[]}}`)) + }, + }) + + c := testClient("", lrclib, netease) + for i := 0; i < 3; i++ { + if _, err := c.Fetch(context.Background(), "Nobody", "Nothing", "", 0); !errors.Is(err, ErrNotFound) { + t.Fatalf("call %d: err = %v, want ErrNotFound", i, err) + } + } + + if got := lrclib.hits.Load(); got != 2 { + t.Errorf("LRCLIB hits = %d, want 2 (one get + one search, then cached)", got) + } + if got := netease.hits.Load(); got != 1 { + t.Errorf("NetEase hits = %d, want 1 (then cached)", got) + } + + // Negative entries are never written to disk. + disk := NewClient(t.TempDir()) + disk.mem.putNegative("k") + if entries, err := readDirNames(disk.CacheDir()); err != nil || len(entries) != 0 { + t.Errorf("cache dir entries = %v (err %v), want none", entries, err) + } +} + +func TestFetchDiskCacheSurvivesNewClient(t *testing.T) { + dir := t.TempDir() + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, lrcLibTrack{ + TrackName: "Voyager", + ArtistName: "Daft Punk", + Duration: 227, + SyncedLyrics: testSyncedLRC, + }) + }, + }) + + first := testClient(dir, lrclib, nil) + if _, err := first.Fetch(context.Background(), "Daft Punk", "Voyager", "", 227*time.Second); err != nil { + t.Fatalf("Fetch: %v", err) + } + if got := lrclib.hits.Load(); got != 1 { + t.Fatalf("hits = %d, want 1", got) + } + + // A brand new client with a cold memory cache reads the sheet from disk. + second := testClient(dir, lrclib, nil) + sheet, err := second.Fetch(context.Background(), "Daft Punk", "Voyager", "", 227*time.Second) + if err != nil { + t.Fatalf("Fetch (disk): %v", err) + } + if got := lrclib.hits.Load(); got != 1 { + t.Errorf("hits = %d, want the second client served from disk", got) + } + if sheet.Source != SourceLRCLib { + t.Errorf("Source = %q, want the original provider name", sheet.Source) + } + if !sheet.Synced || len(sheet.Lines) != 3 || sheet.Lines[2].At != 30*time.Second { + t.Errorf("disk sheet lost data: %+v", sheet) + } + if _, ok := second.mem.get(cacheKey("Daft Punk", "Voyager", "", 227*time.Second)); !ok { + t.Error("a disk hit should populate the memory cache") + } +} + +func TestFetchContextCancelled(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + }, + }) + + c := testClient("", lrclib, nil) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + sheet, err := c.Fetch(ctx, "Daft Punk", "Voyager", "", 0) + if err == nil { + t.Fatal("expected an error for a cancelled context") + } + if sheet != nil { + t.Errorf("sheet = %+v, want nil", sheet) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("err = %v, want a context deadline error", err) + } +} + +func TestFetchAlreadyCancelledContext(t *testing.T) { + c := testClient("", nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := c.Fetch(ctx, "Daft Punk", "Voyager", "", 0); !errors.Is(err, context.Canceled) { + t.Errorf("err = %v, want context.Canceled", err) + } +} + +func TestFetchNilReceiverAndContext(t *testing.T) { + if _, err := (*Client)(nil).Fetch(context.Background(), "a", "b", "", 0); !errors.Is(err, ErrNotFound) { + t.Errorf("nil client err = %v, want ErrNotFound", err) + } + + // A zero-value Client must lazily build its cache rather than panic. + zero := &Client{LRCLibBaseURL: "http://127.0.0.1:1", NetEaseBaseURL: "http://127.0.0.1:1"} + //nolint:staticcheck // deliberately passing a nil context to prove it is tolerated. + if _, err := zero.Fetch(nil, "Daft Punk", "Voyager", "", 0); err == nil { + t.Error("expected an error from unreachable providers") + } +} + +func TestFetchConcurrent(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, lrcLibTrack{TrackName: "Voyager", ArtistName: "Daft Punk", SyncedLyrics: testSyncedLRC}) + }, + }) + + c := testClient(t.TempDir(), lrclib, nil) + var wg sync.WaitGroup + for i := 0; i < 12; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + title := fmt.Sprintf("Voyager %d", n%4) + if _, err := c.Fetch(context.Background(), "Daft Punk", title, "", 0); err != nil { + t.Errorf("Fetch: %v", err) + } + }(i) + } + wg.Wait() +} + +func TestPickNetEaseSong(t *testing.T) { + song := func(id int64, name, artist string, durMS int64) netEaseSong { + s := netEaseSong{ID: id, Name: name, Duration: durMS} + if artist != "" { + s.Artists = []struct { + Name string `json:"name"` + }{{Name: artist}} + } + return s + } + + tests := []struct { + name string + songs []netEaseSong + artist string + title string + duration time.Duration + wantID int64 + }{ + {name: "no songs", songs: nil, wantID: 0}, + {name: "zero id skipped", songs: []netEaseSong{song(0, "Voyager", "Daft Punk", 0)}, wantID: 0}, + { + name: "exact title and artist wins", + songs: []netEaseSong{song(1, "Other", "Someone", 0), song(2, "Voyager", "Daft Punk", 0)}, + artist: "Daft Punk", title: "Voyager", wantID: 2, + }, + { + name: "closest duration breaks a tie", + songs: []netEaseSong{song(1, "Voyager", "Daft Punk", 400_000), song(2, "Voyager", "Daft Punk", 228_000)}, + artist: "Daft Punk", title: "Voyager", duration: 227 * time.Second, wantID: 2, + }, + { + name: "first hit when nothing matches", + songs: []netEaseSong{song(5, "Totally Other", "Nobody", 0), song(6, "Also Other", "Nobody", 0)}, + artist: "Daft Punk", title: "Voyager", wantID: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pickNetEaseSong(tt.songs, tt.artist, tt.title, tt.duration) + if tt.wantID == 0 { + if got != nil { + t.Fatalf("picked %+v, want nil", got) + } + return + } + if got == nil { + t.Fatalf("picked nil, want id %d", tt.wantID) + } + if got.ID != tt.wantID { + t.Errorf("picked id %d, want %d", got.ID, tt.wantID) + } + }) + } +} + +func TestNetEaseSongArtistName(t *testing.T) { + s := netEaseSong{Artists: []struct { + Name string `json:"name"` + }{{Name: "Daft Punk"}, {Name: " "}, {Name: "Pharrell"}}} + if got := s.artistName(); got != "Daft Punk & Pharrell" { + t.Errorf("artistName = %q", got) + } + if got := (netEaseSong{}).artistName(); got != "" { + t.Errorf("artistName = %q, want empty", got) + } +} + +func TestApplyRequestMetadata(t *testing.T) { + sheet := &Sheet{} + applyRequestMetadata(sheet, " Daft Punk ", " Voyager ", " Discovery ", 227*time.Second) + if sheet.Artist != "Daft Punk" || sheet.Title != "Voyager" || sheet.Album != "Discovery" { + t.Errorf("metadata not filled in: %+v", sheet) + } + if sheet.Duration != 227*time.Second { + t.Errorf("Duration = %v, want the requested duration", sheet.Duration) + } + + provider := &Sheet{Artist: "Daft Punk", Title: "Voyager", Album: "Discovery", Duration: 200 * time.Second} + applyRequestMetadata(provider, "other", "other", "other", time.Hour) + if provider.Artist != "Daft Punk" || provider.Duration != 200*time.Second { + t.Errorf("provider metadata was overwritten: %+v", provider) + } +} + +func TestLRCLibTrackToSheet(t *testing.T) { + tests := []struct { + name string + track lrcLibTrack + wantNil bool + wantSynced bool + wantInstrumental bool + wantLines int + }{ + {name: "no lyrics", track: lrcLibTrack{TrackName: "x"}, wantNil: true}, + { + name: "instrumental", + track: lrcLibTrack{TrackName: "x", Instrumental: true}, + wantInstrumental: true, wantLines: 1, + }, + { + name: "synced preferred over plain", + track: lrcLibTrack{TrackName: "x", SyncedLyrics: testSyncedLRC, PlainLyrics: "a"}, + wantSynced: true, wantLines: 3, + }, + { + name: "plain fallback", + track: lrcLibTrack{TrackName: "x", PlainLyrics: "a\nb"}, + wantLines: 2, + }, + { + name: "unparseable synced lyrics fall back to plain", + track: lrcLibTrack{TrackName: "x", SyncedLyrics: "no timestamps", PlainLyrics: "a\nb"}, + wantLines: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.track.toSheet() + if tt.wantNil { + if got != nil { + t.Fatalf("toSheet = %+v, want nil", got) + } + return + } + if got == nil { + t.Fatal("toSheet = nil") + } + if got.Source != SourceLRCLib { + t.Errorf("Source = %q", got.Source) + } + if got.Synced != tt.wantSynced { + t.Errorf("Synced = %v, want %v", got.Synced, tt.wantSynced) + } + if got.Instrumental != tt.wantInstrumental { + t.Errorf("Instrumental = %v, want %v", got.Instrumental, tt.wantInstrumental) + } + if len(got.Lines) != tt.wantLines { + t.Errorf("len(Lines) = %d, want %d", len(got.Lines), tt.wantLines) + } + }) + } +} + +func TestGetJSONStatusHandling(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantErr error + }{ + {name: "404 is no match", handler: notFoundHandler, wantErr: errNoMatch}, + { + name: "204 is no match", + handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }, + wantErr: errNoMatch, + }, + { + name: "bad json is a shape error", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("")) }, + wantErr: errBadShape, + }, + { + name: "ok", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"trackName":"x"}`)) }, + wantErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + c := NewClient("") + var out lrcLibTrack + err := c.getJSON(context.Background(), srv.URL, nil, &out) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("err = %v, want nil", err) + } + return + } + if !errors.Is(err, tt.wantErr) { + t.Errorf("err = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestGetJSONServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer srv.Close() + + c := NewClient("") + var out lrcLibTrack + err := c.getJSON(context.Background(), srv.URL, nil, &out) + if err == nil { + t.Fatal("expected an error for HTTP 500") + } + if errors.Is(err, errNoMatch) || errors.Is(err, errBadShape) { + t.Errorf("err = %v, want a transport-level error", err) + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("err = %v, want the status code in the message", err) + } +} + +func TestGetJSONBadURL(t *testing.T) { + c := NewClient("") + var out lrcLibTrack + if err := c.getJSON(context.Background(), "http://[::1]:namedport/x", nil, &out); err == nil { + t.Error("expected an error for an invalid URL") + } +} + +// readDirNames lists a directory, treating a missing directory as empty. +func readDirNames(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names, nil +} + +func TestFetchRecoversFromAFailingProvider(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }, + }) + netease := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"result":{"songs":[{"id":4,"name":"Voyager"}]}}`)) + }, + "/api/song/lyric": func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, map[string]any{"lrc": map[string]any{"lyric": testSyncedLRC}}) + }, + }) + + c := testClient("", lrclib, netease) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if err != nil { + t.Fatalf("Fetch: %v", err) + } + if sheet.Source != SourceNetEase { + t.Errorf("Source = %q, want %q after the LRCLIB search failed", sheet.Source, SourceNetEase) + } +} + +func TestFetchNetEaseLyricRequestFails(t *testing.T) { + lrclib := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/get": notFoundHandler, + "/api/search": emptyArrayHandler, + }) + netease := newRecordingServer(t, map[string]http.HandlerFunc{ + "/api/search/get": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"result":{"songs":[{"id":4,"name":"Voyager"}]}}`)) + }, + "/api/song/lyric": func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom", http.StatusServiceUnavailable) + }, + }) + + c := testClient("", lrclib, netease) + sheet, err := c.Fetch(context.Background(), "Daft Punk", "Voyager", "", 0) + if err == nil || errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want a provider failure", err) + } + if sheet != nil { + t.Errorf("sheet = %+v, want nil", sheet) + } + if !strings.Contains(err.Error(), "netease") { + t.Errorf("err = %v, want the NetEase failure named", err) + } +} diff --git a/pkg/lyrics/lrc.go b/pkg/lyrics/lrc.go new file mode 100644 index 0000000..742e274 --- /dev/null +++ b/pkg/lyrics/lrc.go @@ -0,0 +1,158 @@ +package lyrics + +import ( + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +var ( + // Leading timestamp of an LRC line, e.g. "[01:23.45]" / "[01:23]" / "[01:23.456]". + lrcStampRe = regexp.MustCompile(`^\[\s*(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\s*\]`) + + // Enhanced (word level) LRC timings embedded in the text, e.g. "<00:12.34>". + lrcWordStampRe = regexp.MustCompile(`<\s*\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\s*>`) + + // Global offset tag in milliseconds, e.g. "[offset:+250]". + lrcOffsetRe = regexp.MustCompile(`(?i)^\[\s*offset\s*:\s*([+-]?\d{1,9})\s*\]$`) +) + +// ParseLRC parses an LRC document into timestamped lines, sorted ascending. +// Handles [mm:ss.xx], [mm:ss.xxx], [mm:ss], multiple timestamps on one line, +// and skips metadata tags like [ar:], [ti:], [al:], [length:], [offset:]. +// An [offset:NNN] tag (milliseconds) shifts every timestamp accordingly: a +// positive offset delays the lines, a negative one advances them, and no +// timestamp is ever shifted below zero. Lines that carry a timestamp but no +// text are preserved, because they mark instrumental gaps. +func ParseLRC(raw string) []Line { + if strings.TrimSpace(raw) == "" { + return nil + } + + var ( + lines []Line + offset time.Duration + ) + + for _, rawLine := range strings.Split(raw, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" { + continue + } + + if m := lrcOffsetRe.FindStringSubmatch(line); m != nil { + if ms, err := strconv.Atoi(m[1]); err == nil { + offset = time.Duration(ms) * time.Millisecond + } + continue + } + + stamps, rest := leadingTimestamps(line) + if len(stamps) == 0 { + // Metadata tag ([ar:], [ti:], ...) or untimed filler; skip it. + continue + } + + text := strings.TrimSpace(lrcWordStampRe.ReplaceAllString(rest, "")) + for _, at := range stamps { + lines = append(lines, Line{At: at, Text: text}) + } + } + + if offset != 0 { + for i := range lines { + lines[i].At += offset + if lines[i].At < 0 { + lines[i].At = 0 + } + } + } + + sort.SliceStable(lines, func(i, j int) bool { return lines[i].At < lines[j].At }) + return lines +} + +// leadingTimestamps peels every timestamp off the front of an LRC line and +// returns them together with the remaining lyric text. +func leadingTimestamps(line string) ([]time.Duration, string) { + var stamps []time.Duration + rest := line + + for { + idx := lrcStampRe.FindStringSubmatchIndex(rest) + if idx == nil { + break + } + group := func(n int) string { + if 2*n+1 >= len(idx) || idx[2*n] < 0 { + return "" + } + return rest[idx[2*n]:idx[2*n+1]] + } + + minutes, err := strconv.Atoi(group(1)) + if err != nil { + break + } + seconds, err := strconv.Atoi(group(2)) + if err != nil { + break + } + + at := time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second + at += fractionToDuration(group(3)) + stamps = append(stamps, at) + + rest = strings.TrimLeft(rest[idx[1]:], " \t") + } + + return stamps, rest +} + +// fractionToDuration converts the sub-second digits of a timestamp, which may +// be tenths, hundredths, or milliseconds, into a duration. +func fractionToDuration(frac string) time.Duration { + if frac == "" { + return 0 + } + n, err := strconv.Atoi(frac) + if err != nil { + return 0 + } + switch len(frac) { + case 1: + return time.Duration(n) * 100 * time.Millisecond + case 2: + return time.Duration(n) * 10 * time.Millisecond + default: + return time.Duration(n) * time.Millisecond + } +} + +// plainToLines converts an unsynced lyric blob into untimed lines, trimming +// leading and trailing blank lines. +func plainToLines(raw string) []Line { + if strings.TrimSpace(raw) == "" { + return nil + } + + fields := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") + start, end := 0, len(fields) + for start < end && strings.TrimSpace(fields[start]) == "" { + start++ + } + for end > start && strings.TrimSpace(fields[end-1]) == "" { + end-- + } + if start >= end { + return nil + } + + out := make([]Line, 0, end-start) + for _, f := range fields[start:end] { + out = append(out, Line{Text: strings.TrimSpace(f)}) + } + return out +} diff --git a/pkg/lyrics/lrc_test.go b/pkg/lyrics/lrc_test.go new file mode 100644 index 0000000..34a26a7 --- /dev/null +++ b/pkg/lyrics/lrc_test.go @@ -0,0 +1,214 @@ +package lyrics + +import ( + "reflect" + "testing" + "time" +) + +func ms(n int) time.Duration { return time.Duration(n) * time.Millisecond } + +func TestParseLRC(t *testing.T) { + tests := []struct { + name string + raw string + want []Line + }{ + { + name: "empty input", + raw: "", + want: nil, + }, + { + name: "whitespace only", + raw: " \n\t\n", + want: nil, + }, + { + name: "centiseconds", + raw: "[00:12.34]Hello\n[01:02.50]World", + want: []Line{ + {At: 12*time.Second + ms(340), Text: "Hello"}, + {At: 62*time.Second + ms(500), Text: "World"}, + }, + }, + { + name: "milliseconds", + raw: "[00:01.001]One\n[00:02.010]Two", + want: []Line{ + {At: time.Second + ms(1), Text: "One"}, + {At: 2*time.Second + ms(10), Text: "Two"}, + }, + }, + { + name: "tenths", + raw: "[00:05.5]Half", + want: []Line{{At: 5*time.Second + ms(500), Text: "Half"}}, + }, + { + name: "no fraction", + raw: "[02:03]Plain", + want: []Line{{At: 2*time.Minute + 3*time.Second, Text: "Plain"}}, + }, + { + name: "colon fraction separator", + raw: "[00:09:25]Odd", + want: []Line{{At: 9*time.Second + ms(250), Text: "Odd"}}, + }, + { + name: "multiple timestamps on one line", + raw: "[00:10.00][00:40.00] Chorus", + want: []Line{ + {At: 10 * time.Second, Text: "Chorus"}, + {At: 40 * time.Second, Text: "Chorus"}, + }, + }, + { + name: "metadata tags skipped", + raw: "[ar:Daft Punk]\n[ti:Voyager]\n[al:Discovery]\n[by:someone]\n" + + "[length:03:47]\n[00:00.00]Intro", + want: []Line{{At: 0, Text: "Intro"}}, + }, + { + name: "untimed filler skipped", + raw: "Lyrics by nobody\n[00:03.00]Line", + want: []Line{{At: 3 * time.Second, Text: "Line"}}, + }, + { + name: "positive offset delays lines", + raw: "[offset:+500]\n[00:10.00]A\n[00:20.00]B", + want: []Line{ + {At: 10*time.Second + ms(500), Text: "A"}, + {At: 20*time.Second + ms(500), Text: "B"}, + }, + }, + { + name: "negative offset advances and clamps at zero", + raw: "[offset:-2000]\n[00:01.00]A\n[00:10.00]B", + want: []Line{ + {At: 0, Text: "A"}, + {At: 8 * time.Second, Text: "B"}, + }, + }, + { + name: "trailing offset tag still applies", + raw: "[00:10.00]A\n[offset:1000]", + want: []Line{{At: 11 * time.Second, Text: "A"}}, + }, + { + name: "unsorted input is sorted ascending", + raw: "[00:30.00]Third\n[00:10.00]First\n[00:20.00]Second", + want: []Line{ + {At: 10 * time.Second, Text: "First"}, + {At: 20 * time.Second, Text: "Second"}, + {At: 30 * time.Second, Text: "Third"}, + }, + }, + { + name: "blank timed lines are kept as gaps", + raw: "[00:00.00]\n[00:05.00]Sing", + want: []Line{ + {At: 0, Text: ""}, + {At: 5 * time.Second, Text: "Sing"}, + }, + }, + { + name: "enhanced word timings stripped", + raw: "[00:12.00]<00:12.00>Hey <00:12.50>there", + want: []Line{{At: 12 * time.Second, Text: "Hey there"}}, + }, + { + name: "windows line endings", + raw: "[00:01.00]A\r\n[00:02.00]B\r\n", + want: []Line{ + {At: time.Second, Text: "A"}, + {At: 2 * time.Second, Text: "B"}, + }, + }, + { + name: "long tracks over 99 minutes", + raw: "[100:07.00]Late", + want: []Line{{At: 100*time.Minute + 7*time.Second, Text: "Late"}}, + }, + { + name: "garbage without timestamps", + raw: "not an lrc file at all\nsecond line", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseLRC(tt.raw) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseLRC(%q)\n got: %v\nwant: %v", tt.raw, got, tt.want) + } + }) + } +} + +func TestParseLRCSortedAndMonotonic(t *testing.T) { + raw := "[00:40.00]d\n[00:10.00]a\n[00:10.00]b\n[00:30.00]c" + got := ParseLRC(raw) + if len(got) != 4 { + t.Fatalf("expected 4 lines, got %d", len(got)) + } + for i := 1; i < len(got); i++ { + if got[i].At < got[i-1].At { + t.Fatalf("lines not sorted at %d: %v", i, got) + } + } + // Stable sort keeps the original order of equal timestamps. + if got[0].Text != "a" || got[1].Text != "b" { + t.Errorf("equal timestamps not stably ordered: %v", got) + } +} + +func TestPlainToLines(t *testing.T) { + tests := []struct { + name string + raw string + want []Line + }{ + {name: "empty", raw: "", want: nil}, + {name: "blank", raw: "\n\n \n", want: nil}, + { + name: "trims surrounding blanks and keeps inner blank", + raw: "\n\nfirst\n\nsecond\n\n", + want: []Line{{Text: "first"}, {Text: ""}, {Text: "second"}}, + }, + { + name: "crlf", + raw: "one\r\ntwo", + want: []Line{{Text: "one"}, {Text: "two"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := plainToLines(tt.raw) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("plainToLines(%q)\n got: %v\nwant: %v", tt.raw, got, tt.want) + } + }) + } +} + +func TestFractionToDuration(t *testing.T) { + tests := []struct { + frac string + want time.Duration + }{ + {"", 0}, + {"5", 500 * time.Millisecond}, + {"05", 50 * time.Millisecond}, + {"50", 500 * time.Millisecond}, + {"123", 123 * time.Millisecond}, + {"007", 7 * time.Millisecond}, + } + for _, tt := range tests { + if got := fractionToDuration(tt.frac); got != tt.want { + t.Errorf("fractionToDuration(%q) = %v, want %v", tt.frac, got, tt.want) + } + } +} diff --git a/pkg/lyrics/sheet.go b/pkg/lyrics/sheet.go new file mode 100644 index 0000000..c7deab4 --- /dev/null +++ b/pkg/lyrics/sheet.go @@ -0,0 +1,120 @@ +// Package lyrics implements halpradio's synced-lyrics engine. +// +// It provides LRC parsing, radio stream title splitting, and a lyric Client +// that resolves tracks against LRCLIB with a NetEase fallback, memoising +// results in RAM and on disk. The package depends on the standard library +// only and never panics: every failure path returns an error. +package lyrics + +import ( + "strings" + "time" +) + +// Line is one lyric line with its playback offset. For unsynced sheets At is 0. +type Line struct { + At time.Duration + Text string +} + +// Sheet is a fetched lyric document for one track. +type Sheet struct { + Artist string + Title string + Album string + Duration time.Duration + Synced bool // true when Lines carry real timestamps + Instrumental bool + Lines []Line + Source string // "LRCLIB" or "NetEase" +} + +// ActiveIndex returns the index of the line that should be highlighted at the +// given elapsed playback offset, or -1 when the sheet is unsynced or elapsed +// precedes the first line. +func (s *Sheet) ActiveIndex(elapsed time.Duration) int { + if s == nil || !s.Synced || len(s.Lines) == 0 { + return -1 + } + if elapsed < s.Lines[0].At { + return -1 + } + + // Binary search for the last line whose timestamp is <= elapsed. + lo, hi := 0, len(s.Lines)-1 + for lo < hi { + mid := (lo + hi + 1) / 2 + if s.Lines[mid].At <= elapsed { + lo = mid + } else { + hi = mid - 1 + } + } + return lo +} + +// Progress returns how far (0.0-1.0) playback has advanced through the line at +// index i, or 0 when unknown. +func (s *Sheet) Progress(i int, elapsed time.Duration) float64 { + if s == nil || !s.Synced || i < 0 || i >= len(s.Lines) { + return 0 + } + + start := s.Lines[i].At + var end time.Duration + switch { + case i+1 < len(s.Lines): + end = s.Lines[i+1].At + case s.Duration > start: + end = s.Duration + default: + // Last line of a sheet with unknown total duration. + return 0 + } + + if end <= start || elapsed <= start { + return 0 + } + if elapsed >= end { + return 1 + } + return float64(elapsed-start) / float64(end-start) +} + +// IsEmpty reports whether the sheet carries no renderable lines. +func (s *Sheet) IsEmpty() bool { + if s == nil { + return true + } + for _, l := range s.Lines { + if strings.TrimSpace(l.Text) != "" { + return false + } + } + return true +} + +// PlainLines returns the lyric text without timestamps. +func (s *Sheet) PlainLines() []string { + if s == nil || len(s.Lines) == 0 { + return nil + } + out := make([]string, 0, len(s.Lines)) + for _, l := range s.Lines { + out = append(out, l.Text) + } + return out +} + +// clone returns a deep copy so cached sheets can never be mutated by callers. +func (s *Sheet) clone() *Sheet { + if s == nil { + return nil + } + out := *s + if s.Lines != nil { + out.Lines = make([]Line, len(s.Lines)) + copy(out.Lines, s.Lines) + } + return &out +} diff --git a/pkg/lyrics/sheet_test.go b/pkg/lyrics/sheet_test.go new file mode 100644 index 0000000..7e82ad3 --- /dev/null +++ b/pkg/lyrics/sheet_test.go @@ -0,0 +1,203 @@ +package lyrics + +import ( + "reflect" + "testing" + "time" +) + +func syncedSheet() *Sheet { + return &Sheet{ + Artist: "Daft Punk", + Title: "Voyager", + Duration: 60 * time.Second, + Synced: true, + Source: SourceLRCLib, + Lines: []Line{ + {At: 10 * time.Second, Text: "first"}, + {At: 20 * time.Second, Text: "second"}, + {At: 30 * time.Second, Text: "third"}, + }, + } +} + +func TestSheetActiveIndex(t *testing.T) { + synced := syncedSheet() + unsynced := &Sheet{Lines: []Line{{Text: "a"}, {Text: "b"}}} + empty := &Sheet{Synced: true} + + tests := []struct { + name string + sheet *Sheet + elapsed time.Duration + want int + }{ + {name: "nil sheet", sheet: nil, elapsed: time.Second, want: -1}, + {name: "unsynced sheet", sheet: unsynced, elapsed: time.Second, want: -1}, + {name: "no lines", sheet: empty, elapsed: time.Second, want: -1}, + {name: "before first line", sheet: synced, elapsed: 0, want: -1}, + {name: "just before first line", sheet: synced, elapsed: 9999 * time.Millisecond, want: -1}, + {name: "exactly on first line", sheet: synced, elapsed: 10 * time.Second, want: 0}, + {name: "inside first line", sheet: synced, elapsed: 15 * time.Second, want: 0}, + {name: "exactly on second line", sheet: synced, elapsed: 20 * time.Second, want: 1}, + {name: "inside last line", sheet: synced, elapsed: 35 * time.Second, want: 2}, + {name: "far past the end", sheet: synced, elapsed: time.Hour, want: 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.sheet.ActiveIndex(tt.elapsed); got != tt.want { + t.Errorf("ActiveIndex(%v) = %d, want %d", tt.elapsed, got, tt.want) + } + }) + } +} + +func TestSheetActiveIndexMatchesLinearScan(t *testing.T) { + sheet := &Sheet{Synced: true} + for i := 0; i < 50; i++ { + sheet.Lines = append(sheet.Lines, Line{At: time.Duration(i) * 3 * time.Second}) + } + + for elapsed := time.Duration(0); elapsed < 160*time.Second; elapsed += 500 * time.Millisecond { + want := -1 + for i, l := range sheet.Lines { + if l.At <= elapsed { + want = i + } + } + if got := sheet.ActiveIndex(elapsed); got != want { + t.Fatalf("ActiveIndex(%v) = %d, want %d", elapsed, got, want) + } + } +} + +func TestSheetActiveIndexDuplicateTimestamps(t *testing.T) { + sheet := &Sheet{ + Synced: true, + Lines: []Line{ + {At: 5 * time.Second, Text: "a"}, + {At: 5 * time.Second, Text: "b"}, + {At: 9 * time.Second, Text: "c"}, + }, + } + if got := sheet.ActiveIndex(6 * time.Second); got != 1 { + t.Errorf("ActiveIndex = %d, want 1 (last line sharing the timestamp)", got) + } +} + +func TestSheetProgress(t *testing.T) { + synced := syncedSheet() + noDuration := syncedSheet() + noDuration.Duration = 0 + unsynced := &Sheet{Lines: []Line{{Text: "a"}}} + + tests := []struct { + name string + sheet *Sheet + index int + elapsed time.Duration + want float64 + }{ + {name: "nil sheet", sheet: nil, index: 0, elapsed: time.Second, want: 0}, + {name: "unsynced", sheet: unsynced, index: 0, elapsed: time.Second, want: 0}, + {name: "negative index", sheet: synced, index: -1, elapsed: 15 * time.Second, want: 0}, + {name: "index past end", sheet: synced, index: 99, elapsed: 15 * time.Second, want: 0}, + {name: "before line start", sheet: synced, index: 1, elapsed: 15 * time.Second, want: 0}, + {name: "at line start", sheet: synced, index: 0, elapsed: 10 * time.Second, want: 0}, + {name: "half way", sheet: synced, index: 0, elapsed: 15 * time.Second, want: 0.5}, + {name: "three quarters", sheet: synced, index: 1, elapsed: 27500 * time.Millisecond, want: 0.75}, + {name: "past line end clamps to one", sheet: synced, index: 0, elapsed: 25 * time.Second, want: 1}, + {name: "last line uses sheet duration", sheet: synced, index: 2, elapsed: 45 * time.Second, want: 0.5}, + {name: "last line without duration is unknown", sheet: noDuration, index: 2, elapsed: 45 * time.Second, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.sheet.Progress(tt.index, tt.elapsed) + if diff := got - tt.want; diff > 1e-9 || diff < -1e-9 { + t.Errorf("Progress(%d, %v) = %v, want %v", tt.index, tt.elapsed, got, tt.want) + } + }) + } +} + +func TestSheetProgressStaysInRange(t *testing.T) { + sheet := syncedSheet() + for elapsed := time.Duration(0); elapsed < 90*time.Second; elapsed += time.Second { + for i := range sheet.Lines { + p := sheet.Progress(i, elapsed) + if p < 0 || p > 1 { + t.Fatalf("Progress(%d, %v) = %v out of range", i, elapsed, p) + } + } + } +} + +func TestSheetProgressZeroLengthLine(t *testing.T) { + sheet := &Sheet{ + Synced: true, + Lines: []Line{ + {At: 5 * time.Second, Text: "a"}, + {At: 5 * time.Second, Text: "b"}, + }, + Duration: 30 * time.Second, + } + if got := sheet.Progress(0, 5*time.Second); got != 0 { + t.Errorf("Progress on zero-length line = %v, want 0", got) + } +} + +func TestSheetIsEmpty(t *testing.T) { + tests := []struct { + name string + sheet *Sheet + want bool + }{ + {name: "nil", sheet: nil, want: true}, + {name: "no lines", sheet: &Sheet{}, want: true}, + {name: "only blank lines", sheet: &Sheet{Lines: []Line{{Text: ""}, {Text: " "}}}, want: true}, + {name: "has text", sheet: &Sheet{Lines: []Line{{Text: ""}, {Text: "hi"}}}, want: false}, + {name: "instrumental marker", sheet: &Sheet{Lines: []Line{{Text: InstrumentalMarker}}}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.sheet.IsEmpty(); got != tt.want { + t.Errorf("IsEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSheetPlainLines(t *testing.T) { + if got := (*Sheet)(nil).PlainLines(); got != nil { + t.Errorf("nil sheet PlainLines = %v, want nil", got) + } + if got := (&Sheet{}).PlainLines(); got != nil { + t.Errorf("empty sheet PlainLines = %v, want nil", got) + } + + sheet := syncedSheet() + want := []string{"first", "second", "third"} + if got := sheet.PlainLines(); !reflect.DeepEqual(got, want) { + t.Errorf("PlainLines = %v, want %v", got, want) + } +} + +func TestSheetClone(t *testing.T) { + if (*Sheet)(nil).clone() != nil { + t.Fatal("clone of nil sheet should be nil") + } + + orig := syncedSheet() + copied := orig.clone() + copied.Lines[0].Text = "mutated" + copied.Title = "other" + + if orig.Lines[0].Text != "first" { + t.Error("clone shares the Lines backing array") + } + if orig.Title != "Voyager" { + t.Error("clone shares scalar fields") + } +} diff --git a/pkg/lyrics/title.go b/pkg/lyrics/title.go new file mode 100644 index 0000000..dfe87ca --- /dev/null +++ b/pkg/lyrics/title.go @@ -0,0 +1,261 @@ +package lyrics + +import ( + "fmt" + "html" + "regexp" + "strings" + "time" + "unicode" +) + +var ( + // "Now Playing: ...", "*** CURRENT TRACK *** ..." and friends. + bannerPrefixRe = regexp.MustCompile(`(?i)^[\s*#~_=♫♪▶►|•\-]*\b(now\s+playing|currently\s+playing|current\s+track|on\s+air|playing\s+now)\b[\s*#~_=♫♪▶►|•]*[:\-–—]?\s*`) + + decorPrefixRe = regexp.MustCompile(`^[\s*#~_=♫♪▶►•]+`) + decorSuffixRe = regexp.MustCompile(`[\s*#~_=♫♪▶►•]+$`) + + // Advert, jingle and station-liner markers. A title containing one of these + // carries no track information. + adMarkerRe = regexp.MustCompile(`(?i)\b(advert(is(e|ing|ement))?s?|commercial\s+break|ad\s+break|adbreak|jingle|station\s+id|sponsored\s+by|buy\s+ads?\s+at|you(\s+are|'re)\s+listening\s+to|tune\s+in(\s+to)?|stay\s+tuned|we('ll|\s+will)\s+be\s+right\s+back|sweeper|promo(tion)?al\s+spot|news\s+bulletin|traffic\s+(report|and\s+weather)|weather\s+update)\b`) + + urlNoiseRe = regexp.MustCompile(`(?i)(https?://\S+|www\.[^\s|]+)`) + + // Whole-string values that carry no usable metadata. + placeholderRe = regexp.MustCompile(`(?i)^(unknown(\s+(artist|track|title))?|no\s+(artist|title)|not\s+available|n/?a|none|various\s+artists|untitled|unnamed|track\s*\d*|audio\s*track|song|artist|title|default)$`) + + // YouTube style channel suffix. + topicSuffixRe = regexp.MustCompile(`(?i)\s*[-–—]\s*topic\s*$`) + + // Trailing " | Some Station FM" / " / Some Station". + stationSuffixRe = regexp.MustCompile(`\s*\|[^|]*$`) + + // Any square-bracketed chunk: "[HQ]", "[128kbps]", "[Official Video]". + bracketRe = regexp.MustCompile(`\s*\[[^\]]*\]`) + + // Parenthesised marketing noise. Musically meaningful parentheticals such + // as "(Remix)" or "(Live at Wembley)" are deliberately kept. + noiseParenRe = regexp.MustCompile(`(?i)\s*\([^()]*\b(official(\s+\w+)*|lyrics?(\s+video)?|music\s+video|video|audio|visuali[sz]er|hd|hq|4k|full\s+album|free\s+download|explicit|clean\s+version|radio\s+edit|remaster(ed)?|\d{4}\s+remaster(ed)?|stream\s+version)\b[^()]*\)`) + + // "(feat. X)" / "[ft. X]" anywhere, and a bare "feat. X" tail. + featParenRe = regexp.MustCompile(`(?i)\s*[\(\[]\s*(feat|ft|featuring|w/)\.?\s*[^)\]]*[\)\]]`) + featBareRe = regexp.MustCompile(`(?i)\s+(feat|ft|featuring)\.?\s+.*$`) + + // "- Remastered 2011", "- 2011 Remaster". + remasterTailRe = regexp.MustCompile(`(?i)\s*[-–—]\s*((19|20)\d{2}\s+)?remaster(ed)?(\s+version)?(\s+(19|20)\d{2})?\s*$`) + + // A trailing release year, bare or bracketed. + yearTailRe = regexp.MustCompile(`\s*[\(\[]?((19|20)\d{2})[\)\]]?\s*$`) + + whitespaceRe = regexp.MustCompile(`\s+`) + + // Playlist track numbers: "01. Artist - Title", "3) Artist - Title", + // "01 - Artist - Title". + trackNumberPrefixRe = regexp.MustCompile(`^\d{1,3}\s*[.)]\s+|^\d{1,3}\s+[-–—]\s+`) + + // Separators between artist and title, longest/most explicit first. + dashSeparators = []string{" -- ", " - ", " – ", " — ", " ‐ ", " ‑ ", " − ", " : "} + + // Unspaced typographic dashes and double hyphens are safe to split on; a + // bare ASCII hyphen is not, so it is only used as a last resort in + // splitOnDash. + tightDashSeparators = []string{"--", "–", "—"} +) + +// SplitTrackTitle splits a raw radio stream title into artist and title. +// Handles "Artist - Title", "Artist – Title", "Artist — Title", "Title by Artist". +// It strips common station noise: leading/trailing whitespace, wrapping quotes, +// bracketed suffixes like "(Official Video)", "[HQ]", " - Topic", trailing +// " | StationName", and advert/jingle markers. Returns empty strings when the +// input is unusable (e.g. just a station name or an ad slug). +// +// The "Title by Artist" form is only recognised for a lower-case " by ", so +// that Title-Cased song names such as "Stand By Me" are not mis-split. +func SplitTrackTitle(raw string) (artist, title string) { + cleaned := cleanStreamTitle(raw) + if cleaned == "" { + return "", "" + } + + a, t, ok := splitOnDash(cleaned) + if !ok { + a, t, ok = splitOnBy(cleaned) + } + if !ok { + return "", "" + } + + a = finishPart(a) + t = finishPart(t) + if isUnusablePart(a) || isUnusablePart(t) { + return "", "" + } + return a, t +} + +// cleanStreamTitle removes station noise from a raw stream title and returns an +// empty string when nothing usable is left. +func cleanStreamTitle(raw string) string { + s := strings.TrimSpace(raw) + if s == "" { + return "" + } + + s = html.UnescapeString(s) + s = stripUnprintable(s) + + if adMarkerRe.MatchString(s) { + return "" + } + + s = bannerPrefixRe.ReplaceAllString(s, "") + s = urlNoiseRe.ReplaceAllString(s, " ") + s = topicSuffixRe.ReplaceAllString(s, "") + s = stationSuffixRe.ReplaceAllString(s, "") + s = bracketRe.ReplaceAllString(s, "") + s = noiseParenRe.ReplaceAllString(s, "") + s = decorPrefixRe.ReplaceAllString(s, "") + s = decorSuffixRe.ReplaceAllString(s, "") + s = unwrapQuotes(s) + s = trackNumberPrefixRe.ReplaceAllString(collapseSpace(s), "") + + return collapseSpace(s) +} + +// stripUnprintable drops control and non-printable runes that stations +// occasionally emit inside ICY metadata. +func stripUnprintable(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == '\t' || r == ' ' { + b.WriteRune(' ') + continue + } + if r < 32 || r == 127 || !unicode.IsPrint(r) { + continue + } + b.WriteRune(r) + } + return strings.TrimSpace(b.String()) +} + +func collapseSpace(s string) string { + return strings.TrimSpace(whitespaceRe.ReplaceAllString(s, " ")) +} + +// unwrapQuotes removes a single matching pair of wrapping quotes. +func unwrapQuotes(s string) string { + s = strings.TrimSpace(s) + pairs := [][2]string{{`"`, `"`}, {"'", "'"}, {"“", "”"}, {"‘", "’"}, {"«", "»"}} + for _, p := range pairs { + if len(s) > len(p[0])+len(p[1]) && strings.HasPrefix(s, p[0]) && strings.HasSuffix(s, p[1]) { + return strings.TrimSpace(s[len(p[0]) : len(s)-len(p[1])]) + } + } + return s +} + +// splitOnDash splits at the earliest artist/title separator in s. When several +// separators start at the same offset the longest one wins. +func splitOnDash(s string) (string, string, bool) { + for _, group := range [][]string{dashSeparators, tightDashSeparators} { + at, sep := -1, "" + for _, cand := range group { + i := strings.Index(s, cand) + if i <= 0 || i+len(cand) >= len(s) { + continue + } + if at < 0 || i < at || (i == at && len(cand) > len(sep)) { + at, sep = i, cand + } + } + if at > 0 { + return s[:at], s[at+len(sep):], true + } + } + + // Last resort: a single unspaced ASCII hyphen ("Artist-Title"). + if strings.Count(s, "-") == 1 { + if i := strings.Index(s, "-"); i > 0 && i+1 < len(s) { + return s[:i], s[i+1:], true + } + } + return "", "", false +} + +// splitOnBy handles the "Title by Artist" form. +func splitOnBy(s string) (string, string, bool) { + const sep = " by " + i := strings.LastIndex(s, sep) + if i <= 0 || i+len(sep) >= len(s) { + return "", "", false + } + return s[i+len(sep):], s[:i], true +} + +// finishPart tidies one half of a split title. +func finishPart(s string) string { + s = collapseSpace(s) + s = unwrapQuotes(s) + s = strings.Trim(s, " \t,;:-–—*_|") + return collapseSpace(s) +} + +// isUnusablePart reports whether a split half carries no track information. +func isUnusablePart(s string) bool { + if s == "" { + return true + } + if placeholderRe.MatchString(s) || adMarkerRe.MatchString(s) { + return true + } + for _, r := range s { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return false + } + } + return true +} + +// normalizeQuery strips featured-artist annotations, marketing suffixes, +// bracketed noise, remaster tails and trailing years so provider lookups and +// cache keys stay stable. The caller's original strings are never altered. +func normalizeQuery(s string) string { + out := collapseSpace(stripUnprintable(html.UnescapeString(s))) + if out == "" { + return "" + } + + out = bracketRe.ReplaceAllString(out, "") + out = featParenRe.ReplaceAllString(out, "") + out = featBareRe.ReplaceAllString(out, "") + out = noiseParenRe.ReplaceAllString(out, "") + out = remasterTailRe.ReplaceAllString(out, "") + + // Only drop a trailing year when something survives it, so that titles + // which are themselves a year (e.g. "1999") stay intact. + if trimmed := strings.TrimSpace(yearTailRe.ReplaceAllString(out, "")); trimmed != "" { + out = trimmed + } + + out = strings.Trim(collapseSpace(out), " \t.,;:-–—*_|") + return collapseSpace(out) +} + +// normalizeKey lower-cases a normalised value for use inside cache keys. +func normalizeKey(s string) string { + return strings.ToLower(normalizeQuery(s)) +} + +// cacheKey builds the stable identity of a lyric lookup. It never contains raw +// user text in a form that could escape a directory, because callers hash it. +func cacheKey(artist, title, album string, duration time.Duration) string { + secs := int64(0) + if duration > 0 { + secs = int64(duration.Round(time.Second) / time.Second) + } + return fmt.Sprintf("%s|%s|%s|%d", + normalizeKey(artist), normalizeKey(title), normalizeKey(album), secs) +} diff --git a/pkg/lyrics/title_test.go b/pkg/lyrics/title_test.go new file mode 100644 index 0000000..498385f --- /dev/null +++ b/pkg/lyrics/title_test.go @@ -0,0 +1,237 @@ +package lyrics + +import ( + "strings" + "testing" + "time" +) + +func TestSplitTrackTitle(t *testing.T) { + tests := []struct { + name string + raw string + wantArtist string + wantTitle string + }{ + {name: "empty", raw: "", wantArtist: "", wantTitle: ""}, + {name: "whitespace only", raw: " \t ", wantArtist: "", wantTitle: ""}, + {name: "plain hyphen", raw: "Daft Punk - Voyager", wantArtist: "Daft Punk", wantTitle: "Voyager"}, + {name: "extra whitespace", raw: " Daft Punk - Voyager ", wantArtist: "Daft Punk", wantTitle: "Voyager"}, + {name: "en dash", raw: "Daft Punk – Voyager", wantArtist: "Daft Punk", wantTitle: "Voyager"}, + {name: "em dash", raw: "Daft Punk — Voyager", wantArtist: "Daft Punk", wantTitle: "Voyager"}, + {name: "unspaced double hyphen", raw: "Daft Punk--Voyager", wantArtist: "Daft Punk", wantTitle: "Voyager"}, + {name: "unspaced hyphen", raw: "Daft Punk-Voyager", wantArtist: "Daft Punk", wantTitle: "Voyager"}, + {name: "colon separator", raw: "Kraftwerk : Autobahn", wantArtist: "Kraftwerk", wantTitle: "Autobahn"}, + {name: "title by artist", raw: "Voyager by Daft Punk", wantArtist: "Daft Punk", wantTitle: "Voyager"}, + { + name: "title cased By is not a separator", raw: "Stand By Me", + wantArtist: "", wantTitle: "", + }, + {name: "station name only", raw: "Radio Paradise", wantArtist: "", wantTitle: ""}, + { + name: "now playing banner", raw: "Now Playing: Daft Punk - Voyager", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "decorated banner", raw: "*** NOW PLAYING *** Daft Punk - Voyager ***", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "official video suffix", raw: "Daft Punk - Voyager (Official Video)", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "bracketed suffix", raw: "Daft Punk - Voyager [HQ]", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "bitrate bracket", raw: "DJ Shadow - Midnight In A Perfect World [128kbps]", + wantArtist: "DJ Shadow", wantTitle: "Midnight In A Perfect World", + }, + {name: "topic channel suffix", raw: "Daft Punk - Topic", wantArtist: "", wantTitle: ""}, + { + name: "trailing station pipe", raw: "Daft Punk - Voyager | Radio X FM", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "quoted title", raw: `Daft Punk - "Voyager"`, + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "wrapping quotes", raw: `"Daft Punk - Voyager"`, + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "curly quotes", raw: "“Daft Punk - Voyager”", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "html entities", raw: "Simon & Garfunkel - The Sound of Silence", + wantArtist: "Simon & Garfunkel", wantTitle: "The Sound of Silence", + }, + { + name: "playlist track number", raw: "01. Daft Punk - Voyager", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + { + name: "playlist track number with dash", raw: "03 - Daft Punk - Voyager", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + {name: "advert", raw: "Advertisement", wantArtist: "", wantTitle: ""}, + {name: "commercial break", raw: "Commercial Break", wantArtist: "", wantTitle: ""}, + {name: "station liner", raw: "You're listening to Radio X", wantArtist: "", wantTitle: ""}, + {name: "ad slug", raw: "buy ads at adsite.com - listen now", wantArtist: "", wantTitle: ""}, + {name: "jingle", raw: "Jingle - Radio X", wantArtist: "", wantTitle: ""}, + {name: "url only", raw: "http://radio.example.com", wantArtist: "", wantTitle: ""}, + {name: "unknown placeholders", raw: "Unknown - Unknown", wantArtist: "", wantTitle: ""}, + {name: "unknown artist track number", raw: "Unknown Artist - Track 01", wantArtist: "", wantTitle: ""}, + {name: "various artists", raw: "Various Artists - Compilation", wantArtist: "", wantTitle: ""}, + {name: "punctuation only", raw: "- - -", wantArtist: "", wantTitle: ""}, + {name: "slash in artist", raw: "AC/DC - Thunderstruck", wantArtist: "AC/DC", wantTitle: "Thunderstruck"}, + {name: "hyphenated artist", raw: "Jay-Z - 99 Problems", wantArtist: "Jay-Z", wantTitle: "99 Problems"}, + { + name: "plus in artist", raw: "Florence + The Machine - Dog Days Are Over (Official)", + wantArtist: "Florence + The Machine", wantTitle: "Dog Days Are Over", + }, + { + name: "trailing period kept", raw: "Bruce Springsteen - Born in the U.S.A.", + wantArtist: "Bruce Springsteen", wantTitle: "Born in the U.S.A.", + }, + { + name: "feature kept in the raw title", raw: "Beyoncé - Halo (feat. Jay-Z)", + wantArtist: "Beyoncé", wantTitle: "Halo (feat. Jay-Z)", + }, + { + name: "third segment stays with the title", raw: "Daft Punk - Voyager - Discovery", + wantArtist: "Daft Punk", wantTitle: "Voyager - Discovery", + }, + { + name: "numeric title is not a placeholder", raw: "Prince - 1999", + wantArtist: "Prince", wantTitle: "1999", + }, + { + name: "control characters", raw: "Daft Punk - Voyager\x00\x07", + wantArtist: "Daft Punk", wantTitle: "Voyager", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + artist, title := SplitTrackTitle(tt.raw) + if artist != tt.wantArtist || title != tt.wantTitle { + t.Errorf("SplitTrackTitle(%q) = (%q, %q), want (%q, %q)", + tt.raw, artist, title, tt.wantArtist, tt.wantTitle) + } + }) + } +} + +func TestSplitTrackTitleNeverPartial(t *testing.T) { + // Either both halves are populated or both are empty. + inputs := []string{ + "", "x", "Radio", "- Title", "Artist -", "Now Playing:", "|||", "a - b", + "Artist - ", " - Title", "feat. Someone", "??? - ???", + } + for _, in := range inputs { + artist, title := SplitTrackTitle(in) + if (artist == "") != (title == "") { + t.Errorf("SplitTrackTitle(%q) returned a partial result (%q, %q)", in, artist, title) + } + } +} + +func TestNormalizeQuery(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "empty", in: "", want: ""}, + {name: "whitespace collapsed", in: " Daft Punk\t ", want: "Daft Punk"}, + {name: "feat in parens", in: "Halo (feat. Jay-Z)", want: "Halo"}, + {name: "ft in brackets", in: "Halo [ft. Jay-Z]", want: "Halo"}, + {name: "bare feat tail", in: "Track feat. Someone", want: "Track"}, + {name: "bare ft tail", in: "Track ft. Someone", want: "Track"}, + {name: "official video", in: "Song Title (Official Music Video)", want: "Song Title"}, + {name: "explicit bracket", in: "Song [Explicit]", want: "Song"}, + {name: "remaster paren", in: "Thriller (2001 Remaster)", want: "Thriller"}, + {name: "remaster tail", in: "Nothing Else Matters - Remastered 2021", want: "Nothing Else Matters"}, + {name: "trailing year", in: "Blue Monday 1988", want: "Blue Monday"}, + {name: "bracketed trailing year", in: "Blue Monday (1988)", want: "Blue Monday"}, + {name: "title that is only a year is kept", in: "1999", want: "1999"}, + {name: "numeric title kept", in: "2112", want: "2112"}, + {name: "remix parenthetical kept", in: "Around the World (Remix)", want: "Around the World (Remix)"}, + {name: "live parenthetical kept", in: "Song (Live at Wembley)", want: "Song (Live at Wembley)"}, + {name: "html entity", in: "Simon & Garfunkel", want: "Simon & Garfunkel"}, + {name: "already clean", in: "Discovery", want: "Discovery"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeQuery(tt.in); got != tt.want { + t.Errorf("normalizeQuery(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNormalizeKeyIsLowercase(t *testing.T) { + got := normalizeKey("DAFT PUNK (Official Video)") + if got != "daft punk" { + t.Errorf("normalizeKey = %q, want %q", got, "daft punk") + } +} + +func TestCacheKey(t *testing.T) { + base := cacheKey("Daft Punk", "Voyager", "Discovery", 227*time.Second) + + if !strings.Contains(base, "daft punk") || !strings.Contains(base, "voyager") { + t.Fatalf("cache key %q does not contain normalised metadata", base) + } + if !strings.HasSuffix(base, "|227") { + t.Errorf("cache key %q should end with the duration in seconds", base) + } + + tests := []struct { + name string + key string + equal bool + }{ + {name: "identical", key: cacheKey("Daft Punk", "Voyager", "Discovery", 227*time.Second), equal: true}, + {name: "case insensitive", key: cacheKey("DAFT PUNK", "voyager", "DISCOVERY", 227*time.Second), equal: true}, + {name: "noise insensitive", key: cacheKey("Daft Punk", "Voyager (Official Video)", "Discovery", 227*time.Second), equal: true}, + {name: "rounds sub-second durations", key: cacheKey("Daft Punk", "Voyager", "Discovery", 227*time.Second+400*time.Millisecond), equal: true}, + {name: "different title", key: cacheKey("Daft Punk", "Aerodynamic", "Discovery", 227*time.Second), equal: false}, + {name: "different duration", key: cacheKey("Daft Punk", "Voyager", "Discovery", 300*time.Second), equal: false}, + {name: "different album", key: cacheKey("Daft Punk", "Voyager", "Homework", 227*time.Second), equal: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if (tt.key == base) != tt.equal { + t.Errorf("cacheKey %q vs %q: equal=%v, want %v", tt.key, base, tt.key == base, tt.equal) + } + }) + } + + if zero := cacheKey("", "", "", 0); zero != "|||0" { + t.Errorf("cacheKey of empty metadata = %q, want %q", zero, "|||0") + } +} + +func TestUnwrapQuotes(t *testing.T) { + tests := []struct{ in, want string }{ + {`"quoted"`, "quoted"}, + {"'quoted'", "quoted"}, + {"“quoted”", "quoted"}, + {"«quoted»", "quoted"}, + {`"unbalanced`, `"unbalanced`}, + {`""`, `""`}, + {"plain", "plain"}, + } + for _, tt := range tests { + if got := unwrapQuotes(tt.in); got != tt.want { + t.Errorf("unwrapQuotes(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/pkg/ui/components/art.go b/pkg/ui/components/art.go new file mode 100644 index 0000000..5affcf5 --- /dev/null +++ b/pkg/ui/components/art.go @@ -0,0 +1,106 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/halpworld/halpradio/pkg/art" + "github.com/halpworld/halpradio/pkg/theme" +) + +// AlbumArtModalInput bundles the state the album art modal renders. +type AlbumArtModalInput struct { + Cover *art.Cover + Lines []string + Status string + Fetching bool + Protocol art.Protocol + TrackLabel string + Width int + Height int +} + +// RenderAlbumArtModal draws the floating cover art viewer opened with A. The +// artwork lines are rasterised in the update loop, so this stays a pure view. +func RenderAlbumArtModal(in AlbumArtModalInput, th theme.Theme) string { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(th.Primary).Align(lipgloss.Center) + trackStyle := lipgloss.NewStyle().Bold(true).Foreground(th.Foreground) + metaStyle := lipgloss.NewStyle().Foreground(th.Secondary) + protoStyle := lipgloss.NewStyle().Foreground(th.BadgeText).Background(th.Badge).Bold(true).Padding(0, 1) + hintStyle := lipgloss.NewStyle().Foreground(th.Muted) + infoStyle := lipgloss.NewStyle().Foreground(th.Highlight).Italic(true) + + artWidth := 0 + for _, line := range in.Lines { + if w := lipgloss.Width(line); w > artWidth { + artWidth = w + } + } + + boxWidth := artWidth + 6 + if boxWidth < 44 { + boxWidth = 44 + } + if boxWidth > in.Width-4 { + boxWidth = in.Width - 4 + } + if boxWidth < 24 { + boxWidth = 24 + } + innerW := boxWidth - 4 + + parts := []string{titleStyle.Width(innerW).Render("🖼 ALBUM ART"), ""} + + if len(in.Lines) > 0 { + for _, line := range in.Lines { + parts = append(parts, centerLine(line, innerW)) + } + } else { + msg := in.Status + if msg == "" { + if in.Fetching { + msg = "Fetching cover art…" + } else { + msg = "No cover art available for this track" + } + } + for _, row := range wrapPlain(msg, innerW) { + parts = append(parts, infoStyle.Render(row)) + } + } + + parts = append(parts, "") + if in.TrackLabel != "" { + parts = append(parts, trackStyle.Render(truncate(in.TrackLabel, innerW))) + } + if in.Cover != nil { + meta := in.Cover.Source + if in.Cover.Album != "" { + meta = fmt.Sprintf("%s • %s", in.Cover.Album, in.Cover.Source) + } + parts = append(parts, metaStyle.Render(truncate(meta, innerW))) + } + + badge := protoStyle.Render(in.Protocol.Label()) + parts = append(parts, "", badge) + hint := "[ A ] / [ Esc ] close · [ L ] lyrics" + if innerW < lipgloss.Width(hint) { + hint = "[ Esc ] close" + } + parts = append(parts, hintStyle.Render(hint)) + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(th.Border). + Padding(1, 1). + Width(boxWidth) + + return lipgloss.Place( + in.Width, + in.Height, + lipgloss.Center, + lipgloss.Center, + boxStyle.Render(strings.Join(parts, "\n")), + ) +} diff --git a/pkg/ui/components/art_test.go b/pkg/ui/components/art_test.go new file mode 100644 index 0000000..54c0afd --- /dev/null +++ b/pkg/ui/components/art_test.go @@ -0,0 +1,81 @@ +package components + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/halpworld/halpradio/pkg/art" + "github.com/halpworld/halpradio/pkg/theme" +) + +func TestRenderAlbumArtModal_WithArtwork(t *testing.T) { + lines := []string{ + strings.Repeat("▀", 24), + strings.Repeat("▀", 24), + strings.Repeat("▀", 24), + } + in := AlbumArtModalInput{ + Cover: &art.Cover{Source: "iTunes", Album: "Dive", Artist: "Tycho", Title: "A Walk"}, + Lines: lines, + Protocol: art.ProtocolHalfBlock, + TrackLabel: "Tycho - A Walk", + Width: 100, + Height: 30, + } + out := RenderAlbumArtModal(in, theme.GetTheme("tokyonight")) + + if !strings.Contains(out, "ALBUM ART") { + t.Errorf("expected the modal title, got:\n%s", out) + } + if !strings.Contains(out, "Tycho - A Walk") { + t.Errorf("expected the track label, got:\n%s", out) + } + if !strings.Contains(out, "Dive") || !strings.Contains(out, "iTunes") { + t.Errorf("expected album and provider metadata, got:\n%s", out) + } + if !strings.Contains(out, art.ProtocolHalfBlock.Label()) { + t.Errorf("expected the protocol badge, got:\n%s", out) + } + if got := lipgloss.Width(out); got != 100 { + t.Errorf("modal placed at %d columns, want 100", got) + } + if got := lipgloss.Height(out); got != 30 { + t.Errorf("modal placed at %d rows, want 30", got) + } +} + +func TestRenderAlbumArtModal_EmptyStates(t *testing.T) { + th := theme.GetTheme("nord") + + fetching := RenderAlbumArtModal(AlbumArtModalInput{ + Fetching: true, + Protocol: art.ProtocolKitty, + Width: 90, + Height: 28, + }, th) + if !strings.Contains(fetching, "Fetching cover art") { + t.Errorf("expected a fetching hint, got:\n%s", fetching) + } + + missing := RenderAlbumArtModal(AlbumArtModalInput{ + Status: "No cover art found for this track", + Protocol: art.ProtocolBraille, + Width: 90, + Height: 28, + }, th) + if !strings.Contains(missing, "No cover art found") { + t.Errorf("expected the status message, got:\n%s", missing) + } +} + +func TestRenderAlbumArtModal_NarrowTerminal(t *testing.T) { + out := RenderAlbumArtModal(AlbumArtModalInput{ + Protocol: art.ProtocolHalfBlock, + Width: 40, + Height: 12, + }, theme.GetTheme("catppuccin")) + if got := lipgloss.Width(out); got != 40 { + t.Errorf("modal placed at %d columns, want 40", got) + } +} diff --git a/pkg/ui/components/lyrics.go b/pkg/ui/components/lyrics.go new file mode 100644 index 0000000..155b33c --- /dev/null +++ b/pkg/ui/components/lyrics.go @@ -0,0 +1,397 @@ +package components + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/halpworld/halpradio/pkg/lyrics" + "github.com/halpworld/halpradio/pkg/theme" +) + +// LyricsDrawerInput bundles everything the lyrics drawer renders. It is passed +// by value so the view stays a pure function of model state. +type LyricsDrawerInput struct { + Sheet *lyrics.Sheet + Status string + Fetching bool + TrackLabel string + ArtLines []string + ArtCols int + ArtSource string + Elapsed time.Duration + Offset time.Duration + Scroll int + Focused bool + Width int + Height int +} + +// displayRow is one wrapped screen row of a lyric sheet, tracking which source +// line it came from so the active line can still be located after wrapping. +type displayRow struct { + srcIdx int + text string + cont bool + last bool +} + +// RenderLyricsDrawer draws the side drawer holding album art and the live +// lyric sheet. Synced sheets auto-scroll around the active line; unsynced +// sheets scroll manually from in.Scroll. +func RenderLyricsDrawer(in LyricsDrawerInput, th theme.Theme) string { + borderColor := th.Border + if in.Focused { + borderColor = th.Primary + } + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1). + Width(in.Width - 2). + Height(in.Height - 2) + + innerW := in.Width - 4 + if innerW < 10 { + innerW = 10 + } + + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(th.Primary) + mutedStyle := lipgloss.NewStyle().Foreground(th.Muted) + trackStyle := lipgloss.NewStyle().Bold(true).Foreground(th.Secondary) + + var head []string + head = append(head, titleStyle.Render(truncate("📜 LIVE LYRICS", innerW))) + + if len(in.ArtLines) > 0 { + head = append(head, "") + for _, line := range in.ArtLines { + head = append(head, centerLine(line, innerW)) + } + if in.ArtSource != "" { + head = append(head, centerLine(mutedStyle.Render(truncate("🖼 "+in.ArtSource, innerW)), innerW)) + } + } + + if in.TrackLabel != "" { + head = append(head, "") + head = append(head, trackStyle.Render(truncate(in.TrackLabel, innerW))) + } + + foot := lyricsFooter(in, innerW, th) + + // Everything left over after the header and footer belongs to the sheet. + bodyHeight := (in.Height - 2) - len(head) - len(foot) + if bodyHeight < 1 { + bodyHeight = 1 + } + + body := renderLyricsBody(in, innerW, bodyHeight, th) + + all := append([]string{}, head...) + all = append(all, body...) + all = append(all, foot...) + return boxStyle.Render(strings.Join(all, "\n")) +} + +// renderLyricsBody produces exactly height rows of lyric text. +func renderLyricsBody(in LyricsDrawerInput, width, height int, th theme.Theme) []string { + activeStyle := lipgloss.NewStyle().Bold(true).Foreground(th.Playing) + nearStyle := lipgloss.NewStyle().Foreground(th.Foreground) + farStyle := lipgloss.NewStyle().Foreground(th.Muted) + infoStyle := lipgloss.NewStyle().Foreground(th.Highlight).Italic(true) + + if in.Sheet == nil || in.Sheet.IsEmpty() { + msg := in.Status + if msg == "" { + if in.Fetching { + msg = "Searching for lyrics…" + } else { + msg = "No lyrics loaded" + } + } + rows := wrapPlain(msg, width) + out := make([]string, 0, height) + pad := (height - len(rows)) / 2 + for i := 0; i < pad && len(out) < height; i++ { + out = append(out, "") + } + for _, r := range rows { + if len(out) >= height { + break + } + out = append(out, infoStyle.Render(r)) + } + for len(out) < height { + out = append(out, "") + } + return out + } + + // Reserve the last row for the line progress gauge on synced sheets. + gaugeRows := 0 + if in.Sheet.Synced && height >= 4 { + gaugeRows = 2 + } + textHeight := height - gaugeRows + if textHeight < 1 { + textHeight = 1 + gaugeRows = height - 1 + if gaugeRows < 0 { + gaugeRows = 0 + } + } + + // The active-line marker costs two columns on each side. + rows := wrapSheet(in.Sheet, width-4) + if len(rows) == 0 { + return make([]string, height) + } + + activeIdx := -1 + if in.Sheet.Synced { + activeIdx = in.Sheet.ActiveIndex(in.Elapsed) + } + + anchor := 0 + if activeIdx >= 0 { + anchor = firstRowFor(rows, activeIdx) + } else { + anchor = firstRowFor(rows, in.Scroll) + } + + start := anchor - textHeight/2 + if in.Sheet.Synced { + // Keep the active line a third of the way down so upcoming lines + // stay visible. + start = anchor - textHeight/3 + } else { + start = anchor + } + if start > len(rows)-textHeight { + start = len(rows) - textHeight + } + if start < 0 { + start = 0 + } + + out := make([]string, 0, height) + for i := 0; i < textHeight; i++ { + idx := start + i + if idx < 0 || idx >= len(rows) { + out = append(out, "") + continue + } + row := rows[idx] + switch { + case activeIdx >= 0 && row.srcIdx == activeIdx: + marker := "► " + if row.cont { + marker = " " + } + tail := "" + if row.last { + tail = " ◄" + } + out = append(out, activeStyle.Render(marker+row.text+tail)) + case activeIdx >= 0 && absInt(row.srcIdx-activeIdx) == 1: + out = append(out, " "+nearStyle.Render(row.text)) + default: + out = append(out, " "+farStyle.Render(row.text)) + } + } + + if gaugeRows > 0 { + out = append(out, "") + progress := 0.0 + if activeIdx >= 0 { + progress = in.Sheet.Progress(activeIdx, in.Elapsed) + } + out = append(out, lyricGauge(progress, width, th)) + for len(out) < height { + out = append(out, "") + } + } + for len(out) < height { + out = append(out, "") + } + return out[:height] +} + +// lyricGauge draws the elapsed share of the active lyric line. +func lyricGauge(progress float64, width int, th theme.Theme) string { + if width < 4 { + return "" + } + if progress < 0 { + progress = 0 + } + if progress > 1 { + progress = 1 + } + filled := int(progress * float64(width)) + if filled > width { + filled = width + } + on := lipgloss.NewStyle().Foreground(th.Playing) + off := lipgloss.NewStyle().Foreground(th.Border) + return on.Render(strings.Repeat("━", filled)) + off.Render(strings.Repeat("─", width-filled)) +} + +// lyricsFooter renders the provenance and key hints at the base of the drawer. +func lyricsFooter(in LyricsDrawerInput, width int, th theme.Theme) []string { + sourceStyle := lipgloss.NewStyle().Foreground(th.Highlight) + hintStyle := lipgloss.NewStyle().Foreground(th.Muted) + + var provenance string + switch { + case in.Sheet != nil && in.Sheet.Instrumental: + provenance = fmt.Sprintf("🎼 Instrumental • %s", in.Sheet.Source) + case in.Sheet != nil && in.Sheet.Synced: + provenance = fmt.Sprintf("⏱ Synced via %s", in.Sheet.Source) + if in.Offset != 0 { + provenance += fmt.Sprintf(" (%s)", signedSeconds(in.Offset)) + } + case in.Sheet != nil: + provenance = fmt.Sprintf("📄 Unsynced via %s", in.Sheet.Source) + case in.Fetching: + provenance = "⟳ Querying LRCLIB…" + default: + provenance = "— no sheet —" + } + + hint := "L close · , . sync" + if in.Sheet != nil && !in.Sheet.Synced { + hint = "j/k scroll · L close" + } + + return []string{ + "", + sourceStyle.Render(truncate(provenance, width)), + hintStyle.Render(truncate(hint, width)), + } +} + +// signedSeconds formats a sync offset with an explicit sign. +func signedSeconds(d time.Duration) string { + secs := d.Round(100 * time.Millisecond).Seconds() + if secs >= 0 { + return fmt.Sprintf("+%.1fs", secs) + } + return fmt.Sprintf("%.1fs", secs) +} + +// wrapSheet expands every lyric line into the display rows it needs at the +// given width, preserving the source index of each row. +func wrapSheet(sheet *lyrics.Sheet, width int) []displayRow { + if width < 4 { + width = 4 + } + var rows []displayRow + for i, line := range sheet.Lines { + text := strings.TrimSpace(line.Text) + if text == "" { + rows = append(rows, displayRow{srcIdx: i, text: "", last: true}) + continue + } + chunks := wrapPlain(text, width) + for j, chunk := range chunks { + rows = append(rows, displayRow{ + srcIdx: i, + text: chunk, + cont: j > 0, + last: j == len(chunks)-1, + }) + } + } + return rows +} + +// firstRowFor returns the index of the first display row belonging to the +// given source line, clamped into range. +func firstRowFor(rows []displayRow, srcIdx int) int { + if srcIdx <= 0 { + return 0 + } + for i, r := range rows { + if r.srcIdx >= srcIdx { + return i + } + } + if len(rows) == 0 { + return 0 + } + return len(rows) - 1 +} + +// wrapPlain word-wraps s to width columns, breaking overlong words. +func wrapPlain(s string, width int) []string { + if width < 1 { + width = 1 + } + words := strings.Fields(s) + if len(words) == 0 { + return []string{""} + } + var out []string + current := "" + for _, w := range words { + for lipgloss.Width(w) > width { + // Break a word that cannot fit on any line. + if current != "" { + out = append(out, current) + current = "" + } + head, tail := splitAtWidth(w, width) + out = append(out, head) + w = tail + } + switch { + case current == "": + current = w + case lipgloss.Width(current)+1+lipgloss.Width(w) <= width: + current += " " + w + default: + out = append(out, current) + current = w + } + } + if current != "" { + out = append(out, current) + } + return out +} + +// splitAtWidth cuts s after width display columns. +func splitAtWidth(s string, width int) (string, string) { + runes := []rune(s) + for i := range runes { + if lipgloss.Width(string(runes[:i+1])) > width { + if i == 0 { + return string(runes[:1]), string(runes[1:]) + } + return string(runes[:i]), string(runes[i:]) + } + } + return s, "" +} + +// centerLine pads a pre-rendered line so it sits centred in width columns. +// It measures with lipgloss so terminal image escape sequences, which have no +// display width, keep the padding the renderer already baked in. +func centerLine(line string, width int) string { + w := lipgloss.Width(line) + if w >= width { + return line + } + left := (width - w) / 2 + return strings.Repeat(" ", left) + line +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} diff --git a/pkg/ui/components/lyrics_test.go b/pkg/ui/components/lyrics_test.go new file mode 100644 index 0000000..e524e81 --- /dev/null +++ b/pkg/ui/components/lyrics_test.go @@ -0,0 +1,210 @@ +package components + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/halpworld/halpradio/pkg/lyrics" + "github.com/halpworld/halpradio/pkg/theme" +) + +func syncedTestSheet() *lyrics.Sheet { + return &lyrics.Sheet{ + Artist: "Tycho", + Title: "A Walk", + Synced: true, + Source: "LRCLIB", + Lines: []lyrics.Line{ + {At: 0, Text: "I've been wandering through the neon lights"}, + {At: 4 * time.Second, Text: "Searching for a signal in the dead of night"}, + {At: 9 * time.Second, Text: "Everything is quiet when the music starts"}, + {At: 14 * time.Second, Text: "And the city holds its breath"}, + }, + } +} + +func plainTestSheet(n int) *lyrics.Sheet { + sheet := &lyrics.Sheet{Artist: "Boards", Title: "Olson", Source: "NetEase"} + for i := 0; i < n; i++ { + sheet.Lines = append(sheet.Lines, lyrics.Line{Text: strings.Repeat("word ", 6)}) + } + return sheet +} + +func TestRenderLyricsDrawer_HighlightsActiveLine(t *testing.T) { + in := LyricsDrawerInput{ + Sheet: syncedTestSheet(), + TrackLabel: "Tycho - A Walk", + Elapsed: 5 * time.Second, + Width: 44, + Height: 20, + } + out := RenderLyricsDrawer(in, theme.GetTheme("tokyonight")) + + if !strings.Contains(out, "►") || !strings.Contains(out, "◄") { + t.Errorf("expected the active line markers in the drawer, got:\n%s", out) + } + if !strings.Contains(out, "Searching for a signal") { + t.Errorf("expected the line active at 5s to be visible, got:\n%s", out) + } + if !strings.Contains(out, "Synced via LRCLIB") { + t.Errorf("expected LRCLIB provenance in the footer, got:\n%s", out) + } +} + +func TestRenderLyricsDrawer_RespectsHeight(t *testing.T) { + sheet := plainTestSheet(60) + for _, h := range []int{8, 14, 20, 40} { + in := LyricsDrawerInput{Sheet: sheet, Width: 40, Height: h} + out := RenderLyricsDrawer(in, theme.GetTheme("nord")) + if got := lipgloss.Height(out); got != h { + t.Errorf("height %d: drawer rendered %d rows, want %d", h, got, h) + } + if got := lipgloss.Width(out); got != 40 { + t.Errorf("height %d: drawer rendered %d columns, want 40", h, got) + } + } +} + +func TestRenderLyricsDrawer_UnsyncedScrolls(t *testing.T) { + sheet := &lyrics.Sheet{Source: "NetEase"} + for i := 0; i < 40; i++ { + sheet.Lines = append(sheet.Lines, lyrics.Line{Text: lineMarker(i)}) + } + + top := RenderLyricsDrawer(LyricsDrawerInput{Sheet: sheet, Width: 40, Height: 16}, theme.GetTheme("dracula")) + if !strings.Contains(top, lineMarker(0)) { + t.Errorf("expected the first line at scroll 0, got:\n%s", top) + } + + scrolled := RenderLyricsDrawer(LyricsDrawerInput{Sheet: sheet, Width: 40, Height: 16, Scroll: 20}, theme.GetTheme("dracula")) + if strings.Contains(scrolled, lineMarker(0)) { + t.Errorf("expected the first line to be scrolled away, got:\n%s", scrolled) + } + if !strings.Contains(scrolled, lineMarker(20)) { + t.Errorf("expected line 20 after scrolling, got:\n%s", scrolled) + } + if !strings.Contains(scrolled, "Unsynced via NetEase") { + t.Errorf("expected unsynced provenance, got:\n%s", scrolled) + } +} + +func TestRenderLyricsDrawer_EmptyStates(t *testing.T) { + th := theme.GetTheme("gruvbox") + + fetching := RenderLyricsDrawer(LyricsDrawerInput{Fetching: true, Width: 40, Height: 14}, th) + if !strings.Contains(fetching, "Querying LRCLIB") { + t.Errorf("expected a fetching hint, got:\n%s", fetching) + } + + missing := RenderLyricsDrawer(LyricsDrawerInput{Status: "No lyrics found for \"Foo\"", Width: 40, Height: 14}, th) + if !strings.Contains(missing, "No lyrics found") { + t.Errorf("expected the status message, got:\n%s", missing) + } +} + +func TestRenderLyricsDrawer_InstrumentalFooter(t *testing.T) { + sheet := &lyrics.Sheet{ + Synced: false, + Instrumental: true, + Source: "LRCLIB", + Lines: []lyrics.Line{{Text: "♪ Instrumental ♪"}}, + } + out := RenderLyricsDrawer(LyricsDrawerInput{Sheet: sheet, Width: 40, Height: 14}, theme.GetTheme("synthwave")) + if !strings.Contains(out, "Instrumental") { + t.Errorf("expected the instrumental badge, got:\n%s", out) + } +} + +func TestRenderLyricsDrawer_ShowsSyncOffset(t *testing.T) { + in := LyricsDrawerInput{ + Sheet: syncedTestSheet(), + Elapsed: 5 * time.Second, + Offset: -1500 * time.Millisecond, + Width: 46, + Height: 20, + } + out := RenderLyricsDrawer(in, theme.GetTheme("tokyonight")) + if !strings.Contains(out, "-1.5s") { + t.Errorf("expected the sync offset in the footer, got:\n%s", out) + } +} + +func TestRenderLyricsDrawer_KeepsArtworkPadding(t *testing.T) { + artLines := []string{strings.Repeat("#", 12), strings.Repeat("#", 12)} + in := LyricsDrawerInput{ + Sheet: syncedTestSheet(), + ArtLines: artLines, + ArtCols: 12, + ArtSource: "iTunes", + Width: 44, + Height: 24, + } + out := RenderLyricsDrawer(in, theme.GetTheme("tokyonight")) + if got := lipgloss.Width(out); got != 44 { + t.Errorf("artwork changed the drawer width to %d, want 44", got) + } + if !strings.Contains(out, "iTunes") { + t.Errorf("expected the artwork provider label, got:\n%s", out) + } +} + +func TestWrapPlain(t *testing.T) { + tests := []struct { + name string + text string + width int + want []string + }{ + {"empty", "", 10, []string{""}}, + {"fits", "hello world", 20, []string{"hello world"}}, + {"wraps on words", "hello world again", 11, []string{"hello world", "again"}}, + {"breaks long word", "abcdefghij", 4, []string{"abcd", "efgh", "ij"}}, + {"collapses whitespace", " a b ", 10, []string{"a b"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := wrapPlain(tc.text, tc.width) + if len(got) != len(tc.want) { + t.Fatalf("wrapPlain(%q, %d) = %q, want %q", tc.text, tc.width, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("row %d = %q, want %q", i, got[i], tc.want[i]) + } + } + }) + } +} + +func TestWrapSheetTracksSourceLines(t *testing.T) { + sheet := &lyrics.Sheet{Lines: []lyrics.Line{ + {Text: "short"}, + {Text: "a much longer line that certainly wraps"}, + }} + rows := wrapSheet(sheet, 12) + if len(rows) < 3 { + t.Fatalf("expected the long line to wrap, got %d rows", len(rows)) + } + if rows[0].srcIdx != 0 || rows[0].cont { + t.Errorf("first row should be source line 0 and not a continuation, got %+v", rows[0]) + } + var conts int + for _, r := range rows { + if r.srcIdx == 1 && r.cont { + conts++ + } + } + if conts == 0 { + t.Error("expected at least one continuation row for the wrapped line") + } + if idx := firstRowFor(rows, 1); rows[idx].srcIdx != 1 || rows[idx].cont { + t.Errorf("firstRowFor(1) returned %+v, want the first row of source line 1", rows[idx]) + } +} + +func lineMarker(i int) string { + return "LYRICLINE" + string(rune('A'+i%26)) + string(rune('a'+i/26)) +} diff --git a/pkg/ui/components/whichkey.go b/pkg/ui/components/whichkey.go index c6da688..aaf4ca8 100644 --- a/pkg/ui/components/whichkey.go +++ b/pkg/ui/components/whichkey.go @@ -60,6 +60,9 @@ func RenderWhichKeyOverlay(width int, height int, th theme.Theme) string { col2 := []string{ sectionStyle.Render("⭐ DISCOVERY & SHARING"), formatRow("I", "Identify stream (AcoustID)", 11), + formatRow("L", "Synced lyrics drawer", 11), + formatRow("A", "Album art viewer", 11), + formatRow(", / .", "Nudge lyric sync", 11), formatRow("y", "Yank (copy) track info", 11), formatRow("o", "Open streaming search", 11), formatRow("s", "Bookmark track (in Hist)", 11), @@ -100,6 +103,7 @@ func RenderWhichKeyOverlay(width int, height int, th theme.Theme) string { col2 := []string{ sectionStyle.Render("⭐ ACTIONS & SEARCH"), formatRow("I/y", "Identify/Yank", 8), + formatRow("L/A", "Lyrics/Art", 8), formatRow("o", "Search web", 8), formatRow("f/s", "Fav/Bookmark", 8), formatRow("a/e/d", "Add/Edit/Del", 8), @@ -130,6 +134,7 @@ func RenderWhichKeyOverlay(width int, height int, th theme.Theme) string { formatRow("s / x", "Stop playback", 8), formatRow("z / r", "Timer/Random", 8), formatRow("y / f", "Yank/Favorite", 8), + formatRow("L / A", "Lyrics/Art", 8), formatRow("+/-/m", "Vol/Zoom/Mute", 8), formatRow("p / P", "PR/Plugins", 8), formatRow("/ / ?", "Search/Help", 8), diff --git a/pkg/ui/keymap.go b/pkg/ui/keymap.go index c0f71ec..feb4c62 100644 --- a/pkg/ui/keymap.go +++ b/pkg/ui/keymap.go @@ -53,6 +53,10 @@ type KeyMap struct { Plugins key.Binding Party key.Binding IdentifyTrack key.Binding + Lyrics key.Binding + AlbumArt key.Binding + LyricsSyncBack key.Binding + LyricsSyncFwd key.Binding } func DefaultKeyMap() KeyMap { @@ -233,5 +237,21 @@ func DefaultKeyMap() KeyMap { key.WithKeys("I"), key.WithHelp("I", "identify track (acoustic fingerprint)"), ), + Lyrics: key.NewBinding( + key.WithKeys("L"), + key.WithHelp("L", "synced lyrics drawer"), + ), + AlbumArt: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "album art modal"), + ), + LyricsSyncBack: key.NewBinding( + key.WithKeys(","), + key.WithHelp(",", "nudge lyrics sync back"), + ), + LyricsSyncFwd: key.NewBinding( + key.WithKeys("."), + key.WithHelp(".", "nudge lyrics sync forward"), + ), } } diff --git a/pkg/ui/model.go b/pkg/ui/model.go index c2dc2ef..6c6a335 100644 --- a/pkg/ui/model.go +++ b/pkg/ui/model.go @@ -6,7 +6,9 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/halpworld/halpradio/pkg/art" "github.com/halpworld/halpradio/pkg/desktop" + "github.com/halpworld/halpradio/pkg/lyrics" "github.com/halpworld/halpradio/pkg/party" "github.com/halpworld/halpradio/pkg/player" "github.com/halpworld/halpradio/pkg/player/fingerprint" @@ -24,6 +26,7 @@ type FocusArea int const ( FocusMainList FocusArea = iota FocusSidebar + FocusLyrics ) type TickMsg time.Time @@ -207,6 +210,34 @@ type Model struct { LastFingerprintTime time.Time FingerprintClient *fingerprint.Client + // Synced lyrics & terminal album art state + ShowLyrics bool + ShowArtModal bool + LyricsClient *lyrics.Client + LyricsSheet *lyrics.Sheet + LyricsStatus string + IsFetchingLyrics bool + LyricsScroll int + LyricsOffset time.Duration + LyricsTrackKey string + TrackStartTime time.Time + + ArtClient *art.Client + ArtRenderer *art.Renderer + ArtProtocol art.Protocol + Cover *art.Cover + ArtLines []string + ArtCols int + ArtRows int + ArtStatus string + IsFetchingArt bool + ArtTrackKey string + + // ArtClearFrames counts down the frames that still carry the protocol's + // image-delete escape. Kitty placements survive a text repaint, so a + // closed drawer has to explicitly evict them. + ArtClearFrames int + // Terminal Party Room State PartySession *party.PartySession ShowPartyModal bool @@ -218,6 +249,10 @@ type Model struct { IsChatting bool ChatInput string sendMsgFn func(tea.Msg) + + // nowPlayingSig is the station + track signature the lyric sheet and + // artwork currently belong to, used to notice a change on air. + nowPlayingSig string } func NewModel( @@ -289,6 +324,19 @@ func NewModel( PlaybackStartTime: time.Now(), FingerprintClient: fingerprint.NewClient(cfg.AcoustidAPIKey), PartyInputs: make([]string, 3), + LyricsOffset: time.Duration(cfg.LyricsOffsetMs) * time.Millisecond, + ShowLyrics: cfg.LyricsEnabled && cfg.LyricsAutoOpen, + } + + if cfg.LyricsEnabled { + m.LyricsClient = lyrics.NewClient(util.GetLyricsCacheDir()) + } + if cfg.AlbumArtEnabled { + m.ArtProtocol = art.Resolve(cfg.AlbumArtProtocol) + m.ArtRenderer = art.NewRenderer(m.ArtProtocol) + m.ArtClient = art.NewClient(util.GetAlbumArtCacheDir(), cfg.LastFMAPIKey) + } else { + m.ArtProtocol = art.ProtocolNone } allThemes := theme.GetAllThemes() @@ -617,6 +665,7 @@ func (m *Model) PlayNextStation() { m.IsIdentifying = false m.PlaybackStartTime = time.Now() m.LastFingerprintTime = time.Time{} + m.resetNowPlaying() _ = m.Player.Play(st) m.PlayingID = st.ID m.StatusMessage = fmt.Sprintf("Playing %s [%s]", st.Name, m.Player.ActiveBackend()) @@ -637,6 +686,7 @@ func (m *Model) PlayPrevStation() { m.IsIdentifying = false m.PlaybackStartTime = time.Now() m.LastFingerprintTime = time.Time{} + m.resetNowPlaying() _ = m.Player.Play(st) m.PlayingID = st.ID m.StatusMessage = fmt.Sprintf("Playing %s [%s]", st.Name, m.Player.ActiveBackend()) @@ -672,6 +722,7 @@ func (m *Model) TogglePlayPause() { m.IsIdentifying = false m.PlaybackStartTime = time.Now() m.LastFingerprintTime = time.Time{} + m.resetNowPlaying() _ = m.Player.Play(st) m.PlayingID = st.ID m.StatusMessage = fmt.Sprintf("Playing %s [%s]", st.Name, m.Player.ActiveBackend()) diff --git a/pkg/ui/nowplaying.go b/pkg/ui/nowplaying.go new file mode 100644 index 0000000..38c9e7d --- /dev/null +++ b/pkg/ui/nowplaying.go @@ -0,0 +1,469 @@ +package ui + +import ( + "context" + "fmt" + "math" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/halpworld/halpradio/pkg/art" + "github.com/halpworld/halpradio/pkg/lyrics" + "github.com/halpworld/halpradio/pkg/player" + "github.com/halpworld/halpradio/pkg/util" +) + +// LyricsFetchedMsg carries the result of an asynchronous lyric lookup. +type LyricsFetchedMsg struct { + StationID string + TrackKey string + Sheet *lyrics.Sheet + Err error +} + +// CoverArtFetchedMsg carries the result of an asynchronous cover art lookup. +type CoverArtFetchedMsg struct { + StationID string + TrackKey string + Cover *art.Cover + Err error +} + +// lyricsSyncStep is how far one press of the sync nudge keys shifts playback. +const lyricsSyncStep = 500 * time.Millisecond + +// nowPlayingTrack returns the best available "Artist - Title" string for the +// track currently on air, preferring an acoustic identification over the raw +// ICY metadata broadcast by the station. +func (m Model) nowPlayingTrack() string { + if m.IdentifiedResult != nil { + if s := m.IdentifiedResult.SimpleTitle(); s != "" { + return s + } + } + return strings.TrimSpace(m.Player.CurrentTrack()) +} + +// nowPlayingParts splits the current track into artist, title and album, +// falling back to the acoustic fingerprint fields when metadata is thin. +func (m Model) nowPlayingParts() (artist, title, album string) { + if m.IdentifiedResult != nil { + artist = m.IdentifiedResult.Artist + title = m.IdentifiedResult.Title + album = m.IdentifiedResult.Album + } + if artist == "" || title == "" { + a, t := lyrics.SplitTrackTitle(m.Player.CurrentTrack()) + if a != "" { + artist = a + } + if t != "" { + title = t + } + } + return artist, title, album +} + +// lyricElapsed estimates how far into the current track playback has reached. +// Internet radio exposes no seek position, so the clock starts when the +// station first announced this title and the user can nudge it with , and . +func (m Model) lyricElapsed() time.Duration { + if m.TrackStartTime.IsZero() { + return 0 + } + if m.Player.Status() != player.StatusPlaying { + return 0 + } + el := time.Since(m.TrackStartTime) + m.LyricsOffset + if el < 0 { + return 0 + } + return el +} + +// nowPlayingSignature identifies the station and track currently on air. A +// change in the signature is what triggers a fresh lyric and artwork lookup. +func (m Model) nowPlayingSignature() string { + st := m.Player.CurrentStation() + if st == nil { + return "" + } + if m.Player.Status() != player.StatusPlaying && m.Player.Status() != player.StatusConnecting { + return "" + } + ident := "" + if m.IdentifiedResult != nil { + ident = m.IdentifiedResult.SimpleTitle() + } + return st.ID + "\x1f" + m.Player.CurrentTrack() + "\x1f" + ident +} + +// coverSourceLabel names the provider the held artwork came from. +func (m Model) coverSourceLabel() string { + if m.Cover == nil { + return "" + } + return m.Cover.Source +} + +// resetNowPlaying drops the lyric sheet and artwork held for the previous +// track. It is called whenever the station or announced title changes. +func (m *Model) resetNowPlaying() { + m.LyricsSheet = nil + m.LyricsStatus = "" + m.LyricsScroll = 0 + m.LyricsTrackKey = "" + m.IsFetchingLyrics = false + m.Cover = nil + m.ArtLines = nil + m.ArtStatus = "" + m.ArtTrackKey = "" + m.IsFetchingArt = false + m.TrackStartTime = time.Time{} +} + +// syncNowPlaying starts any lookups the current track still needs and returns +// the commands to run them. It is safe to call on every metadata update: a +// track already fetched or already in flight produces no commands. +func (m *Model) syncNowPlaying() []tea.Cmd { + st := m.Player.CurrentStation() + if st == nil { + return nil + } + if m.Player.Status() != player.StatusPlaying && m.Player.Status() != player.StatusConnecting { + return nil + } + + artist, title, _ := m.nowPlayingParts() + if artist == "" || title == "" { + // Station is broadcasting its own name or an advert slug; there is + // nothing a lyrics or artwork provider could match on. + return nil + } + key := artist + " - " + title + if m.TrackStartTime.IsZero() { + m.TrackStartTime = time.Now() + } + + var cmds []tea.Cmd + if m.Config.LyricsEnabled && m.LyricsClient != nil && + !m.IsFetchingLyrics && m.LyricsTrackKey != key { + m.IsFetchingLyrics = true + m.LyricsTrackKey = key + m.LyricsSheet = nil + m.LyricsScroll = 0 + m.LyricsStatus = "Searching LRCLIB for synced lyrics…" + cmds = append(cmds, m.fetchLyricsCmd(st.ID, key)) + } + if m.Config.AlbumArtEnabled && m.ArtClient != nil && m.ArtProtocol != art.ProtocolNone && + !m.IsFetchingArt && m.ArtTrackKey != key { + m.IsFetchingArt = true + m.ArtTrackKey = key + m.Cover = nil + m.ArtLines = nil + m.ArtStatus = "Fetching cover art…" + cmds = append(cmds, m.fetchCoverCmd(st.ID, key)) + } + return cmds +} + +// fetchLyricsCmd looks up a lyric sheet off the UI thread. +func (m Model) fetchLyricsCmd(stationID, key string) tea.Cmd { + client := m.LyricsClient + if client == nil { + return nil + } + artist, title, album := m.nowPlayingParts() + var dur time.Duration + if m.IdentifiedResult != nil && m.IdentifiedResult.Duration > 0 { + dur = time.Duration(m.IdentifiedResult.Duration * float64(time.Second)) + } + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + sheet, err := client.Fetch(ctx, artist, title, album, dur) + return LyricsFetchedMsg{ + StationID: stationID, + TrackKey: key, + Sheet: sheet, + Err: err, + } + } +} + +// fetchCoverCmd downloads cover artwork off the UI thread. +func (m Model) fetchCoverCmd(stationID, key string) tea.Cmd { + client := m.ArtClient + if client == nil { + return nil + } + artist, title, album := m.nowPlayingParts() + + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cover, err := client.Fetch(ctx, artist, title, album) + return CoverArtFetchedMsg{ + StationID: stationID, + TrackKey: key, + Cover: cover, + Err: err, + } + } +} + +// LyricsDrawerMinWidth is the narrowest terminal that can host the drawer +// alongside the station list, which itself will not render below 28 columns +// next to an 18 column sidebar. +const LyricsDrawerMinWidth = 80 + +// LyricsDrawerWidth returns how many columns the lyrics drawer occupies for a +// given terminal width, or 0 when the terminal is too narrow to host it. +func LyricsDrawerWidth(width int) int { + if width < LyricsDrawerMinWidth { + return 0 + } + w := width * 2 / 5 + if w > 56 { + w = 56 + } + if w < 32 { + w = 32 + } + return w +} + +// artTarget returns the cell dimensions artwork should be rendered at for the +// currently visible surface, or zeroes when artwork has nowhere to go. +func (m Model) artTarget() (cols, rows int) { + width, height := m.Width, m.Height + if width == 0 || height == 0 { + width, height = 80, 24 + } + + if m.ShowArtModal { + cols = width - 16 + if cols > 60 { + cols = 60 + } + maxRows := height - 12 + if maxRows < 6 { + maxRows = 6 + } + if rows = cellRowsFor(cols); rows > maxRows { + rows = maxRows + cols = cellColsFor(rows) + } + if cols < 12 { + return 0, 0 + } + return cols, rows + } + + if !m.ShowLyrics { + return 0, 0 + } + drawer := LyricsDrawerWidth(width) + if drawer == 0 { + return 0, 0 + } + cols = drawer - 6 + if cols > 20 { + cols = 20 + } + rows = cellRowsFor(cols) + + // The sheet is the point of the drawer, so the thumbnail only gets the + // rows left over once the chrome and a readable run of lyrics are paid + // for. On a short terminal it is dropped entirely; A still shows it full + // size. + budget := drawerArtBudget(height) + if budget < drawerArtMinRows { + return 0, 0 + } + if rows > budget { + rows = budget + cols = cellColsFor(rows) + } + if cols < 8 { + return 0, 0 + } + return cols, rows +} + +// drawerArtMinRows is the smallest thumbnail worth drawing in the drawer. +const drawerArtMinRows = 6 + +// drawerArtBudget returns how many rows of the drawer are free for artwork +// once the frame, the header, the footer and a readable run of lyric lines +// have been accounted for. +func drawerArtBudget(termHeight int) int { + // Header, player bar, status bar and the spacer between them. + const chromeRows = 12 + // Drawer border, its own header block, its footer and the minimum sheet. + const drawerRows = 2 + 5 + 3 + 7 + return termHeight - chromeRows - drawerRows +} + +// cellRowsFor returns the row count that renders cols columns as a square, +// assuming a terminal cell is twice as tall as it is wide. +func cellRowsFor(cols int) int { + rows := int(math.Round(float64(cols) / 2.0)) + if rows < 1 { + rows = 1 + } + return rows +} + +// cellColsFor is the inverse of cellRowsFor. +func cellColsFor(rows int) int { + cols := rows * 2 + if cols < 1 { + cols = 1 + } + return cols +} + +// renderArt re-rasterises the held cover for the current surface size. It is +// pure CPU work on already-downloaded bytes, kept in the update loop so the +// component views stay side-effect free. +func (m *Model) renderArt() { + had := len(m.ArtLines) > 0 + cols, rows := m.artTarget() + if m.Cover == nil || m.ArtRenderer == nil || cols == 0 || rows == 0 { + m.dropArt(had) + return + } + if m.ArtCols == cols && m.ArtRows == rows && len(m.ArtLines) > 0 { + return + } + lines, err := m.ArtRenderer.Render(m.Cover.Data, cols, rows) + if err != nil { + m.dropArt(had) + m.ArtStatus = "Cover art could not be rendered" + return + } + m.ArtLines = lines + m.ArtCols, m.ArtRows = cols, rows + m.ArtClearFrames = 0 +} + +// artClearFrameCount is how many frames carry the image-delete escape after +// artwork disappears, enough to survive a partial repaint. +const artClearFrameCount = 3 + +// dropArt forgets the rasterised artwork, scheduling the protocol's +// image-delete escape when something was actually on screen. +func (m *Model) dropArt(hadLines bool) { + m.ArtLines = nil + m.ArtCols, m.ArtRows = 0, 0 + if hadLines && m.ArtRenderer != nil && m.ArtRenderer.Clear() != "" { + m.ArtClearFrames = artClearFrameCount + } +} + +// ArtClearSequence returns the escape that evicts a lingering terminal image, +// or an empty string when there is nothing to evict. +func (m Model) ArtClearSequence() string { + if m.ArtClearFrames <= 0 || m.ArtRenderer == nil { + return "" + } + return m.ArtRenderer.Clear() +} + +// toggleLyricsDrawer opens or closes the lyric drawer, moving keyboard focus +// into it so j/k scroll the sheet, and returns any lookup it kicked off. +func (m *Model) toggleLyricsDrawer() []tea.Cmd { + if !m.Config.LyricsEnabled { + m.StatusMessage = "Lyrics are disabled (set lyrics_enabled: true in config.yaml)" + return nil + } + if m.ShowLyrics { + m.ShowLyrics = false + if m.ActiveFocus == FocusLyrics { + m.ActiveFocus = FocusMainList + } + m.renderArt() + return nil + } + if LyricsDrawerWidth(m.Width) == 0 && m.Width > 0 { + m.StatusMessage = fmt.Sprintf("Terminal too narrow for the lyrics drawer (needs %d columns)", LyricsDrawerMinWidth) + return nil + } + + m.ShowLyrics = true + m.ActiveFocus = FocusLyrics + cmds := m.syncNowPlaying() + m.renderArt() + if m.LyricsSheet == nil && !m.IsFetchingLyrics && m.LyricsStatus == "" { + if m.Player.Status() != player.StatusPlaying { + m.LyricsStatus = "Start a station to pull in its lyrics" + } else { + m.LyricsStatus = "Waiting for track metadata from the stream…" + } + } + return cmds +} + +// toggleArtModal opens or closes the full-size album art modal. +func (m *Model) toggleArtModal() []tea.Cmd { + if !m.Config.AlbumArtEnabled || m.ArtProtocol == art.ProtocolNone { + m.StatusMessage = "Album art is disabled (set album_art_enabled: true in config.yaml)" + return nil + } + if m.ShowArtModal { + m.ShowArtModal = false + m.renderArt() + return nil + } + + m.ShowArtModal = true + cmds := m.syncNowPlaying() + m.renderArt() + if m.Cover == nil && !m.IsFetchingArt && m.ArtStatus == "" { + m.ArtStatus = "Waiting for track metadata from the stream…" + } + return cmds +} + +// scrollLyrics moves the manual scroll offset used for unsynced sheets. +func (m *Model) scrollLyrics(delta int) { + if m.LyricsSheet == nil { + return + } + m.LyricsScroll += delta + maxScroll := len(m.LyricsSheet.Lines) - 1 + if maxScroll < 0 { + maxScroll = 0 + } + if m.LyricsScroll > maxScroll { + m.LyricsScroll = maxScroll + } + if m.LyricsScroll < 0 { + m.LyricsScroll = 0 + } +} + +// nudgeLyricsSync shifts the estimated track start, correcting for stations +// that announce metadata late or early. +func (m *Model) nudgeLyricsSync(delta time.Duration) { + m.LyricsOffset += delta + m.Config.LyricsOffsetMs = int(m.LyricsOffset / time.Millisecond) + // Stations tend to be consistently late or early, so the correction is + // worth carrying across restarts. + _ = util.SaveConfig(m.Config) + m.StatusMessage = "Lyric sync offset " + formatOffset(m.LyricsOffset) +} + +// formatOffset renders a signed sync offset for the status bar. +func formatOffset(d time.Duration) string { + secs := d.Round(100 * time.Millisecond).Seconds() + if secs >= 0 { + return fmt.Sprintf("+%.1fs", secs) + } + return fmt.Sprintf("%.1fs", secs) +} diff --git a/pkg/ui/nowplaying_test.go b/pkg/ui/nowplaying_test.go new file mode 100644 index 0000000..f1b6eae --- /dev/null +++ b/pkg/ui/nowplaying_test.go @@ -0,0 +1,575 @@ +package ui + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/color" + "image/png" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/halpworld/halpradio/pkg/art" + "github.com/halpworld/halpradio/pkg/lyrics" + "github.com/halpworld/halpradio/pkg/player" + "github.com/halpworld/halpradio/pkg/util" +) + +func keyRune(r rune) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} +} + +func sizedTestModel(w, h int) Model { + m := createTestModel() + m.Width, m.Height = w, h + return m +} + +func TestLyricsKey_TogglesDrawerAndFocus(t *testing.T) { + m := sizedTestModel(120, 40) + + updated, _ := m.Update(keyRune('L')) + m = updated.(Model) + if !m.ShowLyrics { + t.Fatal("expected L to open the lyrics drawer") + } + if m.ActiveFocus != FocusLyrics { + t.Errorf("expected focus to move into the drawer, got %v", m.ActiveFocus) + } + + updated, _ = m.Update(keyRune('L')) + m = updated.(Model) + if m.ShowLyrics { + t.Error("expected a second L to close the drawer") + } + if m.ActiveFocus == FocusLyrics { + t.Error("expected focus to leave the drawer when it closes") + } +} + +func TestLyricsKey_RefusedOnNarrowTerminal(t *testing.T) { + m := sizedTestModel(70, 20) + + updated, _ := m.Update(keyRune('L')) + m = updated.(Model) + if m.ShowLyrics { + t.Error("expected the drawer to stay closed on a 60 column terminal") + } + if !strings.Contains(m.StatusMessage, "too narrow") { + t.Errorf("expected a width warning, got %q", m.StatusMessage) + } +} + +func TestLyricsKey_DisabledByConfig(t *testing.T) { + m := sizedTestModel(120, 40) + m.Config.LyricsEnabled = false + + updated, _ := m.Update(keyRune('L')) + m = updated.(Model) + if m.ShowLyrics { + t.Error("expected the drawer to stay closed when lyrics are disabled") + } + if !strings.Contains(m.StatusMessage, "lyrics_enabled") { + t.Errorf("expected the config hint, got %q", m.StatusMessage) + } +} + +func TestEscapeClosesLyricsDrawerBeforeClearingSearch(t *testing.T) { + m := sizedTestModel(120, 40) + m.SearchQuery = "jazz" + m.ShowLyrics = true + m.ActiveFocus = FocusLyrics + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.ShowLyrics { + t.Error("expected Esc to close the drawer") + } + if m.SearchQuery != "jazz" { + t.Errorf("expected the search query to survive the first Esc, got %q", m.SearchQuery) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.SearchQuery != "" { + t.Errorf("expected the second Esc to clear the search, got %q", m.SearchQuery) + } +} + +func TestLyricsFocus_ScrollsWithJK(t *testing.T) { + m := sizedTestModel(120, 40) + m.ShowLyrics = true + m.ActiveFocus = FocusLyrics + m.SelectedIndex = 0 + m.LyricsSheet = &lyrics.Sheet{Source: "NetEase"} + for i := 0; i < 20; i++ { + m.LyricsSheet.Lines = append(m.LyricsSheet.Lines, lyrics.Line{Text: "line"}) + } + + updated, _ := m.Update(keyRune('j')) + m = updated.(Model) + if m.LyricsScroll != 1 { + t.Errorf("expected j to scroll the sheet, got offset %d", m.LyricsScroll) + } + if m.SelectedIndex != 0 { + t.Errorf("expected the station selection to stay put, got %d", m.SelectedIndex) + } + + updated, _ = m.Update(keyRune('k')) + m = updated.(Model) + if m.LyricsScroll != 0 { + t.Errorf("expected k to scroll back, got offset %d", m.LyricsScroll) + } + + // Scrolling up at the top must not go negative. + updated, _ = m.Update(keyRune('k')) + m = updated.(Model) + if m.LyricsScroll != 0 { + t.Errorf("expected the scroll offset to clamp at 0, got %d", m.LyricsScroll) + } +} + +func TestLyricsSyncNudgeKeys(t *testing.T) { + // The nudge persists the offset, so keep it out of the real config dir. + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + t.Setenv("XDG_CONFIG_HOME", tempDir) + + m := sizedTestModel(120, 40) + + // Closed drawer: the nudge keys do nothing. + updated, _ := m.Update(keyRune('.')) + m = updated.(Model) + if m.LyricsOffset != 0 { + t.Errorf("expected no offset change while the drawer is closed, got %v", m.LyricsOffset) + } + + m.ShowLyrics = true + updated, _ = m.Update(keyRune('.')) + m = updated.(Model) + if m.LyricsOffset != lyricsSyncStep { + t.Errorf("expected . to add one sync step, got %v", m.LyricsOffset) + } + + updated, _ = m.Update(keyRune(',')) + updated, _ = updated.(Model).Update(keyRune(',')) + m = updated.(Model) + if m.LyricsOffset != -lyricsSyncStep { + t.Errorf("expected two , presses to land at -1 step, got %v", m.LyricsOffset) + } + if m.Config.LyricsOffsetMs != int(-lyricsSyncStep/time.Millisecond) { + t.Errorf("expected the offset to be mirrored into config, got %d", m.Config.LyricsOffsetMs) + } + + saved, err := util.LoadConfig() + if err != nil { + t.Fatalf("expected the nudge to have written config.yaml: %v", err) + } + if saved.LyricsOffsetMs != int(-lyricsSyncStep/time.Millisecond) { + t.Errorf("expected the offset to survive a reload, got %d", saved.LyricsOffsetMs) + } +} + +func TestLyricsFetchedMsg_PopulatesSheet(t *testing.T) { + m := sizedTestModel(120, 40) + m.LyricsTrackKey = "Tycho - A Walk" + m.IsFetchingLyrics = true + m.ShowLyrics = true + + sheet := &lyrics.Sheet{ + Artist: "Tycho", + Title: "A Walk", + Synced: true, + Source: "LRCLIB", + Lines: []lyrics.Line{{At: 0, Text: "first"}}, + } + updated, _ := m.Update(LyricsFetchedMsg{TrackKey: "Tycho - A Walk", Sheet: sheet}) + m = updated.(Model) + + if m.IsFetchingLyrics { + t.Error("expected the in-flight flag to clear") + } + if m.LyricsSheet == nil || m.LyricsSheet.Source != "LRCLIB" { + t.Fatalf("expected the sheet to be stored, got %+v", m.LyricsSheet) + } + if !strings.Contains(m.StatusMessage, "synced lyrics from LRCLIB") { + t.Errorf("expected a provenance flash, got %q", m.StatusMessage) + } +} + +func TestLyricsFetchedMsg_IgnoresStaleResult(t *testing.T) { + m := sizedTestModel(120, 40) + m.LyricsTrackKey = "Tycho - A Walk" + m.IsFetchingLyrics = true + + stale := &lyrics.Sheet{Source: "LRCLIB", Lines: []lyrics.Line{{Text: "old"}}} + updated, _ := m.Update(LyricsFetchedMsg{TrackKey: "Someone Else - Old Song", Sheet: stale}) + m = updated.(Model) + + if m.LyricsSheet != nil { + t.Error("expected a superseded lookup to be dropped") + } + if !m.IsFetchingLyrics { + t.Error("expected the in-flight flag for the current track to survive") + } +} + +func TestLyricsFetchedMsg_NoMatch(t *testing.T) { + m := sizedTestModel(120, 40) + m.LyricsTrackKey = "Unknown - Track" + m.IsFetchingLyrics = true + + updated, _ := m.Update(LyricsFetchedMsg{TrackKey: "Unknown - Track", Err: lyrics.ErrNotFound}) + m = updated.(Model) + + if m.LyricsSheet != nil { + t.Error("expected no sheet after a failed lookup") + } + if !strings.Contains(m.LyricsStatus, "No lyrics found") { + t.Errorf("expected a not-found status, got %q", m.LyricsStatus) + } +} + +func TestAlbumArtKey_TogglesModal(t *testing.T) { + m := sizedTestModel(120, 40) + m.ArtProtocol = art.ProtocolHalfBlock + m.ArtRenderer = art.NewRenderer(art.ProtocolHalfBlock) + + updated, _ := m.Update(keyRune('A')) + m = updated.(Model) + if !m.ShowArtModal { + t.Fatal("expected A to open the art modal") + } + + updated, _ = m.Update(keyRune('A')) + m = updated.(Model) + if m.ShowArtModal { + t.Error("expected a second A to close the art modal") + } +} + +func TestAlbumArtModal_HandsOffToLyrics(t *testing.T) { + m := sizedTestModel(120, 40) + m.ArtProtocol = art.ProtocolHalfBlock + m.ArtRenderer = art.NewRenderer(art.ProtocolHalfBlock) + m.ShowArtModal = true + + updated, _ := m.Update(keyRune('L')) + m = updated.(Model) + if m.ShowArtModal { + t.Error("expected L to close the art modal") + } + if !m.ShowLyrics { + t.Error("expected L to open the lyrics drawer from the art modal") + } +} + +func TestAlbumArtKey_DisabledByConfig(t *testing.T) { + m := sizedTestModel(120, 40) + m.Config.AlbumArtEnabled = false + + updated, _ := m.Update(keyRune('A')) + m = updated.(Model) + if m.ShowArtModal { + t.Error("expected the modal to stay closed when album art is disabled") + } + if !strings.Contains(m.StatusMessage, "album_art_enabled") { + t.Errorf("expected the config hint, got %q", m.StatusMessage) + } +} + +func TestCoverArtFetchedMsg_StoresAndRejects(t *testing.T) { + m := sizedTestModel(120, 40) + m.ArtProtocol = art.ProtocolHalfBlock + m.ArtRenderer = art.NewRenderer(art.ProtocolHalfBlock) + m.ArtTrackKey = "Tycho - A Walk" + m.IsFetchingArt = true + + cover := &art.Cover{Data: testPNG(t), Source: "iTunes"} + updated, _ := m.Update(CoverArtFetchedMsg{TrackKey: "Tycho - A Walk", Cover: cover}) + m = updated.(Model) + if m.Cover == nil || m.Cover.Source != "iTunes" { + t.Fatalf("expected the cover to be stored, got %+v", m.Cover) + } + if m.IsFetchingArt { + t.Error("expected the in-flight flag to clear") + } + + m.ArtTrackKey = "Newer - Track" + updated, _ = m.Update(CoverArtFetchedMsg{TrackKey: "Tycho - A Walk", Cover: cover, Err: art.ErrNotFound}) + m = updated.(Model) + if m.Cover == nil { + t.Error("expected a superseded artwork result to leave the held cover alone") + } +} + +func TestLyricElapsed_TracksOffsetAndPlayback(t *testing.T) { + m := sizedTestModel(120, 40) + if got := m.lyricElapsed(); got != 0 { + t.Errorf("expected zero elapsed with no track start, got %v", got) + } + + st := m.Stations[0] + _ = m.Player.Play(st) + m.TrackStartTime = time.Now().Add(-10 * time.Second) + + if got := m.lyricElapsed(); got < 9*time.Second || got > 11*time.Second { + t.Errorf("expected roughly 10s elapsed, got %v", got) + } + + m.LyricsOffset = -30 * time.Second + if got := m.lyricElapsed(); got != 0 { + t.Errorf("expected a large negative offset to clamp at zero, got %v", got) + } + + m.LyricsOffset = 0 + _ = m.Player.Stop() + if got := m.lyricElapsed(); got != 0 { + t.Errorf("expected zero elapsed while stopped, got %v", got) + } +} + +func TestNowPlayingSignature_ChangesWithTrack(t *testing.T) { + m := sizedTestModel(120, 40) + if sig := m.nowPlayingSignature(); sig != "" { + t.Errorf("expected an empty signature while stopped, got %q", sig) + } + + mock := m.Player.(*player.MockPlayer) + _ = mock.Play(m.Stations[0]) + first := m.nowPlayingSignature() + if first == "" { + t.Fatal("expected a signature once a station is playing") + } + + mock.SetTrack("Tycho - A Walk") + if second := m.nowPlayingSignature(); second == first { + t.Error("expected the signature to change when the announced track changes") + } +} + +func TestView_WithLyricsDrawerKeepsTerminalBounds(t *testing.T) { + for _, size := range [][2]int{{200, 50}, {120, 40}, {100, 30}, {80, 24}} { + m := sizedTestModel(size[0], size[1]) + m.ShowLyrics = true + m.ActiveFocus = FocusLyrics + m.LyricsSheet = &lyrics.Sheet{ + Synced: true, + Source: "LRCLIB", + Lines: []lyrics.Line{ + {At: 0, Text: "neon lights over the overpass"}, + {At: 3 * time.Second, Text: "searching for a signal"}, + }, + } + m.TrackStartTime = time.Now().Add(-4 * time.Second) + _ = m.Player.Play(m.Stations[0]) + + out := m.View() + if got := lipgloss.Width(out); got > size[0] { + t.Errorf("%dx%d: view is %d columns wide, exceeds the terminal", size[0], size[1], got) + } + if got := lipgloss.Height(out); got > size[1] { + t.Errorf("%dx%d: view is %d rows tall, exceeds the terminal", size[0], size[1], got) + } + if !strings.Contains(out, "LIVE LYRICS") { + t.Errorf("%dx%d: expected the drawer to be visible in the view", size[0], size[1]) + } + } +} + +func TestDrawerArtBudget_DropsThumbnailOnShortTerminals(t *testing.T) { + m := sizedTestModel(120, 24) + m.ShowLyrics = true + if cols, rows := m.artTarget(); cols != 0 || rows != 0 { + t.Errorf("expected no drawer thumbnail on a 24 row terminal, got %dx%d", cols, rows) + } + + m = sizedTestModel(120, 50) + m.ShowLyrics = true + cols, rows := m.artTarget() + if rows < drawerArtMinRows || cols < 8 { + t.Errorf("expected a thumbnail on a 50 row terminal, got %dx%d", cols, rows) + } +} + +func TestLyricsDrawerWidth(t *testing.T) { + tests := []struct { + width int + want int + }{ + {40, 0}, + {72, 0}, + {79, 0}, + {80, 32}, + {100, 40}, + {200, 56}, + } + for _, tc := range tests { + if got := LyricsDrawerWidth(tc.width); got != tc.want { + t.Errorf("LyricsDrawerWidth(%d) = %d, want %d", tc.width, got, tc.want) + } + } +} + +func TestTunerKeepsLKeyForDialSweep(t *testing.T) { + m := sizedTestModel(120, 40) + m.Config.ExperimentalTuner = true + m.SwitchTab(8) + m.ActiveTuner = true + m.TunerBand = "FM" + m.TunerFreq = 93.9 + + updated, _ := m.Update(keyRune('L')) + m = updated.(Model) + if m.ShowLyrics { + t.Error("expected L to sweep the dial rather than open the drawer in tuner mode") + } + if m.TunerFreq <= 93.9 { + t.Errorf("expected a fast sweep upward, got %.1f", m.TunerFreq) + } +} + +// testPNG builds a small in-memory PNG so artwork handling can be exercised +// without touching the network or the disk cache. +func testPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 32, 32)) + for y := 0; y < 32; y++ { + for x := 0; x < 32; x++ { + img.Set(x, y, color.RGBA{R: uint8(x * 8), G: uint8(y * 8), B: 120, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encoding the test PNG: %v", err) + } + return buf.Bytes() +} + +func TestArtTarget_SizesForSurface(t *testing.T) { + m := sizedTestModel(120, 40) + if cols, rows := m.artTarget(); cols != 0 || rows != 0 { + t.Errorf("expected no artwork target with both surfaces closed, got %dx%d", cols, rows) + } + + m.ShowLyrics = true + cols, rows := m.artTarget() + if cols <= 0 || rows <= 0 { + t.Fatalf("expected a drawer artwork target, got %dx%d", cols, rows) + } + if rows < drawerArtMinRows { + t.Errorf("drawer artwork of %d rows is below the minimum worth drawing", rows) + } + if cols > LyricsDrawerWidth(120)-6 { + t.Errorf("drawer artwork of %d columns does not fit the drawer", cols) + } + + m.ShowLyrics = false + m.ShowArtModal = true + modalCols, modalRows := m.artTarget() + if modalCols <= cols || modalRows <= rows { + t.Errorf("expected the modal to render larger artwork than the drawer, got %dx%d vs %dx%d", + modalCols, modalRows, cols, rows) + } + if modalCols > 120-16 { + t.Errorf("modal artwork of %d columns overflows the terminal", modalCols) + } +} + +func TestLyricsFetchedMsg_TransientFailureAllowsRetry(t *testing.T) { + m := sizedTestModel(120, 40) + m.LyricsTrackKey = "Tycho - A Walk" + m.IsFetchingLyrics = true + + updated, _ := m.Update(LyricsFetchedMsg{ + TrackKey: "Tycho - A Walk", + Err: fmt.Errorf("lrclib: %w", errors.New("dial tcp: connection refused")), + }) + m = updated.(Model) + + if m.LyricsTrackKey != "" { + t.Errorf("expected the track key to be cleared so a retry can happen, got %q", m.LyricsTrackKey) + } + if !strings.Contains(m.LyricsStatus, "retry") { + t.Errorf("expected a retry hint, got %q", m.LyricsStatus) + } +} + +func TestCoverArtFetchedMsg_TransientFailureAllowsRetry(t *testing.T) { + m := sizedTestModel(120, 40) + m.ArtTrackKey = "Tycho - A Walk" + m.IsFetchingArt = true + + updated, _ := m.Update(CoverArtFetchedMsg{ + TrackKey: "Tycho - A Walk", + Err: fmt.Errorf("itunes: %w", errors.New("i/o timeout")), + }) + m = updated.(Model) + + if m.ArtTrackKey != "" { + t.Errorf("expected the track key to be cleared so a retry can happen, got %q", m.ArtTrackKey) + } + if !strings.Contains(m.ArtStatus, "retry") { + t.Errorf("expected a retry hint, got %q", m.ArtStatus) + } +} + +func TestArtClearSequence_EvictsLingeringKittyImage(t *testing.T) { + m := sizedTestModel(120, 50) + m.ArtProtocol = art.ProtocolKitty + m.ArtRenderer = art.NewRenderer(art.ProtocolKitty) + m.Cover = &art.Cover{Data: testPNG(t), Source: "iTunes"} + m.ShowArtModal = true + m.renderArt() + + if len(m.ArtLines) == 0 { + t.Fatal("expected the modal to rasterise artwork") + } + if m.ArtClearSequence() != "" { + t.Error("expected no delete escape while artwork is on screen") + } + + m.ShowArtModal = false + m.renderArt() + if m.ArtClearFrames <= 0 { + t.Fatal("expected closing the modal to schedule an image delete") + } + if seq := m.ArtClearSequence(); seq == "" { + t.Error("expected a Kitty delete escape after the artwork disappeared") + } + if !strings.Contains(m.View(), m.ArtRenderer.Clear()) { + t.Error("expected the view to carry the delete escape") + } + + // The escape is emitted for a few frames and then stops. + for i := 0; i < artClearFrameCount; i++ { + updated, _ := m.Update(TickMsg(time.Now())) + m = updated.(Model) + } + if m.ArtClearSequence() != "" { + t.Errorf("expected the delete escape to stop after %d frames", artClearFrameCount) + } +} + +func TestArtClearSequence_QuietForCellRenderers(t *testing.T) { + m := sizedTestModel(120, 50) + m.ArtProtocol = art.ProtocolHalfBlock + m.ArtRenderer = art.NewRenderer(art.ProtocolHalfBlock) + m.Cover = &art.Cover{Data: testPNG(t), Source: "iTunes"} + m.ShowArtModal = true + m.renderArt() + m.ShowArtModal = false + m.renderArt() + + // Half-blocks are ordinary text, so a repaint is enough. + if m.ArtClearFrames != 0 { + t.Errorf("expected no delete schedule for a cell renderer, got %d frames", m.ArtClearFrames) + } + if m.ArtClearSequence() != "" { + t.Error("expected no delete escape for a cell renderer") + } +} diff --git a/pkg/ui/update.go b/pkg/ui/update.go index 9d77161..6fd4ef6 100644 --- a/pkg/ui/update.go +++ b/pkg/ui/update.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "fmt" "math" "math/rand" @@ -12,7 +13,9 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/halpworld/halpradio/pkg/art" "github.com/halpworld/halpradio/pkg/debuglog" + "github.com/halpworld/halpradio/pkg/lyrics" "github.com/halpworld/halpradio/pkg/party" "github.com/halpworld/halpradio/pkg/player" "github.com/halpworld/halpradio/pkg/player/fingerprint" @@ -58,10 +61,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.Width = msg.Width m.Height = msg.Height + // Artwork is rasterised to a fixed cell grid, so a resize has to + // re-encode it at the new dimensions. + m.renderArt() return m, nil case TickMsg: m.Visualizer.Tick() + if m.ArtClearFrames > 0 { + m.ArtClearFrames-- + } if m.Player.Status() == player.StatusError && m.Player.Error() != "" { m.StatusMessage = fmt.Sprintf("Error: %s", m.Player.Error()) } @@ -120,6 +129,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Lyrics and album art follow whatever is on air, whichever code + // path started it: the station list, the globe, the analog tuner, a + // party room sync or an OS media key. + if m.Config.LyricsEnabled || m.Config.AlbumArtEnabled { + if sig := m.nowPlayingSignature(); sig != m.nowPlayingSig { + m.nowPlayingSig = sig + m.resetNowPlaying() + tickCmds = append(tickCmds, m.syncNowPlaying()...) + m.renderArt() + } + } + if m.PartySession != nil && m.PartySession.IsActive() { m.PartySession.Tick() } @@ -614,6 +635,62 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case LyricsFetchedMsg: + if msg.TrackKey != m.LyricsTrackKey { + // A newer track already superseded this lookup. + return m, nil + } + m.IsFetchingLyrics = false + m.LyricsScroll = 0 + if msg.Err != nil && !errors.Is(msg.Err, lyrics.ErrNotFound) { + // Providers were unreachable rather than empty-handed. Drop the + // track key so reopening the drawer retries instead of showing a + // permanent "no lyrics" verdict. + m.LyricsSheet = nil + m.LyricsTrackKey = "" + m.LyricsStatus = "Lyrics providers unreachable — press L again to retry" + return m, nil + } + if msg.Err != nil || msg.Sheet == nil || msg.Sheet.IsEmpty() { + m.LyricsSheet = nil + m.LyricsStatus = fmt.Sprintf("No lyrics found for %q", msg.TrackKey) + return m, nil + } + m.LyricsSheet = msg.Sheet + m.LyricsStatus = "" + if m.ShowLyrics { + kind := "unsynced" + if msg.Sheet.Synced { + kind = "synced" + } + m.StatusMessage = fmt.Sprintf("📜 Loaded %s lyrics from %s", kind, msg.Sheet.Source) + } + return m, nil + + case CoverArtFetchedMsg: + if msg.TrackKey != m.ArtTrackKey { + return m, nil + } + m.IsFetchingArt = false + if msg.Err != nil && !errors.Is(msg.Err, art.ErrNotFound) { + m.Cover = nil + m.ArtLines = nil + m.ArtTrackKey = "" + m.ArtStatus = "Cover art providers unreachable — press A again to retry" + return m, nil + } + if msg.Err != nil || msg.Cover == nil { + m.Cover = nil + m.ArtLines = nil + m.ArtStatus = "No cover art found for this track" + return m, nil + } + m.Cover = msg.Cover + m.ArtStatus = "" + m.ArtCols, m.ArtRows = 0, 0 + m.renderArt() + return m, nil + case tea.KeyMsg: if m.ShowWhichKey { if key.Matches(msg, m.KeyMap.Clear) || key.Matches(msg, m.KeyMap.Help) || key.Matches(msg, m.KeyMap.Quit) { @@ -629,6 +706,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + if m.ShowArtModal { + switch { + case key.Matches(msg, m.KeyMap.Quit): + _ = m.Player.Stop() + return m, tea.Quit + case key.Matches(msg, m.KeyMap.Lyrics): + m.ShowArtModal = false + return m, tea.Batch(m.toggleLyricsDrawer()...) + case key.Matches(msg, m.KeyMap.Clear), + key.Matches(msg, m.KeyMap.AlbumArt), + key.Matches(msg, m.KeyMap.PlayPause): + m.ShowArtModal = false + m.renderArt() + } + return m, nil + } + if m.ShowThemePicker { return m.handleThemePickerKey(msg) } @@ -781,7 +875,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.KeyMap.Up): - if m.ActiveTab == 8 { + if m.ShowLyrics && m.ActiveFocus == FocusLyrics { + m.scrollLyrics(-1) + } else if m.ActiveTab == 8 { if m.Config.ExperimentalTuner && m.ActiveTuner { cfg := tuner.Bands[m.TunerBand] step := 0.5 @@ -845,7 +941,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.KeyMap.Down): - if m.ActiveTab == 8 { + if m.ShowLyrics && m.ActiveFocus == FocusLyrics { + m.scrollLyrics(1) + } else if m.ActiveTab == 8 { if m.Config.ExperimentalTuner && m.ActiveTuner { cfg := tuner.Bands[m.TunerBand] step := 0.5 @@ -897,7 +995,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } case key.Matches(msg, m.KeyMap.Left): - if m.ActiveTab == 8 { + if m.ShowLyrics && m.ActiveFocus == FocusLyrics { + m.ActiveFocus = FocusMainList + } else if m.ActiveTab == 8 { if m.Config.ExperimentalTuner && m.ActiveTuner { cfg := tuner.Bands[m.TunerBand] step := 0.1 @@ -929,7 +1029,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, m.KeyMap.Right): - if m.ActiveTab == 8 { + if m.ShowLyrics && m.ActiveFocus == FocusLyrics { + // The drawer is already the rightmost pane. + } else if m.ActiveTab == 8 { if m.Config.ExperimentalTuner && m.ActiveTuner { cfg := tuner.Bands[m.TunerBand] step := 0.1 @@ -1198,7 +1300,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SwitchTab(7) } - case key.Matches(msg, m.KeyMap.FastSweepRight): + case key.Matches(msg, m.KeyMap.FastSweepRight), key.Matches(msg, m.KeyMap.Lyrics): + // L drives the analog dial while the tuner is live, and opens the + // synced lyrics drawer everywhere else. if m.ActiveTab == 8 && m.Config.ExperimentalTuner && m.ActiveTuner { step := 1.0 if m.TunerBand == "AM" { @@ -1206,12 +1310,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.TunerBand == "SW" { step = 0.5 } - m.TunerFreq = math.Round((m.TunerFreq-step)*100) / 100 + m.TunerFreq = math.Round((m.TunerFreq+step)*100) / 100 cfg := tuner.Bands[m.TunerBand] if m.TunerFreq > cfg.MaxFreq { m.TunerFreq = cfg.MaxFreq } m.onTunerFreqChanged() + } else { + return m, tea.Batch(m.toggleLyricsDrawer()...) + } + + case key.Matches(msg, m.KeyMap.AlbumArt): + return m, tea.Batch(m.toggleArtModal()...) + + case key.Matches(msg, m.KeyMap.LyricsSyncBack): + if m.ShowLyrics { + m.nudgeLyricsSync(-lyricsSyncStep) + } + + case key.Matches(msg, m.KeyMap.LyricsSyncFwd): + if m.ShowLyrics { + m.nudgeLyricsSync(lyricsSyncStep) } case key.Matches(msg, m.KeyMap.Activity): @@ -1550,7 +1669,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.IsSearching = true case key.Matches(msg, m.KeyMap.Clear): - if m.SearchQuery != "" { + if m.ShowLyrics { + m.ShowLyrics = false + if m.ActiveFocus == FocusLyrics { + m.ActiveFocus = FocusMainList + } + m.renderArt() + } else if m.SearchQuery != "" { m.SearchQuery = "" m.RefreshStations() } diff --git a/pkg/ui/view.go b/pkg/ui/view.go index e54df9d..298d123 100644 --- a/pkg/ui/view.go +++ b/pkg/ui/view.go @@ -12,6 +12,10 @@ import ( ) func (m Model) View() string { + // A terminal image placed by Kitty outlives a text repaint, so a frame + // where artwork just disappeared has to carry the delete escape. + artClear := m.ArtClearSequence() + width := m.Width height := m.Height @@ -21,11 +25,24 @@ func (m Model) View() string { } if m.ShowWhichKey { - return components.RenderWhichKeyOverlay(width, height, m.Theme) + return artClear + components.RenderWhichKeyOverlay(width, height, m.Theme) } if m.ShowPRExport { - return components.RenderPRExportModal(m.ExportStation, width, height, m.Theme) + return artClear + components.RenderPRExportModal(m.ExportStation, width, height, m.Theme) + } + + if m.ShowArtModal { + return artClear + components.RenderAlbumArtModal(components.AlbumArtModalInput{ + Cover: m.Cover, + Lines: m.ArtLines, + Status: m.ArtStatus, + Fetching: m.IsFetchingArt, + Protocol: m.ArtProtocol, + TrackLabel: m.nowPlayingTrack(), + Width: width, + Height: height, + }, m.Theme) } if m.ShowThemePicker { @@ -34,7 +51,7 @@ func (m Model) View() string { if m.ThemeModalTab == 1 { cursor = m.ThemeRegistryCursor } - return components.RenderThemePickerModal( + return artClear + components.RenderThemePickerModal( installed, m.ThemeRegistryList, m.ThemeModalTab, @@ -50,11 +67,11 @@ func (m Model) View() string { } if m.ShowAddModal { - return components.RenderAddStationModal(m.AddInputs, m.AddFocusIdx, m.AddErrMsg, width, height, m.Theme) + return artClear + components.RenderAddStationModal(m.AddInputs, m.AddFocusIdx, m.AddErrMsg, width, height, m.Theme) } if m.ShowTimerModal { - return components.RenderTimerModal( + return artClear + components.RenderTimerModal( m.Timer, m.TimerModalScreen, m.TimerMenuCursor, @@ -70,7 +87,7 @@ func (m Model) View() string { } if m.ShowPartyModal { - return components.RenderPartyManagerModal( + return artClear + components.RenderPartyManagerModal( m.PartySession, m.PartyModalScreen, m.PartyModalCursor, @@ -84,7 +101,7 @@ func (m Model) View() string { } if m.ShowPermissionApproval { - return components.RenderPermissionApprovalModal(m.ApprovalPlugin, width, height, m.Theme) + return artClear + components.RenderPermissionApprovalModal(m.ApprovalPlugin, width, height, m.Theme) } if m.ShowPluginModal { @@ -92,7 +109,7 @@ func (m Model) View() string { if m.PluginMgr != nil { installed = m.PluginMgr.GetPlugins() } - return components.RenderPluginManagerModal( + return artClear + components.RenderPluginManagerModal( installed, m.PluginRegistryList, m.PluginModalTab, @@ -160,6 +177,25 @@ func (m Model) View() string { mainContentHeight = 3 } + // The lyrics drawer steals columns from the main content area so the + // station list keeps its own layout instead of being overlapped. + contentWidth := width + drawerWidth := 0 + if m.ShowLyrics { + drawerWidth = LyricsDrawerWidth(width) + if drawerWidth > 0 { + contentWidth = width - drawerWidth - 1 + if contentWidth < 24 { + contentWidth = 24 + drawerWidth = width - contentWidth - 1 + if drawerWidth < 24 { + drawerWidth = 0 + contentWidth = width + } + } + } + } + var mainArea string if m.ActiveTab == 0 { var actItems []string @@ -172,7 +208,7 @@ func (m Model) View() string { } } sidebarW := 26 - if width < 65 { + if contentWidth < 65 { sidebarW = 18 } sidebarView := components.RenderSidebar( @@ -185,7 +221,7 @@ func (m Model) View() string { m.ActiveFocus == FocusSidebar, m.Theme, ) - listWidth := width - sidebarW - 1 + listWidth := contentWidth - sidebarW - 1 if listWidth < 20 { listWidth = 20 } @@ -210,7 +246,7 @@ func (m Model) View() string { } } sidebarW := 28 - if width < 70 { + if contentWidth < 70 { sidebarW = 18 } sidebarView := components.RenderSidebar( @@ -223,7 +259,7 @@ func (m Model) View() string { m.ActiveFocus == FocusSidebar, m.Theme, ) - listWidth := width - sidebarW - 1 + listWidth := contentWidth - sidebarW - 1 if listWidth < 20 { listWidth = 20 } @@ -239,7 +275,7 @@ func (m Model) View() string { mainArea = lipgloss.JoinHorizontal(lipgloss.Top, sidebarView, " ", stationListView) } else if m.ActiveTab == 3 { sidebarW := 26 - if width < 65 { + if contentWidth < 65 { sidebarW = 18 } sidebarView := components.RenderSidebar( @@ -252,7 +288,7 @@ func (m Model) View() string { m.ActiveFocus == FocusSidebar, m.Theme, ) - listWidth := width - sidebarW - 1 + listWidth := contentWidth - sidebarW - 1 if listWidth < 20 { listWidth = 20 } @@ -270,7 +306,7 @@ func (m Model) View() string { mainArea = components.RenderHistoryList( m.Store.GetHistory(), m.HistoryIndex, - width, + contentWidth, mainContentHeight, m.Theme, ) @@ -281,7 +317,7 @@ func (m Model) View() string { m.TunerFreq, m.TunerBand, m.PlayingID, - width, + contentWidth, mainContentHeight, m.Theme, ) @@ -293,7 +329,7 @@ func (m Model) View() string { m.GlobeZoom, m.GlobeStationIndex, m.PlayingID, - width, + contentWidth, mainContentHeight, m.Theme, ) @@ -303,14 +339,33 @@ func (m Model) View() string { m.Stations, m.SelectedIndex, m.PlayingID, - width, + contentWidth, mainContentHeight, true, m.Theme, ) } - return lipgloss.JoinVertical( + if drawerWidth > 0 { + drawerView := components.RenderLyricsDrawer(components.LyricsDrawerInput{ + Sheet: m.LyricsSheet, + Status: m.LyricsStatus, + Fetching: m.IsFetchingLyrics, + TrackLabel: m.nowPlayingTrack(), + ArtLines: m.ArtLines, + ArtCols: m.ArtCols, + ArtSource: m.coverSourceLabel(), + Elapsed: m.lyricElapsed(), + Offset: m.LyricsOffset, + Scroll: m.LyricsScroll, + Focused: m.ActiveFocus == FocusLyrics, + Width: drawerWidth, + Height: mainContentHeight, + }, m.Theme) + mainArea = lipgloss.JoinHorizontal(lipgloss.Top, mainArea, " ", drawerView) + } + + return artClear + lipgloss.JoinVertical( lipgloss.Left, headerView, mainArea, diff --git a/pkg/util/config.go b/pkg/util/config.go index 4c396d7..11efa5e 100644 --- a/pkg/util/config.go +++ b/pkg/util/config.go @@ -40,6 +40,12 @@ type Config struct { FingerprintEnabled bool `yaml:"fingerprint_enabled"` AcoustidAPIKey string `yaml:"acoustid_api_key,omitempty"` AutoIdentify bool `yaml:"auto_identify"` + LyricsEnabled bool `yaml:"lyrics_enabled"` + LyricsAutoOpen bool `yaml:"lyrics_auto_open"` + LyricsOffsetMs int `yaml:"lyrics_offset_ms,omitempty"` + AlbumArtEnabled bool `yaml:"album_art_enabled"` + AlbumArtProtocol string `yaml:"album_art_protocol,omitempty"` + LastFMAPIKey string `yaml:"lastfm_api_key,omitempty"` PartyNickname string `yaml:"party_nickname,omitempty"` PartyRelayURL string `yaml:"party_relay_url,omitempty"` PartyPort int `yaml:"party_port,omitempty"` @@ -79,6 +85,12 @@ func DefaultConfig() Config { FingerprintEnabled: true, AcoustidAPIKey: "v8pQ6oyB", AutoIdentify: true, + LyricsEnabled: true, + LyricsAutoOpen: false, + LyricsOffsetMs: 0, + AlbumArtEnabled: true, + AlbumArtProtocol: "auto", + LastFMAPIKey: "", PartyNickname: "", PartyPort: 0, } @@ -140,6 +152,40 @@ func GetConfigFile() string { return filepath.Join(GetConfigDir(), "config.yaml") } +// GetCacheDir returns the directory used for large regenerable downloads such +// as album artwork and lyric sheets. It is deliberately separate from the +// config directory so users can delete it without losing their settings. +func GetCacheDir() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".halpradio", "cache") + } + return filepath.Join(home, ".cache", "halpradio") + } + return filepath.Join(cacheDir, "halpradio") +} + +// GetLyricsCacheDir returns the on-disk cache location for lyric sheets. +func GetLyricsCacheDir() string { + return filepath.Join(GetCacheDir(), "lyrics") +} + +// GetAlbumArtCacheDir returns the on-disk cache location for cover artwork. +func GetAlbumArtCacheDir() string { + return filepath.Join(GetCacheDir(), "art") +} + +// EnsureCacheDir creates the lyrics and artwork cache directories. A failure +// here is never fatal: the callers fall back to network-only operation. +func EnsureCacheDir() error { + if err := os.MkdirAll(GetLyricsCacheDir(), 0700); err != nil { + return err + } + return os.MkdirAll(GetAlbumArtCacheDir(), 0700) +} + func GetPluginsDir() string { return filepath.Join(GetConfigDir(), "plugins") } @@ -185,6 +231,9 @@ func LoadConfig() (Config, error) { if cfg.SleepFadeSeconds < 0 { cfg.SleepFadeSeconds = 10 } + if cfg.AlbumArtProtocol == "" { + cfg.AlbumArtProtocol = "auto" + } return cfg, nil } From b52cbff41aba62c30809bb29a90b9db339e6931d Mon Sep 17 00:00:00 2001 From: Kenth Fagerlund Date: Fri, 11 Sep 2026 19:47:02 +0100 Subject: [PATCH 2/2] fix(lyrics): make L work at any width and surface it in the status bar Below 80 columns the lyrics drawer declined to open, because the station list will not render under 28 columns beside an 18 column sidebar. The only feedback was one dim status-bar line, so pressing L on a narrow terminal looked like a dead key. The sheet now has two surfaces. At 80 columns or wider it stays a drawer beside the station list; below that it takes over the content area as a full-width overlay. Resizing moves an open sheet between the two instead of making it vanish, and the width refusal is gone entirely. The status bar also carries [L] Lyrics and [A] Art on every station tab. Previously the keys appeared only in the which-key overlay, so nothing in the default view hinted the feature existed. The legend is clipped to the terminal width, so its order is now a priority list and the two new entries sit near the front; y/Yank and a/Add move to the widest tier only. Verified against the real binary in a pty at 70 columns: L opens the overlay and the legend shows both keys. Co-Authored-By: Claude Opus 5 --- README.md | 3 +- docs/LYRICS_AND_ART.md | 15 +++++--- pkg/ui/components/statusbar.go | 37 +++++++++++++------ pkg/ui/components/statusbar_test.go | 26 ++++++++++++++ pkg/ui/nowplaying.go | 48 ++++++++++++++++++------- pkg/ui/nowplaying_test.go | 55 +++++++++++++++++++++++++---- pkg/ui/view.go | 37 ++++++++++--------- 7 files changed, 167 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b7f52dd..1142246 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,8 @@ Modern terminals grew real graphics capabilities, so `halpradio` uses them. Pres - **Auto-Scrolling Karaoke View**: When timestamped `.lrc` data exists, the drawer highlights the line being sung, fades the surrounding lines, and draws a progress gauge across the active line. - **Manual Scroll For Plain Text**: Unsynced lyrics render as a formatted sheet you scroll with `j` / `k`. - **Sync Nudge**: Internet radio exposes no seek position, so the lyric clock starts when the station announces a new title. Press `,` and `.` to shift the sync in 0.5 second steps when a station announces late or early. -- **Never Blocks The UI**: Every lookup runs as a Bubble Tea command off the update loop, so the TUI stays responsive on slow connections, and the drawer takes its own columns rather than overlapping the station list. +- **Never Blocks The UI**: Every lookup runs as a Bubble Tea command off the update loop, so the TUI stays responsive on slow connections. +- **Fits Any Terminal**: At 80 columns or wider the drawer takes its own columns rather than overlapping the station list; below that the sheet becomes a full-width overlay, and resizing moves it between the two without closing it. - **Disk & Memory Cache**: Sheets are memoised in RAM and cached under `~/.cache/halpradio/lyrics/`, and stations with no match are negative-cached so the APIs are not hammered every track. ### 🖼️ Multi-Protocol Album Art (`A` key) diff --git a/docs/LYRICS_AND_ART.md b/docs/LYRICS_AND_ART.md index bad7a15..ff9005b 100644 --- a/docs/LYRICS_AND_ART.md +++ b/docs/LYRICS_AND_ART.md @@ -149,11 +149,13 @@ JSON sidecar holding the provider, origin URL and fetch time. └───────────────────────────────┴────────────────────────────────┘ ``` -- The drawer **takes its own columns** rather than overlapping the station - list, so the list keeps its own layout and selection. -- It needs an 80 column terminal, because the station list will not render - below 28 columns beside an 18 column sidebar. Narrower terminals get a status - message instead of a broken frame. +- On an 80 column terminal or wider the drawer **takes its own columns** rather + than overlapping the station list, so the list keeps its layout and + selection. +- Below that the sheet takes over the content area as a full-width overlay, + because the station list will not render under 28 columns beside an 18 column + sidebar. `L` therefore always shows something at any width, and resizing + moves the sheet between the two surfaces without closing it. - Every lookup runs as a Bubble Tea command off the update loop, so a slow provider never blocks the keyboard. Results that arrive after the track has changed are dropped by comparing the track key. @@ -174,6 +176,9 @@ JSON sidecar holding the provider, origin URL and fetch time. | `h` | Return focus to the station list, leaving the drawer open | | `Esc` | Close the drawer | +The status bar carries `[L] Lyrics` and `[A] Art` on every station tab, so the +feature is discoverable without opening the which-key overlay. + ### Configuration ```yaml diff --git a/pkg/ui/components/statusbar.go b/pkg/ui/components/statusbar.go index f4c39f6..42d82ff 100644 --- a/pkg/ui/components/statusbar.go +++ b/pkg/ui/components/statusbar.go @@ -162,49 +162,66 @@ func RenderStatusBar(searchQuery string, message string, activeTab int, width in } } } else { - // Standard tabs legend - if width >= 95 { + // Standard tabs legend. The bar is clipped to the terminal width, so + // the order doubles as a priority list: anything that has to survive + // on a narrow terminal belongs near the front. + if width >= 118 { items = []struct { key string desc string }{ {"j/k", "Nav"}, - {"Space", "Play/Pause"}, + {"Space", "Play"}, + {"L", "Lyrics"}, + {"A", "Art"}, {"I", "Identify"}, - {"z", "Timer/Pomo"}, + {"z", "Timer"}, {"f", "Fav"}, - {"y", "Yank"}, {"+/-", "Vol"}, {"/", "Search"}, - {"a", "Add"}, - {"?", "WhichKey"}, + {"?", "Help"}, {"q", "Quit"}, } - } else if width >= 65 { + } else if width >= 95 { items = []struct { key string desc string }{ {"j/k", "Nav"}, {"Space", "Play"}, + {"L", "Lyrics"}, + {"A", "Art"}, {"z", "Timer"}, - {"f", "Fav"}, {"+/-", "Vol"}, {"/", "Search"}, {"?", "Help"}, {"q", "Quit"}, } - } else { + } else if width >= 65 { items = []struct { key string desc string }{ {"j/k", "Nav"}, {"Space", "Play"}, + {"L", "Lyrics"}, + {"A", "Art"}, + {"f", "Fav"}, {"/", "Search"}, {"?", "Help"}, {"q", "Quit"}, } + } else { + items = []struct { + key string + desc string + }{ + {"j/k", "Nav"}, + {"Space", "Play"}, + {"L", "Lyrics"}, + {"?", "Help"}, + {"q", "Quit"}, + } } } diff --git a/pkg/ui/components/statusbar_test.go b/pkg/ui/components/statusbar_test.go index 36204d2..a4ec7e8 100644 --- a/pkg/ui/components/statusbar_test.go +++ b/pkg/ui/components/statusbar_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/lipgloss" "github.com/halpworld/halpradio/pkg/theme" ) @@ -50,3 +51,28 @@ func TestRenderStatusBar(t *testing.T) { t.Errorf("Expected Sweep/Band keys in Tuner statusbar, got: %s", tunerOut) } } + +func TestRenderStatusBar_SurfacesLyricsAndArtKeys(t *testing.T) { + th := theme.GetTheme("tokyonight") + for _, width := range []int{65, 80, 95, 118, 140, 200} { + out := RenderStatusBar("", "", 1, width, th) + if !strings.Contains(out, "[L]") { + t.Errorf("width %d: expected the lyrics key in the legend, got:\n%s", width, out) + } + if !strings.Contains(out, "[A]") { + t.Errorf("width %d: expected the album art key in the legend, got:\n%s", width, out) + } + if got := lipgloss.Width(out); got > width { + t.Errorf("width %d: legend rendered %d columns", width, got) + } + } + + // The narrowest tier keeps lyrics but drops art, and must still fit. + narrow := RenderStatusBar("", "", 1, 50, th) + if !strings.Contains(narrow, "[L]") { + t.Errorf("expected the lyrics key to survive a 50 column legend, got:\n%s", narrow) + } + if got := lipgloss.Width(narrow); got > 50 { + t.Errorf("narrow legend rendered %d columns, want at most 50", got) + } +} diff --git a/pkg/ui/nowplaying.go b/pkg/ui/nowplaying.go index 38c9e7d..8e4d538 100644 --- a/pkg/ui/nowplaying.go +++ b/pkg/ui/nowplaying.go @@ -218,11 +218,13 @@ func (m Model) fetchCoverCmd(stationID, key string) tea.Cmd { // LyricsDrawerMinWidth is the narrowest terminal that can host the drawer // alongside the station list, which itself will not render below 28 columns -// next to an 18 column sidebar. +// next to an 18 column sidebar. Below this the sheet takes over the content +// area instead, so L always shows something. const LyricsDrawerMinWidth = 80 // LyricsDrawerWidth returns how many columns the lyrics drawer occupies for a -// given terminal width, or 0 when the terminal is too narrow to host it. +// given terminal width, or 0 when the terminal cannot host it beside the +// station list. func LyricsDrawerWidth(width int) int { if width < LyricsDrawerMinWidth { return 0 @@ -237,6 +239,34 @@ func LyricsDrawerWidth(width int) int { return w } +// LyricsSurface says where the lyric sheet is drawn at a given terminal size. +type LyricsSurface int + +const ( + // LyricsSurfaceHidden means the sheet is not on screen. + LyricsSurfaceHidden LyricsSurface = iota + // LyricsSurfaceDrawer puts the sheet beside the station list. + LyricsSurfaceDrawer + // LyricsSurfaceOverlay gives the sheet the whole content area, for + // terminals too narrow to show both. + LyricsSurfaceOverlay +) + +// lyricsSurface reports where the sheet goes and how many columns it gets. +func (m Model) lyricsSurface() (LyricsSurface, int) { + if !m.ShowLyrics { + return LyricsSurfaceHidden, 0 + } + width := m.Width + if width == 0 { + width = 80 + } + if drawer := LyricsDrawerWidth(width); drawer > 0 { + return LyricsSurfaceDrawer, drawer + } + return LyricsSurfaceOverlay, width +} + // artTarget returns the cell dimensions artwork should be rendered at for the // currently visible surface, or zeroes when artwork has nowhere to go. func (m Model) artTarget() (cols, rows int) { @@ -264,14 +294,11 @@ func (m Model) artTarget() (cols, rows int) { return cols, rows } - if !m.ShowLyrics { - return 0, 0 - } - drawer := LyricsDrawerWidth(width) - if drawer == 0 { + surface, surfaceWidth := m.lyricsSurface() + if surface == LyricsSurfaceHidden { return 0, 0 } - cols = drawer - 6 + cols = surfaceWidth - 6 if cols > 20 { cols = 20 } @@ -390,11 +417,6 @@ func (m *Model) toggleLyricsDrawer() []tea.Cmd { m.renderArt() return nil } - if LyricsDrawerWidth(m.Width) == 0 && m.Width > 0 { - m.StatusMessage = fmt.Sprintf("Terminal too narrow for the lyrics drawer (needs %d columns)", LyricsDrawerMinWidth) - return nil - } - m.ShowLyrics = true m.ActiveFocus = FocusLyrics cmds := m.syncNowPlaying() diff --git a/pkg/ui/nowplaying_test.go b/pkg/ui/nowplaying_test.go index f1b6eae..8cf225d 100644 --- a/pkg/ui/nowplaying_test.go +++ b/pkg/ui/nowplaying_test.go @@ -51,16 +51,59 @@ func TestLyricsKey_TogglesDrawerAndFocus(t *testing.T) { } } -func TestLyricsKey_RefusedOnNarrowTerminal(t *testing.T) { - m := sizedTestModel(70, 20) +func TestLyricsKey_FallsBackToOverlayOnNarrowTerminal(t *testing.T) { + m := sizedTestModel(70, 24) updated, _ := m.Update(keyRune('L')) m = updated.(Model) - if m.ShowLyrics { - t.Error("expected the drawer to stay closed on a 60 column terminal") + if !m.ShowLyrics { + t.Fatal("expected L to open the sheet even on a 70 column terminal") + } + + surface, surfaceWidth := m.lyricsSurface() + if surface != LyricsSurfaceOverlay { + t.Errorf("expected the overlay surface below %d columns, got %v", LyricsDrawerMinWidth, surface) + } + if surfaceWidth != 70 { + t.Errorf("expected the overlay to take the full width, got %d", surfaceWidth) + } + + out := m.View() + if !strings.Contains(out, "LIVE LYRICS") { + t.Error("expected the sheet to be visible on a narrow terminal") + } + // The overlay replaces the station list rather than squeezing beside it. + if strings.Contains(out, "Ambient Two") { + t.Error("expected the overlay to take over the content area") + } + if got := lipgloss.Width(out); got > 70 { + t.Errorf("overlay view is %d columns wide, exceeds the terminal", got) + } +} + +func TestLyricsSurface_SwitchesOnResize(t *testing.T) { + m := sizedTestModel(120, 40) + updated, _ := m.Update(keyRune('L')) + m = updated.(Model) + + if surface, _ := m.lyricsSurface(); surface != LyricsSurfaceDrawer { + t.Fatalf("expected a drawer at 120 columns, got %v", surface) } - if !strings.Contains(m.StatusMessage, "too narrow") { - t.Errorf("expected a width warning, got %q", m.StatusMessage) + + // Shrinking the window must not make the open sheet vanish. + updated, _ = m.Update(tea.WindowSizeMsg{Width: 70, Height: 24}) + m = updated.(Model) + if surface, _ := m.lyricsSurface(); surface != LyricsSurfaceOverlay { + t.Errorf("expected the sheet to become an overlay after shrinking, got %v", surface) + } + if !strings.Contains(m.View(), "LIVE LYRICS") { + t.Error("expected the sheet to stay visible after shrinking") + } + + updated, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(Model) + if surface, _ := m.lyricsSurface(); surface != LyricsSurfaceDrawer { + t.Error("expected the drawer to come back when the window grows") } } diff --git a/pkg/ui/view.go b/pkg/ui/view.go index 298d123..d2860c8 100644 --- a/pkg/ui/view.go +++ b/pkg/ui/view.go @@ -177,22 +177,17 @@ func (m Model) View() string { mainContentHeight = 3 } - // The lyrics drawer steals columns from the main content area so the - // station list keeps its own layout instead of being overlapped. + // Beside the station list the drawer steals columns so the list keeps its + // own layout. On a terminal too narrow for both, the sheet takes over the + // content area instead of silently declining to appear. + lyricsSurface, lyricsWidth := m.lyricsSurface() contentWidth := width - drawerWidth := 0 - if m.ShowLyrics { - drawerWidth = LyricsDrawerWidth(width) - if drawerWidth > 0 { - contentWidth = width - drawerWidth - 1 - if contentWidth < 24 { - contentWidth = 24 - drawerWidth = width - contentWidth - 1 - if drawerWidth < 24 { - drawerWidth = 0 - contentWidth = width - } - } + if lyricsSurface == LyricsSurfaceDrawer { + contentWidth = width - lyricsWidth - 1 + if contentWidth < 24 { + lyricsSurface = LyricsSurfaceOverlay + lyricsWidth = width + contentWidth = width } } @@ -346,8 +341,8 @@ func (m Model) View() string { ) } - if drawerWidth > 0 { - drawerView := components.RenderLyricsDrawer(components.LyricsDrawerInput{ + if lyricsSurface != LyricsSurfaceHidden { + lyricsView := components.RenderLyricsDrawer(components.LyricsDrawerInput{ Sheet: m.LyricsSheet, Status: m.LyricsStatus, Fetching: m.IsFetchingLyrics, @@ -359,10 +354,14 @@ func (m Model) View() string { Offset: m.LyricsOffset, Scroll: m.LyricsScroll, Focused: m.ActiveFocus == FocusLyrics, - Width: drawerWidth, + Width: lyricsWidth, Height: mainContentHeight, }, m.Theme) - mainArea = lipgloss.JoinHorizontal(lipgloss.Top, mainArea, " ", drawerView) + if lyricsSurface == LyricsSurfaceDrawer { + mainArea = lipgloss.JoinHorizontal(lipgloss.Top, mainArea, " ", lyricsView) + } else { + mainArea = lyricsView + } } return artClear + lipgloss.JoinVertical(