diff --git a/neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts b/neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts new file mode 100644 index 00000000..f098b611 --- /dev/null +++ b/neode-ui/src/api/__tests__/filebrowserStreamUrl.test.ts @@ -0,0 +1,98 @@ +/** + * Regression pin for T-13-39 — `streamUrl` used to append `?auth=` 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('..') + }) +}) diff --git a/neode-ui/src/api/filebrowser-client.ts b/neode-ui/src/api/filebrowser-client.ts index 42faffb4..ac58ad91 100644 --- a/neode-ui/src/api/filebrowser-client.ts +++ b/neode-ui/src/api/filebrowser-client.ts @@ -165,15 +165,25 @@ class FileBrowserClient { } /** - * Get a direct streaming URL with auth token in query string. - * Use for video/audio 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 `` 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 { 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}` } /** diff --git a/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts b/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts new file mode 100644 index 00000000..8f4c5883 --- /dev/null +++ b/neode-ui/src/composables/__tests__/archyContentAdapter.test.ts @@ -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 { + 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) + }) +}) diff --git a/neode-ui/src/composables/archyContentAdapter.ts b/neode-ui/src/composables/archyContentAdapter.ts new file mode 100644 index 00000000..4e0d510d --- /dev/null +++ b/neode-ui/src/composables/archyContentAdapter.ts @@ -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): 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 = { + own: 'nextcloud', + peer: 'plex', + indeehub: 'indeehub', +} + +const SONG_SOURCE_TYPE: Record = { + 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 = { + own: 'rss', + peer: 'rss', + indeehub: 'rss', +} + +const SOURCE_LABEL: Record = { + 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/`, 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//`, 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 } +}