- Add VideoPlayerOverlay component for free film playback - Add GuidePage with interactive node setup walkthrough - Add freeFilms data catalog with public domain films - Enhance PlayerBar with video support and queue management - Add video player store for overlay state management - Refactor music search plugin (Jamendo integration cleanup) - Add PWA cache version purge mechanism in main.ts - Add PWA icon cache fix skill for Brave/Chrome - Improve content grids: loading states, image fallbacks - Enhance useArchy composable with node context - Update useNostr with relay pool management - Expand chat store with guide conversation support - Add test fixtures for guide and node demo prompts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import type { Plugin } from 'vite'
|
|
import type { Connect } from 'vite'
|
|
|
|
export interface MusicSearchResult {
|
|
source: 'wavlake'
|
|
type: 'stream'
|
|
url: string
|
|
title?: string
|
|
artist?: string
|
|
coverUrl?: string
|
|
duration?: number
|
|
trackId?: string
|
|
albumTitle?: string
|
|
wavlakeUrl?: string
|
|
}
|
|
|
|
// ─── Wavlake API types ───────────────────────────────────────
|
|
|
|
interface WavlakeSearchItem {
|
|
id: string
|
|
title?: string
|
|
name?: string
|
|
type: 'track' | 'artist' | 'album'
|
|
albumArtUrl?: string
|
|
artistArtUrl?: string
|
|
artistId?: string
|
|
albumId?: string
|
|
albumTitle?: string
|
|
mediaUrl?: string
|
|
artist?: string
|
|
duration?: number
|
|
}
|
|
|
|
interface WavlakeTrack {
|
|
id: string
|
|
title: string
|
|
albumTitle?: string
|
|
artist: string
|
|
artistId?: string
|
|
albumId?: string
|
|
artistArtUrl?: string
|
|
albumArtUrl?: string
|
|
mediaUrl: string
|
|
duration?: number
|
|
msatTotal?: string
|
|
url?: string
|
|
}
|
|
|
|
// ─── Server-side LRU cache ────────────────────────────────────
|
|
|
|
const CACHE_MAX = 200
|
|
const CACHE_TTL = 60 * 60 * 1000 // 1 hour for positive results
|
|
const NULL_CACHE_TTL = 5 * 60 * 1000 // 5 minutes for not-found results
|
|
const searchCache = new Map<string, { result: MusicSearchResult | null; ts: number }>()
|
|
|
|
function getCached(key: string): MusicSearchResult | null | undefined {
|
|
const entry = searchCache.get(key)
|
|
if (!entry) return undefined
|
|
const ttl = entry.result ? CACHE_TTL : NULL_CACHE_TTL
|
|
if (Date.now() - entry.ts > 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 ─────────────────────────────────────────────────
|
|
|
|
function scoreTrack(
|
|
track: { title?: string; name?: string; artist?: string },
|
|
title: string,
|
|
artist: string,
|
|
): number {
|
|
const t = (track.title ?? track.name ?? '').toLowerCase()
|
|
const a = (track.artist ?? '').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
|
|
}
|
|
|
|
// ─── Wavlake search ──────────────────────────────────────────
|
|
|
|
const WAVLAKE_API = 'https://wavlake.com/api/v1'
|
|
|
|
async function wavlakeSearch(term: string): Promise<WavlakeSearchItem[]> {
|
|
const res = await fetch(
|
|
`${WAVLAKE_API}/content/search?term=${encodeURIComponent(term)}`,
|
|
{ signal: AbortSignal.timeout(8000) },
|
|
)
|
|
if (!res.ok) return []
|
|
const items = (await res.json()) as WavlakeSearchItem[]
|
|
return Array.isArray(items) ? items : []
|
|
}
|
|
|
|
/** Strip parenthetical suffixes, "feat.", and other noise from titles */
|
|
function cleanTitle(t: string): string {
|
|
return t
|
|
.replace(/\s*[\(\[].*?[\)\]]/g, '') // (feat. X), [Remix], etc.
|
|
.replace(/\s*[-–—]\s*(feat|ft|featuring)\.?\s*.*/i, '')
|
|
.trim()
|
|
}
|
|
|
|
async function searchWavlake(
|
|
q: string,
|
|
title?: string,
|
|
artist?: string,
|
|
): Promise<MusicSearchResult | null> {
|
|
try {
|
|
// Wavlake search works best with short queries — combining title+artist
|
|
// often returns nothing. Strategy: search title first, fall back to
|
|
// cleaned title, combined query, then artist-only.
|
|
const searches: string[] = []
|
|
if (title) searches.push(title)
|
|
const cleaned = title ? cleanTitle(title) : ''
|
|
if (cleaned && cleaned !== title) searches.push(cleaned)
|
|
if (title && artist) searches.push(`${title} ${artist}`)
|
|
if (artist) searches.push(artist)
|
|
if (!title && !artist) searches.push(q)
|
|
|
|
let allTracks: WavlakeSearchItem[] = []
|
|
|
|
for (const term of searches) {
|
|
const items = await wavlakeSearch(term)
|
|
const tracks = items.filter(
|
|
(item): item is WavlakeSearchItem & { mediaUrl: string } =>
|
|
item.type === 'track' && !!item.mediaUrl,
|
|
)
|
|
if (tracks.length > 0) {
|
|
allTracks = tracks
|
|
break
|
|
}
|
|
}
|
|
|
|
if (allTracks.length === 0) return null
|
|
|
|
// Score and pick best match using both title and artist
|
|
const best =
|
|
title && artist && allTracks.length > 1
|
|
? allTracks.reduce((a, b) =>
|
|
scoreTrack(b, title, artist) > scoreTrack(a, title, artist) ? b : a,
|
|
)
|
|
: allTracks[0]
|
|
|
|
return {
|
|
source: 'wavlake',
|
|
type: 'stream',
|
|
url: best.mediaUrl,
|
|
title: best.title ?? best.name,
|
|
artist: best.artist,
|
|
coverUrl: best.albumArtUrl ?? best.artistArtUrl,
|
|
duration: best.duration,
|
|
trackId: best.id,
|
|
albumTitle: best.albumTitle,
|
|
wavlakeUrl: `https://wavlake.com/track/${best.id}`,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ─── Wavlake rankings ────────────────────────────────────────
|
|
|
|
async function getWavlakeRankings(
|
|
days: number,
|
|
genre?: string,
|
|
limit?: number,
|
|
): Promise<MusicSearchResult[]> {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
sort: 'sats',
|
|
days: String(days),
|
|
limit: String(limit ?? 20),
|
|
})
|
|
if (genre) params.set('genre', genre)
|
|
|
|
const res = await fetch(`${WAVLAKE_API}/content/rankings?${params}`, {
|
|
signal: AbortSignal.timeout(8000),
|
|
})
|
|
if (!res.ok) return []
|
|
|
|
const tracks = (await res.json()) as WavlakeTrack[]
|
|
if (!Array.isArray(tracks)) return []
|
|
|
|
return tracks
|
|
.filter(t => !!t.mediaUrl)
|
|
.map(t => ({
|
|
source: 'wavlake' as const,
|
|
type: 'stream' as const,
|
|
url: t.mediaUrl,
|
|
title: t.title,
|
|
artist: t.artist,
|
|
coverUrl: t.albumArtUrl ?? t.artistArtUrl,
|
|
duration: t.duration,
|
|
trackId: t.id,
|
|
albumTitle: t.albumTitle,
|
|
wavlakeUrl: t.url ?? `https://wavlake.com/track/${t.id}`,
|
|
}))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
// ─── Vite middleware: search ─────────────────────────────────
|
|
|
|
function createSearchMiddleware() {
|
|
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 {
|
|
// Normalize cache key: use title|artist when available (q is ignored by searchWavlake in that case)
|
|
const key = (title || artist) ? `search|${title ?? ''}|${artist ?? ''}` : `search|${q}||`
|
|
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 on Wavlake' }))
|
|
return
|
|
}
|
|
|
|
console.log(`[music-search] Wavlake query: q="${q}" title="${title ?? ''}" artist="${artist ?? ''}"`)
|
|
const result = await searchWavlake(q, title ?? undefined, artist ?? undefined)
|
|
console.log(`[music-search] Result: ${result ? `"${result.title}" by ${result.artist}` : 'null'}`)
|
|
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 on Wavlake' }))
|
|
} catch (err) {
|
|
console.error('[music-search]', err)
|
|
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: String(err) }))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Vite middleware: rankings ────────────────────────────────
|
|
|
|
function createRankingsMiddleware() {
|
|
const rankingsCache = new Map<string, { data: MusicSearchResult[]; ts: number }>()
|
|
const RANKINGS_TTL = 10 * 60 * 1000 // 10 minutes
|
|
|
|
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 days = parseInt(url.searchParams.get('days') ?? '7', 10)
|
|
const genre = url.searchParams.get('genre')?.trim() || undefined
|
|
const limit = parseInt(url.searchParams.get('limit') ?? '20', 10)
|
|
|
|
const cacheKey = `rankings|${days}|${genre ?? ''}|${limit}`
|
|
const cached = rankingsCache.get(cacheKey)
|
|
if (cached && Date.now() - cached.ts < RANKINGS_TTL) {
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
res.setHeader('Cache-Control', 'public, max-age=600')
|
|
res.setHeader('X-Cache', 'HIT')
|
|
res.end(JSON.stringify(cached.data))
|
|
return
|
|
}
|
|
|
|
try {
|
|
const results = await getWavlakeRankings(days, genre, limit)
|
|
rankingsCache.set(cacheKey, { data: results, ts: Date.now() })
|
|
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
res.setHeader('Cache-Control', 'public, max-age=600')
|
|
res.end(JSON.stringify(results))
|
|
} catch (err) {
|
|
console.error('[music-rankings]', err)
|
|
res.writeHead(502, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: String(err) }))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Plugin ──────────────────────────────────────────────────
|
|
|
|
export function musicSearchPlugin(): Plugin {
|
|
return {
|
|
name: 'aiui-music-search',
|
|
configureServer(server) {
|
|
server.middlewares.use('/api/music/search', createSearchMiddleware())
|
|
server.middlewares.use('/api/music/rankings', createRankingsMiddleware())
|
|
},
|
|
configurePreviewServer(server) {
|
|
server.middlewares.use('/api/music/search', createSearchMiddleware())
|
|
server.middlewares.use('/api/music/rankings', createRankingsMiddleware())
|
|
},
|
|
}
|
|
}
|