From 7bea8f6ba4eea9a2ff7db9e107f769bebec3219a Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 5 Aug 2026 18:17:54 -0400 Subject: [PATCH] feat(13-11): map music.list-tracks records onto AIUI's Song shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adaptLibraryTracks/adaptLibraryAlbums in archyContentAdapter.ts: real tag-extracted title/artist/album/duration from the music.* index (13-07), artist falls back to album_artist then '', order preserved from the index's own deterministic sort (never re-sorted browser-side), no cover-art URL (Track carries no artwork field — SongGrid's no-artwork state renders), own-library tracks resolve through the existing FileBrowser raw-file route, peer tracks through the existing Range- streaming proxy, no credential ever in a query string. 34/34 tests green. --- .../__tests__/archyContentAdapter.test.ts | 133 +++++++++++++ .../src/composables/archyContentAdapter.ts | 182 ++++++++++++++++++ 2 files changed, 315 insertions(+) diff --git a/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts b/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts index 31fc3763..4e73b964 100644 --- a/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts +++ b/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts @@ -3,9 +3,12 @@ import { adaptContentItems, adaptToFilm, adaptToPodcast, + adaptLibraryTracks, + adaptLibraryAlbums, classifyByMime, sortDeterministic, type ArchyContentItem, + type ArchyLibraryTrack, } from '../archyContentAdapter' function item(overrides: Partial): ArchyContentItem { @@ -282,3 +285,133 @@ describe('sortDeterministic', () => { expect(items).toEqual(copy) }) }) + +// ─── Library mapping (13-11) ──────────────────────────────────────────── + +function track(overrides: Partial): ArchyLibraryTrack { + return { + id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Album/01 Song.flac' }, + title: 'Song', + artist: 'Artist', + album: 'Album', + album_artist: 'Artist', + track_number: 1, + disc_number: 1, + year: 2024, + duration_secs: 210, + has_tags: true, + content_hash: null, + ...overrides, + } +} + +describe('adaptLibraryTracks', () => { + it('maps a music.list-tracks record to a Song with title/artist/album/duration carried through from tags', () => { + const [song] = adaptLibraryTracks([ + track({ title: 'Night Drive', artist: 'The Synths', album: 'Neon', duration_secs: 187 }), + ]) + expect(song!.title).toBe('Night Drive') + expect(song!.artist).toBe('The Synths') + expect(song!.album).toBe('Neon') + expect(song!.duration).toBe(187) + }) + + it('falls back to album_artist when artist is absent, and to an empty string when both are absent — never null/undefined', () => { + const [withAlbumArtist] = adaptLibraryTracks([track({ artist: null, album_artist: 'Various' })]) + expect(withAlbumArtist!.artist).toBe('Various') + + const [withNeither] = adaptLibraryTracks([track({ artist: null, album_artist: null })]) + expect(withNeither!.artist).toBe('') + expect(withNeither!.artist).not.toBe('null') + expect(withNeither!.artist).not.toBeUndefined() + }) + + it('preserves the index\'s own deterministic order — calling the adapter twice on the same input yields the same order', () => { + const tracks = [ + track({ id: { source: 'OwnLibrary', path: '/a' }, title: 'A' }), + track({ id: { source: 'OwnLibrary', path: '/b' }, title: 'B' }), + track({ id: { source: 'OwnLibrary', path: '/c' }, title: 'C' }), + ] + const first = adaptLibraryTracks(tracks).map((s) => s.title) + const second = adaptLibraryTracks(tracks).map((s) => s.title) + expect(first).toEqual(['A', 'B', 'C']) + expect(second).toEqual(first) + }) + + it('a track with no cover art maps with an absent coverUrl, not a broken-image URL', () => { + const [song] = adaptLibraryTracks([track({})]) + expect(song!.coverUrl).toBeUndefined() + }) + + it('a peer-sourced track carries a source entry distinguishing it from an own-library track (same three pinned literals)', () => { + const [own] = adaptLibraryTracks([track({ id: { source: 'OwnLibrary', path: '/x' } })]) + const [peer] = adaptLibraryTracks([ + track({ id: { source: { Peer: { onion: 'abc123.onion' } }, path: '/var/lib/archipelago/purchased-content/abc123.onion/content-1' } }), + ]) + expect(own!.sources![0]!.type).toBe('funkwhale') + expect(peer!.sources![0]!.type).toBe('plex') + expect(own!.sources![0]!.type).not.toBe(peer!.sources![0]!.type) + }) + + it('never produces a playback URL carrying a credential as a query parameter', () => { + const songs = adaptLibraryTracks([ + track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/a.flac' } }), + track({ + id: { source: { Peer: { onion: 'peer.onion' } }, path: '/var/lib/archipelago/purchased-content/peer.onion/content-9' }, + }), + ]) + for (const song of songs) { + for (const source of song.sources ?? []) { + expect(source.url).not.toMatch(/[?&](auth|token)=/) + } + } + }) + + it('an own-library track resolves through the existing FileBrowser raw-file route with no query string', () => { + const [song] = adaptLibraryTracks([ + track({ id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Song.flac' } }), + ]) + expect(song!.sources![0]!.url).toBe('/app/filebrowser/api/raw/Music/Artist/Song.flac') + }) + + it('a peer track resolves through the existing peer Range-streaming proxy', () => { + const [song] = adaptLibraryTracks([ + track({ + id: { source: { Peer: { onion: 'xyz.onion' } }, path: '/var/lib/archipelago/purchased-content/xyz.onion/content-42' }, + }), + ]) + expect(song!.sources![0]!.url).toBe('/api/peer-content/xyz.onion/content-42') + }) + + it('an empty library produces an empty songs array, not undefined', () => { + expect(adaptLibraryTracks([])).toEqual([]) + expect(adaptLibraryTracks(null)).toEqual([]) + expect(adaptLibraryTracks(undefined)).toEqual([]) + }) +}) + +describe('adaptLibraryAlbums', () => { + it('groups tracks by (album_artist, album), preserving first-seen order', () => { + const tracks = [ + track({ id: { source: 'OwnLibrary', path: '/1' }, title: 'T1', album: 'Beta', album_artist: 'X' }), + track({ id: { source: 'OwnLibrary', path: '/2' }, title: 'T2', album: 'Alpha', album_artist: 'Y' }), + track({ id: { source: 'OwnLibrary', path: '/3' }, title: 'T3', album: 'Beta', album_artist: 'X' }), + ] + const albums = adaptLibraryAlbums(tracks) + expect(albums.map((a) => a.album)).toEqual(['Beta', 'Alpha']) + expect(albums[0]!.tracks.map((t) => t.title)).toEqual(['T1', 'T3']) + expect(albums[1]!.tracks.map((t) => t.title)).toEqual(['T2']) + }) + + it('a track with no album tag forms no album bucket', () => { + const albums = adaptLibraryAlbums([track({ album: null })]) + expect(albums).toEqual([]) + }) + + it('is stable across repeat calls on the same input', () => { + const tracks = [track({ id: { source: 'OwnLibrary', path: '/1' }, album: 'A' })] + const first = adaptLibraryAlbums(tracks) + const second = adaptLibraryAlbums(tracks) + expect(first).toEqual(second) + }) +}) diff --git a/neode-ui/src/composables/archyContentAdapter.ts b/neode-ui/src/composables/archyContentAdapter.ts index 4e0d510d..1367d81d 100644 --- a/neode-ui/src/composables/archyContentAdapter.ts +++ b/neode-ui/src/composables/archyContentAdapter.ts @@ -406,3 +406,185 @@ export function adaptContentItems( return { films, songs, podcasts } } + +// ─── Library mapping (13-11) ──────────────────────────────────────────── +// +// `music.list-tracks` (13-07, `core/archipelago/src/api/rpc/music.rs`) +// returns real tag-extracted metadata (title/artist/album/duration) for +// this node's indexed library — a materially different, richer input than +// `ContentItem` above (which carries no tag data at all; `adaptToSong` +// above always sets `artist: ''`). `adaptLibraryTracks` is a sibling +// mapping, not a replacement: it feeds the same `songs` bucket of the +// `ArchyContentBundle`/`content:push` shape with real metadata instead of +// filename-derived guesses. + +/** `MusicSource` (`core/archipelago/src/music/mod.rs`), as seen over RPC. + * Serde's default externally-tagged representation: the unit variant + * `OwnLibrary` becomes the bare string `"OwnLibrary"`; the struct variant + * `Peer { onion }` becomes `{ "Peer": { "onion": string } }`. */ +export type ArchyMusicSource = 'OwnLibrary' | { Peer: { onion: string } } + +/** The wire shape of `core/archipelago/src/music/mod.rs`'s `Track`, as + * returned by `music.list-tracks` — field names match the Rust struct + * verbatim (no serde rename). */ +export interface ArchyLibraryTrack { + id: { source: ArchyMusicSource; path: string } + title: string + artist?: string | null + album?: string | null + album_artist?: string | null + track_number?: number | null + disc_number?: number | null + year?: number | null + duration_secs: number + has_tags: boolean + content_hash?: string | null +} + +/** A library track grouped under its album — exported for shape + * completeness and future reuse (`adaptLibraryAlbums`'s doc comment), + * mirroring `adaptToPodcast`'s status in this same file: not consumed by + * this plan's own wiring (`SongGrid` renders a flat track list), but a + * real, tested mapping a future album-detail view can reuse without + * re-deriving the grouping. */ +export interface ArchyLibraryAlbum { + album: string + albumArtist: string + tracks: Song[] +} + +function isPeerMusicSource(source: ArchyMusicSource): source is { Peer: { onion: string } } { + return typeof source === 'object' && source !== null && 'Peer' in source +} + +/** Build a playable URL for a library track. **Never** builds a URL + * carrying a credential in its query string (T-13-71, same rule as + * `buildMediaUrl` above). + * + * An `OwnLibrary` track's `path` is an absolute, canonicalized filesystem + * path rooted at `media_roots()`'s first entry + * (`data_dir/filebrowser/Music`, 13-04/13-07) — everything after the + * `/filebrowser/` path segment is the exact same FileBrowser-relative path + * `filebrowser-client.ts`'s `streamUrl` already serves via + * `/app/filebrowser/api/raw` (the T-13-39 fix, 13-06), so this reuses + * that existing route rather than minting a new one. + * + * A `Peer` track's `path` is the local byte-cache layout + * `/purchased-content//` (13-07's second media + * root) and resolves through the existing peer Range-streaming proxy — + * exactly `buildMediaUrl`'s peer branch above, just deriving `onion` from + * `MusicSource::Peer` instead of an adapter option and `content_id` from + * the path's own basename. */ +function buildLibraryTrackUrl(track: ArchyLibraryTrack): string { + if (isPeerMusicSource(track.id.source)) { + const onion = track.id.source.Peer.onion + const segments = track.id.path.split('/').filter(Boolean) + const contentId = segments[segments.length - 1] + if (!onion || !contentId) return '' + return `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(contentId)}` + } + const marker = '/filebrowser/' + const idx = track.id.path.indexOf(marker) + if (idx === -1) return '' + const relative = track.id.path.slice(idx + marker.length) + if (!relative) return '' + const encoded = relative + .split('/') + .filter(Boolean) + .map((seg) => encodeURIComponent(seg)) + .join('/') + return `/app/filebrowser/api/raw/${encoded}` +} + +function librarySourceKey(source: ArchyMusicSource): string { + return isPeerMusicSource(source) ? `peer:${source.Peer.onion}` : 'own' +} + +/** Stable per-track id: `TrackId` (`{ source, path }`) has no single string + * identity on the wire, so one is derived here deterministically from the + * same two fields — the same input always produces the same id. */ +function libraryTrackId(track: ArchyLibraryTrack): string { + return `${librarySourceKey(track.id.source)}:${track.id.path}` +} + +/** + * Map `music.list-tracks` records onto AIUI's `Song` shape. + * + * - Title/artist/album/duration are carried through from the extracted + * tags (`title`, `artist`, `album`, `duration_secs` → `duration`). + * - A track whose `artist` tag is absent falls back to `album_artist`, and + * to `''` if that is absent too — never the literal `null`/`undefined`. + * - Ordering is **not** recomputed here: `music.list-tracks`'s own + * response is already deterministically ordered + * `(disc, track number, title)` with `(source, path)` as the final + * tiebreak (13-07) — re-sorting by a different key in the browser would + * make the grid and the RPC disagree about what "first" means, so this + * is a straight, order-preserving map. + * - No cover art is ever set (`coverUrl` stays `undefined`): `Track` + * carries no artwork field at all, and AIUI's own artwork sources are + * dev-server-only Vite middleware, 404 on a node (13-CONTEXT.md + * landmine) — `SongGrid`'s existing no-artwork fallback renders instead + * of a broken image, unchanged. + * - A peer-sourced track's `sources[0].type` differs from an own-library + * track's, using the same `'funkwhale'`/`'plex'` literals `SONG_SOURCE_TYPE` + * already pins above (13-06). + * - No produced URL ever carries a credential in its query string. + * - `null`/`undefined`/empty input produces `[]`, never `undefined`. + */ +export function adaptLibraryTracks(tracks: ArchyLibraryTrack[] | null | undefined): Song[] { + return (tracks ?? []).map((track) => { + const peer = isPeerMusicSource(track.id.source) + const sourceType: SongSource['type'] = peer ? 'plex' : 'funkwhale' + const artist = track.artist ?? track.album_artist ?? '' + return { + id: libraryTrackId(track), + title: track.title, + artist, + album: track.album ?? undefined, + year: track.year ?? undefined, + duration: track.duration_secs, + sources: [ + { + type: sourceType, + name: peer ? 'Peer' : 'This node', + url: buildLibraryTrackUrl(track), + icon: sourceType, + }, + ], + } + }) +} + +/** + * Group `music.list-tracks` records into albums, keyed on + * `(album_artist, album)` — the same derived-albums grouping + * `13-MUSIC-MODEL.md` defines server-side for `music.list-albums`, computed + * here over already-adapted `Song`s so a future album-detail view can reuse + * it without a second RPC round trip. A track with no `album` tag forms no + * album bucket (nothing to group it under) but is still present in + * `adaptLibraryTracks`'s flat output. Grouping preserves the input's own + * order — first-seen album first, tracks in the order they appear — so + * calling this twice on the same input array yields identical output order. + */ +export function adaptLibraryAlbums(tracks: ArchyLibraryTrack[] | null | undefined): ArchyLibraryAlbum[] { + const list = tracks ?? [] + const songs = adaptLibraryTracks(list) + const albums: ArchyLibraryAlbum[] = [] + const index = new Map() + + list.forEach((track, i) => { + const album = track.album ?? '' + if (!album) return + const albumArtist = track.album_artist ?? track.artist ?? '' + const key = `${albumArtist}::${album}` + let bucket = index.get(key) + if (!bucket) { + bucket = { album, albumArtist, tracks: [] } + index.set(key, bucket) + albums.push(bucket) + } + bucket.tracks.push(songs[i]!) + }) + + return albums +}