From 4b0ebf421efa6b89773521494f1c9e617f422c15 Mon Sep 17 00:00:00 2001 From: Heidi Date: Wed, 27 May 2026 15:24:14 +0100 Subject: [PATCH] ai generated solutions to our ai generated problems --- server.cjs | 79 +++++++++++++++++++++++++++++++++++++++++++++++++- src/App.jsx | 55 ++++++++++++++++++++++++++++++++++- vite.config.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 3 deletions(-) diff --git a/server.cjs b/server.cjs index c903f2e..571d401 100644 --- a/server.cjs +++ b/server.cjs @@ -130,6 +130,7 @@ const CSP_DIRECTIVES = [ "style-src-elem 'self'", "style-src-attr 'unsafe-inline'", "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://lastfm.freetls.fastly.net https://*.lastfm.freetls.fastly.net", + "media-src https://*.dzcdn.net https://*.deezer.com", "font-src 'self' data:", "connect-src 'self' https://challenges.cloudflare.com", "frame-src https://challenges.cloudflare.com", @@ -242,6 +243,65 @@ function randomItem(items) { return items[index] } +function normalizeComparable(value) { + return String(value || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/&/g, ' and ') + .replace(/[^a-z0-9]+/g, ' ') + .trim() +} + +function previewUrl(value) { + if (!value) return '' + + try { + const parsed = new URL(value) + if (parsed.protocol === 'http:') parsed.protocol = 'https:' + if (parsed.protocol !== 'https:') return '' + if (!parsed.hostname.endsWith('.dzcdn.net') && !parsed.hostname.endsWith('.deezer.com')) return '' + if (!parsed.pathname.endsWith('.mp3')) return '' + return parsed.toString() + } catch { + return '' + } +} + +async function findDeezerPreview(track) { + if (!track?.artist || !track?.name) return null + + const url = new URL('https://api.deezer.com/search/track') + url.searchParams.set('q', `${track.artist} ${track.name}`) + url.searchParams.set('limit', '5') + + const result = await requestJson(url.toString(), 8000) + const candidates = Array.isArray(result.body?.data) ? result.body.data : [] + const targetArtist = normalizeComparable(track.artist) + const targetTitle = normalizeComparable(track.name) + + const matched = candidates.find((candidate) => { + const candidateArtist = normalizeComparable(candidate?.artist?.name) + const candidateTitle = normalizeComparable(candidate?.title_short || candidate?.title) + if (!candidateArtist || !candidateTitle) return false + return ( + candidateTitle === targetTitle && + (candidateArtist === targetArtist || + candidateArtist.includes(targetArtist) || + targetArtist.includes(candidateArtist)) + ) + }) || candidates[0] + + const preview = previewUrl(matched?.preview) + if (!preview) return null + + return { + preview_url: preview, + preview_provider: 'Deezer', + preview_track_url: matched?.link || '', + } +} + async function songOfTheDay() { if (!LASTFM_API_KEY || !LASTFM_USERNAME) { return { @@ -254,6 +314,17 @@ async function songOfTheDay() { const history = readSongHistory() const existing = (history.picks || []).find((pick) => pick.date === date) if (existing?.track) { + if (!existing.track.preview_url) { + try { + const preview = await findDeezerPreview(existing.track) + if (preview) { + existing.track = { ...existing.track, ...preview } + writeSongHistory(history) + } + } catch { + // The Last.fm card still works without a preview. + } + } return { configured: true, date, track: existing.track } } @@ -284,7 +355,13 @@ async function songOfTheDay() { const previousIds = new Set((history.picks || []).map((pick) => pick.track?.id).filter(Boolean)) const available = uniqueTracks.filter((track) => !previousIds.has(track.id)) - const track = randomItem(available.length ? available : uniqueTracks) + let track = randomItem(available.length ? available : uniqueTracks) + try { + const preview = await findDeezerPreview(track) + if (preview) track = { ...track, ...preview } + } catch { + // Preview playback is opportunistic; metadata should still render. + } const nextHistory = { picks: [ ...(history.picks || []).filter((pick) => pick.date !== date), diff --git a/src/App.jsx b/src/App.jsx index 263f1f1..bfaeb2c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1900,10 +1900,37 @@ function Landing({ function SongOfDayCard({ onRetry, songOfDay }) { const track = songOfDay.data?.track const isLoading = songOfDay.status === 'loading' + const audioRef = useRef(null) + const [isPreviewPlaying, setIsPreviewPlaying] = useState(false) + const [previewError, setPreviewError] = useState('') const message = songOfDay.status === 'error' ? songOfDay.error - : songOfDay.data?.error || 'A random track from the last week of Last.fm scrobbles.' + : previewError || songOfDay.data?.error || 'A random track from the last week of Last.fm scrobbles.' + + useEffect(() => { + setIsPreviewPlaying(false) + setPreviewError('') + }, [track?.id, track?.preview_url]) + + function togglePreview() { + const audio = audioRef.current + if (!audio) return + + setPreviewError('') + if (!audio.paused) { + audio.pause() + setIsPreviewPlaying(false) + return + } + + audio.play() + .then(() => setIsPreviewPlaying(true)) + .catch(() => { + setIsPreviewPlaying(false) + setPreviewError('Preview could not play') + }) + } return (
@@ -1919,6 +1946,23 @@ function SongOfDayCard({ onRetry, songOfDay }) {

{track.artist}{track.album ? ` ยท ${track.album}` : ''}

+ {previewError ? ( +

{previewError}

+ ) : null} + {track.preview_url ? ( +