feat(13-11): map music.list-tracks records onto AIUI's Song shape

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.
This commit is contained in:
archipelago
2026-08-05 18:17:54 -04:00
parent dba3aecf26
commit 7bea8f6ba4
2 changed files with 315 additions and 0 deletions
@@ -3,9 +3,12 @@ import {
adaptContentItems,
adaptToFilm,
adaptToPodcast,
adaptLibraryTracks,
adaptLibraryAlbums,
classifyByMime,
sortDeterministic,
type ArchyContentItem,
type ArchyLibraryTrack,
} from '../archyContentAdapter'
function item(overrides: Partial<ArchyContentItem>): ArchyContentItem {
@@ -282,3 +285,133 @@ describe('sortDeterministic', () => {
expect(items).toEqual(copy)
})
})
// ─── Library mapping (13-11) ────────────────────────────────────────────
function track(overrides: Partial<ArchyLibraryTrack>): 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)
})
})
@@ -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<path>` (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
* `<data_dir>/purchased-content/<onion>/<content_id>` (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<string, ArchyLibraryAlbum>()
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
}