feat(13-06): content adapter — ContentItem to Film/Song/Podcast, close the streamUrl JWT leak

- archyContentAdapter.ts: hand-written adaptContentItems mapping (D-12),
  fixture-pinned at the adjacency, empty, ordering and paid-lock edges
  named in AIUI-03; classifyByMime covers the m4a/aac/opus/wma extension
  gap ShareModal.vue's mime map leaves today; buildMediaUrl never puts a
  credential in a query string (T-13-32).
- filebrowser-client.ts: streamUrl now returns a query-free same-origin
  raw-file URL, relying on the path=/ cookie login() already sets instead
  of also putting the JWT in the URL (T-13-39 — closes the pre-existing
  leak CONTEXT.md names, rather than merely not repeating it).
- filebrowserStreamUrl.test.ts: regression pin for the fix, including a
  traversal case confirming sanitizePath behavior is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 19:27:09 -04:00
co-authored by Claude Opus 5
parent 6520cffc95
commit f7691fd1bb
4 changed files with 806 additions and 5 deletions
@@ -0,0 +1,98 @@
/**
* Regression pin for T-13-39 — `streamUrl` used to append `?auth=<jwt>` to
* the raw-file URL, leaking the filebrowser JWT into browser history,
* `Referer` headers and access logs. 13-CONTEXT.md names this "the known
* leak to fix rather than propagate"; this file pins the fix so it cannot
* silently regress.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// FileBrowserClient reads window.location.origin in its constructor.
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
writable: true,
})
const { fileBrowserClient } = await import('../filebrowser-client')
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
clone: () => jsonResponse(body, status),
body: null,
bodyUsed: false,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
formData: () => Promise.resolve(new FormData()),
bytes: () => Promise.resolve(new Uint8Array()),
}
}
describe('FileBrowserClient.streamUrl', () => {
beforeEach(() => {
mockFetch.mockReset()
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = false
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
})
it('resolves to a same-origin raw-file URL with no query component', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'super-secret-jwt-token' } }))
const url = await fileBrowserClient.streamUrl('/Music/song.m4a')
expect(url).toBe('http://localhost/app/filebrowser/api/raw/Music/song.m4a')
expect(url).not.toContain('?')
})
it('never embeds the filebrowser JWT anywhere in the returned string', async () => {
const token = 'super-secret-jwt-token-value-12345'
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token } }))
const url = await fileBrowserClient.streamUrl('/Videos/movie.mp4')
expect(url).not.toContain(token)
expect(url).not.toMatch(/[?&]auth=/)
})
it('awaits authentication (sets the cookie the media request relies on) before returning', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-abc' } }))
await fileBrowserClient.streamUrl('/Videos/movie.mp4')
// The cookie login() sets is what the same-origin media request depends
// on now that the URL itself carries no credential — assert it's really
// there by the time the caller has the URL in hand.
expect(document.cookie).toContain('auth=jwt-abc')
})
it('does not re-authenticate when a valid session already exists', async () => {
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
document.cookie = 'auth=already-authed'
const url = await fileBrowserClient.streamUrl('/a.mp3')
expect(mockFetch).not.toHaveBeenCalled()
expect(url).toBe('http://localhost/app/filebrowser/api/raw/a.mp3')
})
it('still resolves traversal via sanitizePath — a path cannot escape root', async () => {
;(fileBrowserClient as unknown as { _authenticated: boolean })._authenticated = true
document.cookie = 'auth=already-authed'
const url = await fileBrowserClient.streamUrl('/Music/../../etc/passwd')
expect(url).toBe('http://localhost/app/filebrowser/api/raw/etc/passwd')
expect(url).not.toContain('..')
})
})
+15 -5
View File
@@ -165,15 +165,25 @@ class FileBrowserClient {
}
/**
* Get a direct streaming URL with auth token in query string.
* Use for video/audio <src> where browser needs to stream (range requests).
* The token is a short-lived JWT so exposure in URL is acceptable.
* Get a direct streaming URL for video/audio `<src>` where the browser
* needs to make Range requests.
*
* Carries NO credential in the query string (T-13-39, fixed 2026-08-03 —
* this was "the known leak to fix rather than propagate", per
* 13-CONTEXT.md). `login()` already sets the filebrowser JWT as a
* `path=/` cookie on this page's own origin, `baseUrl` is that same
* origin, and the browser attaches the cookie to the same-origin media
* subresource request automatically — the same mechanism filebrowser's
* own web UI relies on. Putting the token in the URL too was redundant,
* and it reached browser history, `Referer` headers and any access log on
* the path. The cookie itself is unchanged by this fix: it is still a
* 24-hour JWT, now confined to the cookie jar rather than also appearing
* in the URL.
*/
async streamUrl(path: string): Promise<string> {
await this.ensureAuth()
const token = this.getAuthCookie()
const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}?auth=${token}`
return `${this.baseUrl}/api/raw${safePath}`
}
/**
@@ -0,0 +1,285 @@
import { describe, it, expect } from 'vitest'
import {
adaptContentItems,
adaptToFilm,
adaptToSong,
adaptToPodcast,
classifyByMime,
sortDeterministic,
type ArchyContentItem,
} from '../archyContentAdapter'
function item(overrides: Partial<ArchyContentItem>): ArchyContentItem {
return {
id: 'id-1',
filename: 'file.bin',
mime_type: 'application/octet-stream',
size_bytes: 1024,
...overrides,
}
}
describe('classifyByMime', () => {
it('classifies a video mime as video', () => {
expect(classifyByMime(item({ mime_type: 'video/mp4', filename: 'movie.mp4' }))).toBe('video')
})
it('classifies an audio mime as audio', () => {
expect(classifyByMime(item({ mime_type: 'audio/mpeg', filename: 'song.mp3' }))).toBe('audio')
})
it('excludes image and document mimes rather than mis-typing them', () => {
expect(classifyByMime(item({ mime_type: 'image/jpeg', filename: 'photo.jpg' }))).toBe('excluded')
expect(classifyByMime(item({ mime_type: 'application/pdf', filename: 'doc.pdf' }))).toBe('excluded')
})
it('classifies m4a, aac, opus and wma as audio via extension fallback (ShareModal blind spot)', () => {
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.m4a' }))).toBe('audio')
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.aac' }))).toBe('audio')
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.opus' }))).toBe('audio')
expect(classifyByMime(item({ mime_type: 'application/octet-stream', filename: 'track.wma' }))).toBe('audio')
})
it('also classifies the correct audio/* mime for those four extensions', () => {
expect(classifyByMime(item({ mime_type: 'audio/mp4', filename: 'track.m4a' }))).toBe('audio')
expect(classifyByMime(item({ mime_type: 'audio/aac', filename: 'track.aac' }))).toBe('audio')
expect(classifyByMime(item({ mime_type: 'audio/opus', filename: 'track.opus' }))).toBe('audio')
expect(classifyByMime(item({ mime_type: 'audio/x-ms-wma', filename: 'track.wma' }))).toBe('audio')
})
})
describe('adaptContentItems', () => {
it('maps a video-mime item to a Film with id carried through and one source entry', () => {
const bundle = adaptContentItems(
[item({ id: 'film-1', filename: 'The Movie.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' })],
{ source: 'own' },
)
expect(bundle.films).toHaveLength(1)
expect(bundle.films[0]!.id).toBe('film-1')
expect(bundle.films[0]!.title).toBe('The Movie')
expect(bundle.films[0]!.sources).toHaveLength(1)
expect(bundle.songs).toHaveLength(0)
expect(bundle.podcasts).toHaveLength(0)
})
it('maps an audio-mime item to a Song', () => {
const bundle = adaptContentItems(
[item({ id: 'song-1', filename: 'Track.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' })],
{ source: 'own' },
)
expect(bundle.songs).toHaveLength(1)
expect(bundle.songs[0]!.id).toBe('song-1')
expect(bundle.films).toHaveLength(0)
})
it('excludes image and document mimes from all three buckets', () => {
const bundle = adaptContentItems(
[
item({ id: 'img-1', filename: 'photo.jpg', mime_type: 'image/jpeg' }),
item({ id: 'doc-1', filename: 'report.pdf', mime_type: 'application/pdf' }),
],
{ source: 'own' },
)
expect(bundle.films).toHaveLength(0)
expect(bundle.songs).toHaveLength(0)
expect(bundle.podcasts).toHaveLength(0)
})
it('maps an access:Paid item with a price and a locked flag, and no playable source URL', () => {
const bundle = adaptContentItems(
[
item({
id: 'paid-1',
filename: 'premium.mp4',
mime_type: 'video/mp4',
access: { paid: { price_sats: 5000 } },
}),
],
{ source: 'peer', peerOnion: 'abc123.onion' },
)
const film = bundle.films[0]!
expect(film.locked).toBe(true)
expect(film.priceSats).toBe(5000)
expect(film.sources[0]!.url).toBe('')
})
it('gives two items with identical filename and size but different id two distinct cards (adjacency)', () => {
const bundle = adaptContentItems(
[
item({ id: 'peer-a', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
item({ id: 'peer-b', filename: 'same.mp4', mime_type: 'video/mp4', size_bytes: 500, added_at: '2026-01-01T00:00:00Z' }),
],
{ source: 'peer', peerOnion: 'peer.onion' },
)
expect(bundle.films).toHaveLength(2)
const ids = bundle.films.map((f) => f.id)
expect(new Set(ids).size).toBe(2)
expect(ids).toContain('peer-a')
expect(ids).toContain('peer-b')
})
it('an item present both in own library and a peer share appears once per source (adjacency, cross-source)', () => {
const own = adaptContentItems(
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
{ source: 'own' },
)
const peer = adaptContentItems(
[item({ id: 'shared-item', filename: 'clip.mp4', mime_type: 'video/mp4', size_bytes: 100, added_at: '2026-01-01T00:00:00Z' })],
{ source: 'peer', peerOnion: 'peer.onion' },
)
// Each source's bundle carries its own single card for the id — the
// broker (Task 2) is responsible for not silently merging bundles from
// different sources into one deduplicated list.
expect(own.films).toHaveLength(1)
expect(peer.films).toHaveLength(1)
expect(own.films[0]!.sources[0]!.type).not.toBe(peer.films[0]!.sources[0]!.type)
})
it('an empty input array produces empty films/songs/podcasts arrays, not undefined or an error', () => {
const bundle = adaptContentItems([], { source: 'own' })
expect(bundle).toEqual({ films: [], songs: [], podcasts: [] })
})
it('handles null/undefined input the same as an empty array', () => {
expect(adaptContentItems(null, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [] })
expect(adaptContentItems(undefined, { source: 'own' })).toEqual({ films: [], songs: [], podcasts: [] })
})
it('maps a null/absent description to an empty string, never the literal "null"', () => {
const withNull = adaptContentItems(
[item({ id: 'f1', filename: 'a.mp4', mime_type: 'video/mp4', description: null })],
{ source: 'own' },
)
const withAbsent = adaptContentItems(
[item({ id: 'f2', filename: 'b.mp4', mime_type: 'video/mp4' })],
{ source: 'own' },
)
expect(withNull.films[0]!.synopsis).toBe('')
expect(withAbsent.films[0]!.synopsis).toBe('')
})
it('sorts added_at descending with id ascending as the deterministic tiebreak', () => {
const bundle = adaptContentItems(
[
item({ id: 'z', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
item({ id: 'a', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
item({ id: 'm', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
],
{ source: 'own' },
)
// Newest added_at first (m), then the 2026-01-01 pair tie-broken by id ascending (a, z).
expect(bundle.films.map((f) => f.id)).toEqual(['m', 'a', 'z'])
})
it('a null added_at sorts last rather than crashing the comparator', () => {
const bundle = adaptContentItems(
[
item({ id: 'has-date', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
item({ id: 'no-date', filename: 'b.mp4', mime_type: 'video/mp4', added_at: null }),
],
{ source: 'own' },
)
expect(bundle.films.map((f) => f.id)).toEqual(['has-date', 'no-date'])
})
it('produces identical output order regardless of input array order (repeat-call stability)', () => {
const items = [
item({ id: 'a', filename: 'a.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
item({ id: 'b', filename: 'b.mp4', mime_type: 'video/mp4', added_at: '2026-02-01T00:00:00Z' }),
item({ id: 'c', filename: 'c.mp4', mime_type: 'video/mp4', added_at: '2026-01-15T00:00:00Z' }),
]
const first = adaptContentItems(items, { source: 'own' })
const second = adaptContentItems([...items].reverse(), { source: 'own' })
expect(first.films.map((f) => f.id)).toEqual(second.films.map((f) => f.id))
})
it('never produces a URL carrying a credential as a query parameter', () => {
const bundle = adaptContentItems(
[
item({ id: 'own-1', filename: 'a.mp4', mime_type: 'video/mp4' }),
item({ id: 'song-1', filename: 'b.mp3', mime_type: 'audio/mpeg' }),
],
{ source: 'own' },
)
const peerBundle = adaptContentItems(
[item({ id: 'peer-1', filename: 'c.mp4', mime_type: 'video/mp4' })],
{ source: 'peer', peerOnion: 'somepeer.onion' },
)
const allUrls = [
...bundle.films.flatMap((f) => f.sources.map((s) => s.url)),
...bundle.songs.flatMap((s) => (s.sources ?? []).map((src) => src.url)),
...peerBundle.films.flatMap((f) => f.sources.map((s) => s.url)),
]
for (const url of allUrls) {
expect(url).not.toMatch(/[?&](auth|token)=/)
}
})
it('shape-pins every field FilmGrid.vue and SongGrid.vue read', () => {
const bundle = adaptContentItems(
[
item({ id: 'film-shape', filename: 'Shape Test.mp4', mime_type: 'video/mp4', added_at: '2026-01-01T00:00:00Z' }),
item({ id: 'song-shape', filename: 'Shape Song.mp3', mime_type: 'audio/mpeg', added_at: '2026-01-01T00:00:00Z' }),
],
{ source: 'own' },
)
const film = bundle.films[0]!
// FilmGrid.vue reads: id, title, year, director, cast (search/aria-label),
// rating, sources[].type (badges), coverSrc()/fallbackSrc() consume
// posterUrl/backdropUrl/title/year, genres (topGenres filter).
expect(typeof film.id).toBe('string')
expect(typeof film.title).toBe('string')
expect(typeof film.year).toBe('number')
expect(typeof film.director).toBe('string')
expect(Array.isArray(film.cast)).toBe(true)
expect(typeof film.rating).toBe('number')
expect(Array.isArray(film.genres)).toBe(true)
expect(Array.isArray(film.sources)).toBe(true)
expect(film.sources.length).toBeGreaterThan(0)
expect(typeof film.sources[0]!.type).toBe('string')
const song = bundle.songs[0]!
// SongGrid.vue reads: id, title, artist (search/aria-label), album
// (search), genres (topGenres), coverUrl, sources[].type (badges).
expect(typeof song.id).toBe('string')
expect(typeof song.title).toBe('string')
expect(typeof song.artist).toBe('string')
expect(Array.isArray(song.sources)).toBe(true)
expect((song.sources ?? []).length).toBeGreaterThan(0)
expect(typeof song.sources![0]!.type).toBe('string')
})
it('pins the three source-badge literal values for own/peer/indeehub films', () => {
const own = adaptToFilm(item({ id: 'x', filename: 'x.mp4', mime_type: 'video/mp4' }), { source: 'own' })
const peer = adaptToFilm(item({ id: 'y', filename: 'y.mp4', mime_type: 'video/mp4' }), { source: 'peer', peerOnion: 'p.onion' })
const indeehub = adaptToFilm(item({ id: 'z', filename: 'z.mp4', mime_type: 'video/mp4' }), { source: 'indeehub' })
expect(own.sources[0]!.type).toBe('nextcloud')
expect(peer.sources[0]!.type).toBe('plex')
expect(indeehub.sources[0]!.type).toBe('indeehub')
})
})
describe('adaptToPodcast', () => {
it('maps a ContentItem to a Podcast shape (exported for completeness; not reachable via adaptContentItems today)', () => {
const podcast = adaptToPodcast(
item({ id: 'pod-1', filename: 'Episode One.mp3', mime_type: 'audio/mpeg', description: 'A description' }),
{ source: 'own' },
)
expect(podcast.id).toBe('pod-1')
expect(podcast.title).toBe('Episode One')
expect(podcast.description).toBe('A description')
expect(Array.isArray(podcast.sources)).toBe(true)
})
})
describe('sortDeterministic', () => {
it('is a pure function that does not mutate its input', () => {
const items = [
item({ id: 'b', filename: 'b.mp4', added_at: '2026-01-01T00:00:00Z' }),
item({ id: 'a', filename: 'a.mp4', added_at: '2026-02-01T00:00:00Z' }),
]
const copy = [...items]
sortDeterministic(items)
expect(items).toEqual(copy)
})
})
@@ -0,0 +1,408 @@
/**
* Archy content adapter — maps `core/archipelago/src/content_server.rs`'s
* `ContentItem` (peer files, this node's own shared files, IndeeHub movies,
* paid/owned purchases) onto AIUI's `Film`/`Song`/`Podcast` shapes so its
* existing `FilmGrid`/`SongGrid`/`NewsGrid` components can render real node
* data instead of records regex-scraped out of the model's own reply text
* (D-12, 13-CONTEXT.md).
*
* RESEARCH Pitfall 4: `ContentItem` (`id`, `filename`, `mime_type`,
* `size_bytes`, `description`, `access`, `availability`, `added_at`) has NO
* shape overlap with `Film`/`Song`/`Podcast` (`posterUrl`, `coverUrl`,
* `sources[]`, `genres`, `runtime`, `director`, ...). This is a hand-written
* adapter, not a pass-through — every field below is a deliberate mapping
* decision, pinned by `__tests__/archyContentAdapter.test.ts`.
*
* neode-ui does not depend on `@aiui/core` (D-19 keeps the two packages
* decoupled even though they now live in one repo), so the target shapes are
* declared locally here rather than imported. They are kept structurally
* identical to `aiui/packages/core/src/types/content.ts`'s `Film`/`Song`/
* `Podcast`, plus a small Archipelago-only extension (`locked`/`priceSats`)
* that AIUI's grids simply ignore today (D-14's paid-unlock state) — the
* "shape pinning" test below is the regression pin against silent drift.
*/
// ─── Target shapes (structurally match aiui/packages/core/src/types/content.ts) ───
export interface FilmSource {
type: 'plex' | 'nextcloud' | 'youtube' | 'free-web' | 'indeehub'
name: string
url: string
quality?: string
icon: string
}
export interface Film {
id: string
title: string
year: number
posterUrl: string
backdropUrl?: string
synopsis: string
genres: string[]
rating: number
runtime: number
director: string
cast: string[]
trailerUrl?: string
sources: FilmSource[]
/** Archipelago extension (not part of AIUI's `Film` type): set when this
* item is `access: 'Paid'` and not yet unlocked. AIUI's `FilmGrid` reads
* only the fields above and ignores unknown ones, so this is additive. */
locked?: boolean
priceSats?: number
}
export interface SongSource {
type:
| 'plex'
| 'spotify'
| 'youtube'
| 'apple-music'
| 'bandcamp'
| 'soundcloud'
| 'wavlake'
| 'internet_archive'
| 'jamendo'
| 'odysee'
| 'funkwhale'
name: string
url: string
icon?: string
}
export interface Song {
id: string
title: string
artist: string
album?: string
year?: number
coverUrl?: string
duration?: number
genres?: string[]
sources?: SongSource[]
locked?: boolean
priceSats?: number
}
export interface PodcastSource {
type: 'fountain' | 'rumble' | 'youtube' | 'podcastindex' | 'castopod' | 'odysee' | 'podverse' | 'ipfs' | 'rss'
name: string
url: string
icon?: string
}
export interface Podcast {
id: string
title: string
host?: string
description?: string
coverUrl?: string
year?: number
episodeCount?: number
genres?: string[]
sources: PodcastSource[]
locked?: boolean
priceSats?: number
}
// ─── Source shape: content_server.rs's ContentItem, as seen over RPC ───
/** `AccessControl` (`core/archipelago/src/content_server.rs`) serializes via
* serde's default externally-tagged representation with `rename_all =
* "lowercase"`: unit variants become bare strings, the struct variant
* becomes `{ paid: { price_sats, accepted } }`. */
export type ArchyAccessControl =
| 'free'
| 'peersonly'
| { paid: { price_sats: number; accepted?: string[] } }
/** `Availability`, same serialization convention. Not consumed by the
* adapter's mapping logic today (RPC scope already decides what's fetched);
* kept on the type for fixture fidelity and future use. */
export type ArchyAvailability = 'nobody' | 'allpeers' | { specific: { peers: string[] } }
/** The wire shape of `content_server::ContentItem`, mirrored field-for-field. */
export interface ArchyContentItem {
id: string
filename: string
mime_type: string
size_bytes: number
description?: string | null
access?: ArchyAccessControl
availability?: ArchyAvailability
added_at?: string | null
}
export interface ArchyContentBundle {
films: Film[]
songs: Song[]
podcasts: Podcast[]
}
export interface AdaptContentOptions {
/** Where this batch of items came from — decides the `sources[]` badge
* and how a playable URL is built. Not a per-item field: a whole RPC
* response (one node's catalog, one peer's catalog, or IndeeHub) shares
* one source. */
source: 'own' | 'peer' | 'indeehub'
/** Required when `source === 'peer'` — the peer's onion address, needed
* to build the Range-streaming proxy URL. */
peerOnion?: string
}
// ─── Classification ───────────────────────────────────────────────────────
type ContentBucket = 'video' | 'audio' | 'excluded'
// `m4a`, `aac`, `opus` and `wma` classify as audio via this extension
// fallback — `ShareModal.vue`'s mime map omits exactly these four today, so
// files shared with those extensions arrive here as generic
// `application/octet-stream` (or another wrong mime) rather than `audio/*`.
// Without the fallback they would be silently mis-typed as `excluded`
// instead of routing to the Songs bucket. 13-11 fixes the share side; this
// adapter must not inherit the same blind spot in the meantime.
const AUDIO_EXT_FALLBACK = new Set([
'm4a',
'aac',
'opus',
'wma',
'mp3',
'flac',
'wav',
'ogg',
])
const VIDEO_EXT_FALLBACK = new Set(['mp4', 'mkv', 'avi', 'mov', 'webm', 'm4v'])
function extensionOf(filename: string): string {
const base = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename
const idx = base.lastIndexOf('.')
return idx > 0 ? base.slice(idx + 1).toLowerCase() : ''
}
/** Strip the extension from a filename to derive a display title. Directory
* separators are stripped first so a full relative path collapses to a
* bare filename-derived title. */
function stripExtension(filename: string): string {
const base = filename.includes('/') ? filename.slice(filename.lastIndexOf('/') + 1) : filename
const idx = base.lastIndexOf('.')
return idx > 0 ? base.slice(0, idx) : base
}
/**
* Decide which grid bucket a `ContentItem` belongs to. Video and audio mimes
* (and, as a fallback for a wrong/generic mime, video and audio extensions)
* route to Film/Song respectively; everything else (image, document, or
* anything unrecognized) is excluded from all three buckets rather than
* mis-typed into one. `ContentItem` carries no podcast-specific signal
* (no episode/feed metadata), so nothing classifies as `podcast` here —
* `adaptToPodcast` exists for shape completeness and future reuse (e.g. an
* RSS/podcast-feed source) but `adaptContentItems` never calls it today.
*/
export function classifyByMime(item: Pick<ArchyContentItem, 'mime_type' | 'filename'>): ContentBucket {
const mime = (item.mime_type || '').toLowerCase().trim()
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
const ext = extensionOf(item.filename || '')
if (AUDIO_EXT_FALLBACK.has(ext)) return 'audio'
if (VIDEO_EXT_FALLBACK.has(ext)) return 'video'
return 'excluded'
}
// ─── Access / paid-lock helpers ────────────────────────────────────────────
function paidPriceSats(access: ArchyAccessControl | undefined): number | null {
if (access && typeof access === 'object' && 'paid' in access) {
return access.paid.price_sats
}
return null
}
// ─── Source badge + URL building ───────────────────────────────────────────
// FilmSource's type union has no literal that means "this node" or "a peer"
// by name — these are infrastructure-flavored badges borrowed from AIUI's
// existing (unmodified, D-12) vocabulary. 'nextcloud' (self-hosted file
// storage) stands in for this node's own catalog; 'plex' (a media server)
// stands in for a peer's shared catalog; 'indeehub' is IndeeHub's own
// literal. SongSource's union has no 'nextcloud' entry at all, so the
// self-hosted analogue there is 'funkwhale' (a self-hosted, federated audio
// server) — the closest existing badge to "this node". These three literal
// values are pinned by the adapter's tests so a later refactor cannot
// quietly change what a grid badge means (13-06-PLAN.md Task 1).
const FILM_SOURCE_TYPE: Record<AdaptContentOptions['source'], FilmSource['type']> = {
own: 'nextcloud',
peer: 'plex',
indeehub: 'indeehub',
}
const SONG_SOURCE_TYPE: Record<AdaptContentOptions['source'], SongSource['type']> = {
own: 'funkwhale',
peer: 'plex',
// IndeeHub carries no audio catalog in this plan's scope — audio arriving
// tagged 'indeehub' is not an expected path, so this falls back to the
// generic peer-media badge rather than an invalid literal.
indeehub: 'plex',
}
const PODCAST_SOURCE_TYPE: Record<AdaptContentOptions['source'], PodcastSource['type']> = {
own: 'rss',
peer: 'rss',
indeehub: 'rss',
}
const SOURCE_LABEL: Record<AdaptContentOptions['source'], string> = {
own: 'This node',
peer: 'Peer',
indeehub: 'IndeeHub',
}
/**
* Build a playable media URL for an unlocked item. **Never** builds a URL
* containing a credential in its query string (T-13-32): own-node media
* resolves through the existing content endpoint (`/content/<id>`, which is
* itself unauthenticated by design — content_server access control is
* per-item, not per-session), and peer media through the existing Rust
* Range-streaming proxy (`/api/peer-content/<onion>/<id>`, which rides the
* page's own session cookie automatically as a same-origin request). Both
* already exist; this function names them, it does not mint anything new.
*/
function buildMediaUrl(item: ArchyContentItem, opts: AdaptContentOptions): string {
if (opts.source === 'peer') {
if (!opts.peerOnion) return ''
return `/api/peer-content/${encodeURIComponent(opts.peerOnion)}/${encodeURIComponent(item.id)}`
}
// 'own' and 'indeehub' items both live in this node's own catalog once
// added (IndeeHub ingestion still lands an entry in the same catalog —
// D-14 routes it through the existing content subsystem, not a new one).
return `/content/${encodeURIComponent(item.id)}`
}
// ─── Per-type mapping ───────────────────────────────────────────────────────
export function adaptToFilm(item: ArchyContentItem, opts: AdaptContentOptions): Film {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
const sourceType = FILM_SOURCE_TYPE[opts.source]
return {
id: item.id,
title: stripExtension(item.filename || ''),
year: 0,
posterUrl: '',
synopsis: item.description ?? '',
genres: [],
rating: 0,
runtime: 0,
director: '',
cast: [],
sources: [
{
type: sourceType,
name: SOURCE_LABEL[opts.source],
url: locked ? '' : buildMediaUrl(item, opts),
icon: sourceType,
},
],
locked,
...(priceSats !== null ? { priceSats } : {}),
}
}
export function adaptToSong(item: ArchyContentItem, opts: AdaptContentOptions): Song {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
const sourceType = SONG_SOURCE_TYPE[opts.source]
return {
id: item.id,
title: stripExtension(item.filename || ''),
artist: '',
sources: [
{
type: sourceType,
name: SOURCE_LABEL[opts.source],
url: locked ? '' : buildMediaUrl(item, opts),
icon: sourceType,
},
],
locked,
...(priceSats !== null ? { priceSats } : {}),
}
}
/** Exported for shape completeness and future reuse (see `classifyByMime`'s
* doc comment) — not called by `adaptContentItems` today, since
* `ContentItem` carries no signal that would classify an item as a podcast
* rather than a song. */
export function adaptToPodcast(item: ArchyContentItem, opts: AdaptContentOptions): Podcast {
const priceSats = paidPriceSats(item.access)
const locked = priceSats !== null
const sourceType = PODCAST_SOURCE_TYPE[opts.source]
return {
id: item.id,
title: stripExtension(item.filename || ''),
description: item.description ?? '',
sources: [
{
type: sourceType,
name: SOURCE_LABEL[opts.source],
url: locked ? '' : buildMediaUrl(item, opts),
icon: sourceType,
},
],
locked,
...(priceSats !== null ? { priceSats } : {}),
}
}
// ─── Deterministic ordering ─────────────────────────────────────────────────
/**
* Sort by `added_at` descending, `id` ascending as the tiebreak. A missing
* `added_at` sorts as the oldest possible value rather than throwing or
* sorting first. Calling this twice on the same input in a different array
* order yields identical output order — the property the AIUI-03 "ordering"
* edge requires.
*/
export function sortDeterministic(items: ArchyContentItem[]): ArchyContentItem[] {
return [...items].sort((a, b) => {
const at = a.added_at ?? ''
const bt = b.added_at ?? ''
if (at !== bt) return at > bt ? -1 : 1
if (a.id === b.id) return 0
return a.id < b.id ? -1 : 1
})
}
// ─── Entry point ─────────────────────────────────────────────────────────
/**
* Map a batch of `ContentItem`s (all from the same source — this node, one
* peer, or IndeeHub) into grid-ready `Film`/`Song`/`Podcast` records.
*
* - An empty input produces `{ films: [], songs: [], podcasts: [] }` — never
* `undefined`, never a thrown error.
* - Two items with identical `filename`/`size_bytes` but different `id`
* produce two distinct cards — cards key on `id`, never on filename+size
* (the AIUI-03 "adjacency" edge; also T-13-37).
* - Output ordering is deterministic (see `sortDeterministic`).
*/
export function adaptContentItems(
items: ArchyContentItem[] | null | undefined,
opts: AdaptContentOptions,
): ArchyContentBundle {
const sorted = sortDeterministic(items ?? [])
const films: Film[] = []
const songs: Song[] = []
const podcasts: Podcast[] = []
for (const item of sorted) {
const bucket = classifyByMime(item)
if (bucket === 'video') films.push(adaptToFilm(item, opts))
else if (bucket === 'audio') songs.push(adaptToSong(item, opts))
// 'excluded' (image/document/other) — not mistyped into any bucket.
}
return { films, songs, podcasts }
}