- 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>
223 lines
7.7 KiB
TypeScript
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))
|
|
},
|
|
}
|
|
}
|