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..1142246 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,66 @@ 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. +- **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) +`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 +438,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 +573,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..ff9005b --- /dev/null +++ b/docs/LYRICS_AND_ART.md @@ -0,0 +1,202 @@ +# 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 │ +└───────────────────────────────┴────────────────────────────────┘ +``` + +- 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. +- 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 | + +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 +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/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/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..8e4d538 --- /dev/null +++ b/pkg/ui/nowplaying.go @@ -0,0 +1,491 @@ +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. 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 cannot host it beside the +// station list. +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 +} + +// 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) { + 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 + } + + surface, surfaceWidth := m.lyricsSurface() + if surface == LyricsSurfaceHidden { + return 0, 0 + } + cols = surfaceWidth - 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 + } + 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..8cf225d --- /dev/null +++ b/pkg/ui/nowplaying_test.go @@ -0,0 +1,618 @@ +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_FallsBackToOverlayOnNarrowTerminal(t *testing.T) { + m := sizedTestModel(70, 24) + + updated, _ := m.Update(keyRune('L')) + m = updated.(Model) + 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) + } + + // 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") + } +} + +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..d2860c8 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,20 @@ func (m Model) View() string { mainContentHeight = 3 } + // 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 + if lyricsSurface == LyricsSurfaceDrawer { + contentWidth = width - lyricsWidth - 1 + if contentWidth < 24 { + lyricsSurface = LyricsSurfaceOverlay + lyricsWidth = width + contentWidth = width + } + } + var mainArea string if m.ActiveTab == 0 { var actItems []string @@ -172,7 +203,7 @@ func (m Model) View() string { } } sidebarW := 26 - if width < 65 { + if contentWidth < 65 { sidebarW = 18 } sidebarView := components.RenderSidebar( @@ -185,7 +216,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 +241,7 @@ func (m Model) View() string { } } sidebarW := 28 - if width < 70 { + if contentWidth < 70 { sidebarW = 18 } sidebarView := components.RenderSidebar( @@ -223,7 +254,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 +270,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 +283,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 +301,7 @@ func (m Model) View() string { mainArea = components.RenderHistoryList( m.Store.GetHistory(), m.HistoryIndex, - width, + contentWidth, mainContentHeight, m.Theme, ) @@ -281,7 +312,7 @@ func (m Model) View() string { m.TunerFreq, m.TunerBand, m.PlayingID, - width, + contentWidth, mainContentHeight, m.Theme, ) @@ -293,7 +324,7 @@ func (m Model) View() string { m.GlobeZoom, m.GlobeStationIndex, m.PlayingID, - width, + contentWidth, mainContentHeight, m.Theme, ) @@ -303,14 +334,37 @@ func (m Model) View() string { m.Stations, m.SelectedIndex, m.PlayingID, - width, + contentWidth, mainContentHeight, true, m.Theme, ) } - return lipgloss.JoinVertical( + if lyricsSurface != LyricsSurfaceHidden { + lyricsView := 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: lyricsWidth, + Height: mainContentHeight, + }, m.Theme) + if lyricsSurface == LyricsSurfaceDrawer { + mainArea = lipgloss.JoinHorizontal(lipgloss.Top, mainArea, " ", lyricsView) + } else { + mainArea = lyricsView + } + } + + 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 }