feat(13-11): wire requestArchyLibrary + fire content/library fetch from a live init-time event (GAP-FOUND)

AIUI asks for the library the same way it asks for content, and the
fetch now actually fires without anyone typing a magic phrase:

- useArchy.ts: requestArchyLibrary(scope) sibling of requestArchyContent
  (13-06), same bridge call with kind: 'library', routed through the
  existing setArchyContent so the songs bucket fills exactly the way
  films already does.
- init() now calls both requestArchyContent('all','own') and
  requestArchyLibrary('own') once, fire-and-forget, immediately after
  archyBridge.init() — the GAP-FOUND fix. 13-06 built the whole
  content:request/content:push machinery and unit-tested it end to end,
  but nothing in the live UI ever called it (13-06-SUMMARY.md's Known
  Limitations); the fetch is now triggered by a real init-time UI event,
  not merely callable.
- useContentPanel.ts's setArchyContent now also opens the panel and
  populates availableTabs/activeTab/panelTitle when Archy supplied
  non-empty content — previously only the data refs were set while the
  tab bar and panelOpen stayed whatever the last regex-driven chat turn
  left them, so real content could sit fully populated and still never
  render. An empty bundle never force-opens the panel.

Deviation (Rule 2, mirrors 13-06's own archyBridge.ts precedent): kind:
'library' genuinely needs a different node-side RPC (music.list-tracks,
real tag-extracted metadata) than content.* (ContentItem has no artist/
album/duration field at all) — contextBroker.ts's handleContentRequest
gained one branch (fetchLibraryContent) to route it, and
aiui-protocol.ts's AIUIContentRequest.kind union gained the 'library'
literal, and archyBridge.ts's requestArchyContent kind param widened to
match. No second channel, no new message type, no new listener — the
existing content:request/content:push channel and its kind discriminator
carry this exactly as 13-06 designed it to. Full detail in the SUMMARY.

neode-ui: 926/926 tests green, vue-tsc -b clean. aiui: 341/344 (3
pre-existing, documented failures unrelated to this plan — 13-06/13-10
already recorded them), vue-tsc --noEmit clean.
This commit is contained in:
archipelago
2026-08-05 18:29:17 -04:00
parent abe77ebe7e
commit d25aea125f
8 changed files with 288 additions and 3 deletions
@@ -252,5 +252,64 @@ describe('ContextBroker', () => {
)
expect(freshCalls).toHaveLength(1)
})
// 13-11: kind: 'library' is the one addition this wave makes to the
// discriminator — it resolves to music.list-tracks, not content.*,
// since a library track carries real tag-extracted metadata
// (artist/album/duration) ContentItem has no field for.
it("kind: 'library' calls music.list-tracks (not content.list-mine) and adapts the result into the songs bucket", async () => {
const perms = useAIPermissionsStore()
perms.enableAll()
vi.mocked(rpcClient.call).mockResolvedValueOnce({
tracks: [
{
id: { source: 'OwnLibrary', path: '/var/lib/archipelago/filebrowser/Music/Artist/Song.flac' },
title: 'Song',
artist: 'Artist',
album: 'Album',
album_artist: 'Artist',
duration_secs: 200,
has_tags: true,
},
],
})
await callContentRequest('req-lib', 'library', 'own')
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'music.list-tracks' }))
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'content.list-mine' }))
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'content:push',
id: 'req-lib',
kind: 'library',
permitted: true,
films: [],
songs: expect.arrayContaining([expect.objectContaining({ title: 'Song', artist: 'Artist', album: 'Album' })]),
}),
expect.any(String),
)
})
it("kind: 'library' degrades to an empty songs bucket (not a thrown error) when music.list-tracks fails", async () => {
const perms = useAIPermissionsStore()
perms.enableAll()
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('no index yet'))
await callContentRequest('req-lib-err', 'library', 'own')
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'content:push',
id: 'req-lib-err',
kind: 'library',
permitted: true,
films: [],
songs: [],
podcasts: [],
}),
expect.any(String),
)
})
})
})
+35 -1
View File
@@ -15,8 +15,10 @@ import { rpcClient } from '@/api/rpc-client'
import { fileBrowserClient } from '@/api/filebrowser-client'
import {
adaptContentItems,
adaptLibraryTracks,
type ArchyContentBundle,
type ArchyContentItem,
type ArchyLibraryTrack,
} from '@/composables/archyContentAdapter'
/** Wire shape of `content_owned::OwnedItem` (already-purchased peer
@@ -366,7 +368,11 @@ export class ContextBroker {
const requestedScope: 'own' | 'peers' | 'owned' =
scope === 'peers' || scope === 'owned' ? scope : 'own'
const bundle = await this.fetchAdaptedContent(requestedScope)
// 13-11: 'library' is the one kind value this wave adds — it resolves
// to music.list-tracks (real tag-extracted metadata) instead of
// content.* (see aiui-protocol.ts's AIUIContentRequest doc comment).
const bundle =
kind === 'library' ? await this.fetchLibraryContent() : await this.fetchAdaptedContent(requestedScope)
// Stale-response guard: a newer content:request has since started —
// discard this result instead of flipping the grids back to older
@@ -424,6 +430,34 @@ export class ContextBroker {
}
}
/**
* 13-11: resolve a `content:request` whose `kind` is `'library'`. Calls
* `music.list-tracks` (13-07) directly, not `content.*` — a library
* track carries real tag-extracted metadata (artist/album/duration)
* `ContentItem` has no field for at all, so `adaptContentItems`'s
* generic mapping cannot produce it (`adaptToSong` always sets
* `artist: ''`). `music.*` rides the same authenticated session as every
* other RPC this broker calls; no separate permission check is added
* here beyond `handleContentRequest`'s existing media/files gate, which
* already ran before this is reached. A page cap of 500 matches
* `music.list-tracks`'s own `MAX_TRACK_LIMIT` (T-13-41/T-13-73) — this
* is the one-page-at-a-time truth the RPC itself enforces, not a second
* cap invented here. Any failure (no index yet, RPC error) degrades to
* an empty songs bucket rather than failing the whole request, matching
* `fetchAdaptedContent`'s own error handling below.
*/
private async fetchLibraryContent(): Promise<ArchyContentBundle> {
try {
const res = await rpcClient.call<{ tracks: ArchyLibraryTrack[] }>({
method: 'music.list-tracks',
params: { limit: 500 },
})
return { films: [], songs: adaptLibraryTracks(res.tracks ?? []), podcasts: [] }
} catch {
return emptyBundle()
}
}
private async handleContextRequest(id: string, category: AIContextCategory, query?: string) {
const perms = useAIPermissionsStore()