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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user