- Fix chat persistence: unwrap Vue Proxy objects before IDB storage, flush pending saves on page unload/visibility change, use immediate saves for conversation creation and seed migration - Fix player performance: parallel music search across providers, server-side LRU cache, client-side result cache, audio element reuse, instant UI feedback, abort stale searches, next-song prefetch - Fix content extraction: strip recipe/event tags in stripContentTags, extend cleanMagazineContent regex for all _ext patterns - Fix PlayerBar: move to App.vue root to avoid stacking context clipping, remove unused isDark conditionals (dark-only app) - Add /seed command: loads 15 seed conversations from fixture index, opens history panel, switches to first seed conversation - Add seed prompt index: 15 realistic AI prompt/response pairs covering films, songs, books, TV, places, podcasts, code, images, recipes, events - Add seedExtraction.test.ts: 60 tests validating extraction counts, tag stripping completeness, and data integrity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
import type { Plugin } from 'vite'
|
|
import type { Connect } from 'vite'
|
|
import { loadEnv } from 'vite'
|
|
|
|
export interface MusicSearchResult {
|
|
source: string
|
|
type: 'stream' | 'embed'
|
|
url: string
|
|
title?: string
|
|
artist?: string
|
|
}
|
|
|
|
// ─── Server-side LRU cache ────────────────────────────────────
|
|
|
|
const CACHE_MAX = 200
|
|
const CACHE_TTL = 60 * 60 * 1000 // 1 hour
|
|
const searchCache = new Map<string, { result: MusicSearchResult | null; ts: number }>()
|
|
|
|
function cacheKey(q: string, title?: string, artist?: string): string {
|
|
return `${q}|${title ?? ''}|${artist ?? ''}`
|
|
}
|
|
|
|
function getCached(key: string): MusicSearchResult | null | undefined {
|
|
const entry = searchCache.get(key)
|
|
if (!entry) return undefined
|
|
if (Date.now() - entry.ts > CACHE_TTL) {
|
|
searchCache.delete(key)
|
|
return undefined
|
|
}
|
|
return entry.result
|
|
}
|
|
|
|
function setCache(key: string, result: MusicSearchResult | null) {
|
|
if (searchCache.size >= CACHE_MAX) {
|
|
const oldest = searchCache.keys().next().value
|
|
if (oldest) searchCache.delete(oldest)
|
|
}
|
|
searchCache.set(key, { result, ts: Date.now() })
|
|
}
|
|
|
|
// ─── Scoring helpers ──────────────────────────────────────────
|
|
|
|
function scoreIAResult(doc: { title?: string; creator?: string }, title: string, artist: string): number {
|
|
const t = (doc.title ?? '').toLowerCase()
|
|
const c = (Array.isArray(doc.creator) ? doc.creator.join(' ') : (doc.creator ?? '')).toLowerCase()
|
|
const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
|
|
const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
|
|
let score = 0
|
|
for (const term of titleTerms) {
|
|
if (t.includes(term)) score += 2
|
|
}
|
|
for (const term of artistTerms) {
|
|
if (c.includes(term) || t.includes(term)) score += 2
|
|
}
|
|
return score
|
|
}
|
|
|
|
function scoreJamendoTrack(
|
|
track: { name: string; artist_name: string },
|
|
title: string,
|
|
artist: string,
|
|
): number {
|
|
const t = track.name.toLowerCase()
|
|
const a = track.artist_name.toLowerCase()
|
|
const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
|
|
const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
|
|
let score = 0
|
|
for (const term of titleTerms) {
|
|
if (t.includes(term)) score += 2
|
|
}
|
|
for (const term of artistTerms) {
|
|
if (a.includes(term)) score += 2
|
|
}
|
|
return score
|
|
}
|
|
|
|
function scoreOdyseeItem(name: string, title: string, artist: string): number {
|
|
const n = name.toLowerCase().replace(/-/g, ' ')
|
|
const titleTerms = title.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
|
|
const artistTerms = artist.toLowerCase().split(/\s+/).filter((w) => w.length > 1)
|
|
let score = 0
|
|
for (const term of titleTerms) {
|
|
if (n.includes(term)) score += 2
|
|
}
|
|
for (const term of artistTerms) {
|
|
if (n.includes(term)) score += 2
|
|
}
|
|
return score
|
|
}
|
|
|
|
// ─── Provider search functions ────────────────────────────────
|
|
|
|
async function searchInternetArchive(q: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
q: `mediatype:audio ${q}`,
|
|
fl: ['identifier', 'title', 'creator'].join(','),
|
|
output: 'json',
|
|
rows: '10',
|
|
})
|
|
const res = await fetch(
|
|
`https://archive.org/advancedsearch.php?${params}`,
|
|
{ headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(8000) },
|
|
)
|
|
if (!res.ok) return null
|
|
const data = (await res.json()) as { response?: { docs?: { identifier: string; title?: string; creator?: string }[] } }
|
|
const docs = data.response?.docs ?? []
|
|
if (docs.length === 0) return null
|
|
const doc =
|
|
title && artist && docs.length > 1
|
|
? docs.reduce((best, d) =>
|
|
scoreIAResult(d, title, artist) > scoreIAResult(best, title, artist) ? d : best,
|
|
)
|
|
: docs[0]
|
|
if (title && artist && scoreIAResult(doc, title, artist) === 0) {
|
|
return null
|
|
}
|
|
const meta = await fetch(`https://archive.org/metadata/${doc.identifier}`, {
|
|
signal: AbortSignal.timeout(5000),
|
|
})
|
|
.then((r) => r.json())
|
|
.catch(() => null)
|
|
const files = (meta as { files?: { name: string; format?: string }[] })?.files ?? []
|
|
const audio = files.find(
|
|
(f: { name: string; format?: string }) =>
|
|
['VBR MP3', 'MP3', 'OGG Vorbis', '128Kbps MP3', 'Flac'].includes((f.format ?? '').toString()) ||
|
|
/\.(mp3|ogg|m4a|flac)$/i.test(f.name),
|
|
)
|
|
if (!audio) return null
|
|
const streamUrl = `https://archive.org/download/${doc.identifier}/${encodeURIComponent(audio.name)}`
|
|
return {
|
|
source: 'internet_archive',
|
|
type: 'stream',
|
|
url: streamUrl,
|
|
title: doc.title,
|
|
artist: Array.isArray(doc.creator) ? doc.creator[0] : doc.creator,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function searchJamendo(q: string, clientId: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
client_id: clientId,
|
|
search: q,
|
|
limit: '5',
|
|
format: 'json',
|
|
})
|
|
const res = await fetch(`https://api.jamendo.com/v3.0/tracks/?${params}`, {
|
|
signal: AbortSignal.timeout(6000),
|
|
})
|
|
if (!res.ok) return null
|
|
const data = (await res.json()) as { results?: { id: string; name: string; artist_name: string; audio: string }[] }
|
|
const results = data.results ?? []
|
|
const track =
|
|
title && artist && results.length > 1
|
|
? results.reduce((best, r) =>
|
|
scoreJamendoTrack(r, title, artist) > scoreJamendoTrack(best, title, artist) ? r : best,
|
|
)
|
|
: results[0]
|
|
if (!track?.audio) return null
|
|
if (!/^https?:\/\//i.test(track.audio)) return null
|
|
if (title && artist && scoreJamendoTrack(track, title, artist) === 0) {
|
|
return null
|
|
}
|
|
return {
|
|
source: 'jamendo',
|
|
type: 'stream',
|
|
url: track.audio,
|
|
title: track.name,
|
|
artist: track.artist_name,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function searchOdysee(q: string, title?: string, artist?: string): Promise<MusicSearchResult | null> {
|
|
try {
|
|
const res = await fetch(
|
|
`https://lighthouse.odysee.tv/search?s=${encodeURIComponent(q)}&size=8`,
|
|
{ signal: AbortSignal.timeout(6000) },
|
|
)
|
|
if (!res.ok) return null
|
|
const items = (await res.json()) as { name: string; claimId: string }[]
|
|
if (!Array.isArray(items) || items.length === 0) return null
|
|
const item =
|
|
title && artist && items.length > 1
|
|
? items.reduce((best, i) =>
|
|
scoreOdyseeItem(i.name, title, artist) > scoreOdyseeItem(best.name, title, artist) ? i : best,
|
|
)
|
|
: items[0]
|
|
if (!item?.claimId) return null
|
|
if (title && artist && scoreOdyseeItem(item.name, title, artist) === 0) {
|
|
return null
|
|
}
|
|
const embedUrl = `https://odysee.com/$/embed/${item.name.replace(/^#/, '')}`
|
|
return {
|
|
source: 'odysee',
|
|
type: 'embed',
|
|
url: embedUrl,
|
|
title: item.name?.replace(/-/g, ' '),
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ─── Parallel search with preference ordering ─────────────────
|
|
|
|
async function searchAllProviders(
|
|
q: string,
|
|
jamendoClientId: string | undefined,
|
|
title?: string,
|
|
artist?: string,
|
|
): Promise<MusicSearchResult | null> {
|
|
// Fire all providers in parallel — streams are preferred over embeds
|
|
const providers = [
|
|
searchInternetArchive(q, title, artist),
|
|
jamendoClientId ? searchJamendo(q, jamendoClientId, title, artist) : Promise.resolve(null),
|
|
searchOdysee(q, title, artist),
|
|
]
|
|
|
|
const results = await Promise.allSettled(providers)
|
|
|
|
// Prefer streams (IA, Jamendo) over embeds (Odysee)
|
|
for (const r of results) {
|
|
if (r.status === 'fulfilled' && r.value?.type === 'stream') return r.value
|
|
}
|
|
for (const r of results) {
|
|
if (r.status === 'fulfilled' && r.value) return r.value
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ─── Vite middleware ──────────────────────────────────────────
|
|
|
|
function createMusicSearchMiddleware(
|
|
jamendoClientId: string | undefined,
|
|
) {
|
|
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
|
|
if (req.method !== 'GET') return next()
|
|
const url = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
|
|
const q = url.searchParams.get('q')?.trim()
|
|
const title = url.searchParams.get('title')?.trim()
|
|
const artist = url.searchParams.get('artist')?.trim()
|
|
if (!q) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: 'Missing q (query)' }))
|
|
return
|
|
}
|
|
try {
|
|
const key = cacheKey(q, title ?? undefined, artist ?? undefined)
|
|
const cached = getCached(key)
|
|
if (cached !== undefined) {
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
res.setHeader('Cache-Control', 'public, max-age=3600')
|
|
res.setHeader('X-Cache', 'HIT')
|
|
res.end(JSON.stringify(cached ?? { error: 'No results from any source' }))
|
|
return
|
|
}
|
|
|
|
const result = await searchAllProviders(q, jamendoClientId, title ?? undefined, artist ?? undefined)
|
|
setCache(key, result)
|
|
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
res.setHeader('Cache-Control', 'public, max-age=3600')
|
|
res.end(JSON.stringify(result ?? { error: 'No results from any source' }))
|
|
} catch (err) {
|
|
console.error('[music-search]', err)
|
|
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: String(err) }))
|
|
}
|
|
}
|
|
}
|
|
|
|
export function musicSearchPlugin(): Plugin {
|
|
let jamendoClientId: string | undefined
|
|
|
|
return {
|
|
name: 'aiui-music-search',
|
|
configResolved(config) {
|
|
const env = loadEnv(config.mode, config.root ?? process.cwd(), '')
|
|
jamendoClientId = env.JAMENDO_CLIENT_ID ?? env.VITE_JAMENDO_CLIENT_ID
|
|
},
|
|
configureServer(server) {
|
|
server.middlewares.use('/api/music/search', createMusicSearchMiddleware(jamendoClientId))
|
|
},
|
|
configurePreviewServer(server) {
|
|
server.middlewares.use('/api/music/search', createMusicSearchMiddleware(jamendoClientId))
|
|
},
|
|
}
|
|
}
|