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:
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -65,11 +65,16 @@ export interface AIUIChatRequest {
|
||||
* RPC method or params (T-13-34); the broker decides the call. A single
|
||||
* generic channel (not one per content type) so 13-11's music-library wave
|
||||
* can extend `kind` without touching this file again.
|
||||
*
|
||||
* `'library'` (13-11) is the one addition this wave makes to the
|
||||
* discriminator: it routes to `music.list-tracks` (13-07) instead of
|
||||
* `content.*`, since a library track carries real tag-extracted metadata
|
||||
* (artist/album/duration) that `ContentItem` has no field for at all.
|
||||
*/
|
||||
export interface AIUIContentRequest {
|
||||
type: 'content:request'
|
||||
id: string
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all'
|
||||
kind: 'films' | 'songs' | 'podcasts' | 'all' | 'library'
|
||||
scope?: 'own' | 'peers' | 'owned'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user