Files
archy/packages/app/vite-music-search.ts
T
DorianandClaude Opus 4.6 c82b4ef54f feat(app): redesign magazine grid, improve article views and proxy
- Redesign MagazineGrid with editorial New Yorker-inspired layout
- Simplify ArticleDetail and ArticleOverlay components
- Enhance claude-proxy with improved content extraction
- Add HTML utility for content processing
- Update NewsCard styling and chat message handling
- Clean up worktree references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:50:46 +00:00

223 lines
7.7 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
}
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
}
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' } },
)
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}`)
.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
}
}
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
}
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}`)
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
}
}
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
}
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`,
)
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
}
}
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 result =
(await searchInternetArchive(q, title ?? undefined, artist ?? undefined)) ??
(jamendoClientId ? await searchJamendo(q, jamendoClientId, title ?? undefined, artist ?? undefined) : null) ??
(await searchOdysee(q, title ?? undefined, artist ?? undefined))
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))
},
}
}