Files
archy/packages/app/vite-music-search.ts
T
DorianandClaude Opus 4.6 493657549e fix(app): harden persistence, player performance, content extraction
- 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>
2026-03-04 17:34:21 +00:00

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))
},
}
}