diff --git a/README.md b/README.md index db013f7..2dc6388 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,6 @@ API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_MAX=120 ``` -Optional Last.fm song of the day: - -```sh -LASTFM_API_KEY=your-lastfm-api-key -LASTFM_USERNAME=your-lastfm-username -LASTFM_HISTORY_FILE=lastfm-song-of-day.json -``` - -The server stores daily picks in `UPTIME_STORAGE_DIR` and avoids repeating songs -until the recent-track pool is exhausted. - ## Reverse proxy / Cloudflare The server only honours `CF-Connecting-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`, diff --git a/example.env b/example.env index 6fc456c..5d0ad09 100644 --- a/example.env +++ b/example.env @@ -20,11 +20,6 @@ ANALYTICS_DATABASE_FILE=viewers.sqlite ANALYTICS_RETENTION_DAYS=30 ANALYTICS_ACTIVE_WINDOW_SECONDS=75 -# Last.fm song of the day. Picks one non-repeated daily track from the configured user's last 7 days. -LASTFM_API_KEY= -LASTFM_USERNAME= -LASTFM_HISTORY_FILE=lastfm-song-of-day.json - API_CACHE_TTL_MS=15000 API_RATE_LIMIT_WINDOW_MS=60000 API_RATE_LIMIT_MAX=120 diff --git a/frontend/index.html b/frontend/index.html index 019fda3..4749f4b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -55,6 +55,9 @@
+ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4eef2d1..dc140c1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,4 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import L from 'leaflet' -import 'leaflet/dist/leaflet.css' import Tree, { prewarmTreeCanvas } from '../Tree/Tree' import FallingLeaves from '../Tree/FallingLeaves' @@ -16,7 +14,6 @@ const apiEndpoints = { viewers: '/api/viewers', viewerEvent: '/api/viewers/event', viewerDelete: '/api/viewers/delete', - songOfDay: '/api/song-of-day', teams: '/api/tss/leaderboard/teams?limit=100', teamsHealth: '/api/tss/leaderboard/teams?limit=1', recentGames: '/api/tss/games/recent?limit=50', @@ -796,10 +793,15 @@ function SiteGate({ onVerified }) { } function App() { - const [gateState, setGateState] = useState(turnstileSiteKey ? 'checking' : 'verified') + const initialGateState = turnstileSiteKey + ? ['verified', 'required'].includes(window.__TSS_TURNSTILE_SESSION__) + ? window.__TSS_TURNSTILE_SESSION__ + : 'checking' + : 'verified' + const [gateState, setGateState] = useState(initialGateState) useEffect(() => { - if (!turnstileSiteKey) return undefined + if (!turnstileSiteKey || gateState !== 'checking') return undefined let cancelled = false fetch('/api/turnstile/session', { headers: { Accept: 'application/json' } }) .then((response) => (response.ok ? response.json() : { verified: false })) @@ -813,10 +815,10 @@ function App() { return () => { cancelled = true } - }, []) + }, [gateState]) if (gateState === 'checking') { - return @@ -2154,193 +2115,6 @@ 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 [previewVolume, setPreviewVolume] = useState(0.18) - const [isPreviewMuted, setIsPreviewMuted] = useState(false) - const message = - songOfDay.status === 'error' - ? songOfDay.error - : previewError || songOfDay.data?.error || 'A random track from the last week of Last.fm scrobbles.' - - useEffect(() => { - setIsPreviewPlaying(false) - setPreviewError('') - }, [track?.id, track?.preview_url]) - - useEffect(() => { - const audio = audioRef.current - if (!audio) return - audio.volume = previewVolume - audio.muted = isPreviewMuted - }, [isPreviewMuted, previewVolume, track?.preview_url]) - - useEffect(() => { - const audio = audioRef.current - if (!track?.preview_url || !audio) return - - audio.volume = previewVolume - audio.muted = isPreviewMuted - audio.play() - .then(() => { - setIsPreviewPlaying(true) - setPreviewError('') - }) - .catch(() => { - setIsPreviewPlaying(false) - }) - }, [isPreviewMuted, previewVolume, 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 ( -
-
- -
-

- Song of the day -

- {track ? ( - <> -

{track.name}

-

- {track.artist}{track.album ? ` · ${track.album}` : ''} -

- {previewError ? ( -

{previewError}

- ) : null} - {track.preview_url ? ( - <> -
- {track?.preview_url ? ( - - ) : null} - {track?.preview_url ? ( - - ) : null} - {track?.url ? ( - - Play - - ) : songOfDay.status === 'error' ? ( - - ) : null} -
-
- ) -} - -function SongArtwork({ src }) { - const [failed, setFailed] = useState(false) - const showImage = src && !failed - - useEffect(() => { - setFailed(false) - }, [src]) - - return ( -
- {showImage ? ( - setFailed(true)} - referrerPolicy="no-referrer" - src={src} - /> - ) : ( - - )} -
- ) -} - function LandingOverview({ teams, matches, navigate }) { const activeTeams = teams.slice(0, 4) const totalPlayers = matches.reduce((sum, match) => sum + Number(match.player_count || 0), 0) @@ -3638,6 +3412,8 @@ function LocationSignalTable({ countries, locations }) { function LocationSignalMap({ countries, locations }) { const mapRef = useRef(null) const markersRef = useRef(null) + const leafletRef = useRef(null) + const [mapReady, setMapReady] = useState(false) const countryMarkerColor = '#e82517' const maxMarkerVisitors = Math.max( 1, @@ -3691,34 +3467,50 @@ function LocationSignalMap({ countries, locations }) { useEffect(() => { if (!mapRef.current || markersRef.current) return undefined + let cancelled = false + let map = null - const map = L.map(mapRef.current, { - center: [25, 0], - maxBounds: [[-85, -220], [85, 220]], - minZoom: 1, - scrollWheelZoom: true, - worldCopyJump: true, - zoom: 1, - }) + async function initializeMap() { + const [{ default: L }] = await Promise.all([ + import('leaflet'), + import('leaflet/dist/leaflet.css'), + ]) + if (cancelled || !mapRef.current) return - L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - maxZoom: 8, - }).addTo(map) + leafletRef.current = L + map = L.map(mapRef.current, { + center: [25, 0], + maxBounds: [[-85, -220], [85, 220]], + minZoom: 1, + scrollWheelZoom: true, + worldCopyJump: true, + zoom: 1, + }) - markersRef.current = { - layer: L.layerGroup().addTo(map), - map, + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + maxZoom: 8, + }).addTo(map) + + markersRef.current = { + layer: L.layerGroup().addTo(map), + map, + } + setMapReady(true) } + initializeMap() return () => { + cancelled = true markersRef.current = null - map.remove() + leafletRef.current = null + map?.remove() } }, []) useEffect(() => { - if (!markersRef.current) return + const L = leafletRef.current + if (!markersRef.current || !L) return const { layer } = markersRef.current layer.clearLayers() @@ -3748,7 +3540,7 @@ function LocationSignalMap({ countries, locations }) { }) .addTo(layer) }) - }, [cityMarkers, countryMarkerColor, countryMarkers, maxMarkerVisitors]) + }, [cityMarkers, countryMarkerColor, countryMarkers, mapReady, maxMarkerVisitors]) return (
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 7234b35..5671800 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,5 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './styles.css' import App from './App.jsx' -createRoot(document.getElementById('root')).render( - - - , -) +createRoot(document.getElementById('root')).render() diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bdf99c2..dd3218a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -109,78 +109,6 @@ color-scheme: light; } -@font-face { - font-display: swap; - font-family: "SF Pro Text Local"; - font-style: normal; - font-weight: 400; - src: url("/fonts/SF-Pro-Text-Regular.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Text Local"; - font-style: normal; - font-weight: 500; - src: url("/fonts/SF-Pro-Text-Medium.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Text Local"; - font-style: normal; - font-weight: 600; - src: url("/fonts/SF-Pro-Text-Semibold.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Text Local"; - font-style: normal; - font-weight: 700; - src: url("/fonts/SF-Pro-Text-Bold.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Text Local"; - font-style: normal; - font-weight: 800; - src: url("/fonts/SF-Pro-Text-Heavy.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Text Local"; - font-style: normal; - font-weight: 900; - src: url("/fonts/SF-Pro-Text-Black.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Rounded Local"; - font-style: normal; - font-weight: 400; - src: url("/fonts/SF-Pro-Rounded-Regular.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Rounded Local"; - font-style: normal; - font-weight: 600; - src: url("/fonts/SF-Pro-Rounded-Semibold.otf") format("opentype"); -} - -@font-face { - font-display: swap; - font-family: "SF Pro Rounded Local"; - font-style: normal; - font-weight: 700 900; - src: url("/fonts/SF-Pro-Rounded-Black.otf") format("opentype"); -} - *, *::before, *::after { @@ -193,8 +121,7 @@ body { min-height: 100vh; background: var(--color-bg); font-family: - "SF Pro Rounded Local", "SF Pro Rounded", "SF Pro Text Local", - "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, + "SF Pro Rounded", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; } @@ -202,8 +129,8 @@ h1, h2, h3 { font-family: - "SF Pro Text Local", "SF Pro Text", -apple-system, BlinkMacSystemFont, - "Segoe UI", Inter, ui-sans-serif, system-ui, sans-serif; + "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, + ui-sans-serif, system-ui, sans-serif; } .pixel-mountains { diff --git a/server.cjs b/server.cjs index d6f8136..96ebe8f 100644 --- a/server.cjs +++ b/server.cjs @@ -46,9 +46,6 @@ const UPTIME_HISTORY_LIMIT = Number(process.env.UPTIME_HISTORY_LIMIT || 336) const ANALYTICS_DATABASE_FILE = process.env.ANALYTICS_DATABASE_FILE || 'viewers.sqlite' const ANALYTICS_RETENTION_DAYS = Number(process.env.ANALYTICS_RETENTION_DAYS || 30) const ANALYTICS_ACTIVE_WINDOW_SECONDS = Number(process.env.ANALYTICS_ACTIVE_WINDOW_SECONDS || 75) -const LASTFM_API_KEY = process.env.LASTFM_API_KEY || '' -const LASTFM_USERNAME = process.env.LASTFM_USERNAME || '' -const LASTFM_HISTORY_FILE = process.env.LASTFM_HISTORY_FILE || 'lastfm-song-of-day.json' const API_CACHE_TTL_MS = Number(process.env.API_CACHE_TTL_MS || 15000) const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000) const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 120) @@ -129,7 +126,7 @@ const CSP_DIRECTIVES = [ "style-src 'self'", "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", + "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com", "media-src https://*.dzcdn.net https://*.deezer.com", "font-src 'self' data:", "connect-src 'self' https://challenges.cloudflare.com", @@ -182,199 +179,6 @@ let uptimeDb = null let analyticsDb = null let latestUptimeSnapshot = null -function songHistoryPath() { - const fileName = path.basename(LASTFM_HISTORY_FILE || 'lastfm-song-of-day.json') - return path.join(uptimeStoragePath(), fileName) -} - -function readSongHistory() { - try { - return JSON.parse(fs.readFileSync(songHistoryPath(), 'utf8')) - } catch { - return { picks: [] } - } -} - -function writeSongHistory(history) { - const storageDir = uptimeStoragePath() - fs.mkdirSync(storageDir, { recursive: true }) - fs.writeFileSync(songHistoryPath(), JSON.stringify(history, null, 2)) -} - -function todayKey() { - return new Date().toISOString().slice(0, 10) -} - -function lastfmImage(track) { - const images = Array.isArray(track.image) ? track.image : [] - const image = [...images].reverse().find((item) => item?.['#text']) - const url = image?.['#text'] || '' - if (!url) return '' - - try { - const parsed = new URL(url) - if (parsed.protocol !== 'https:') return '' - if (parsed.hostname !== 'lastfm.freetls.fastly.net' && !parsed.hostname.endsWith('.lastfm.freetls.fastly.net')) return '' - return parsed.toString() - } catch { - return '' - } -} - -function normalizeLastfmTrack(track) { - const artist = track?.artist?.['#text'] || track?.artist?.name || '' - const name = track?.name || '' - if (!artist || !name) return null - - return { - id: `${artist.toLowerCase()}::${name.toLowerCase()}`, - artist, - name, - album: track?.album?.['#text'] || '', - url: track?.url || '', - image: lastfmImage(track), - played_at: track?.date?.uts ? Number(track.date.uts) : null, - } -} - -function randomItem(items) { - if (!items.length) return null - const index = crypto.randomInt(0, items.length) - 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 { - configured: false, - error: 'Last.fm is not configured', - } - } - - const date = todayKey() - 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 } - } - - const to = Math.floor(Date.now() / 1000) - const from = to - 7 * 24 * 60 * 60 - const url = new URL('https://ws.audioscrobbler.com/2.0/') - url.searchParams.set('method', 'user.getrecenttracks') - url.searchParams.set('user', LASTFM_USERNAME) - url.searchParams.set('api_key', LASTFM_API_KEY) - url.searchParams.set('format', 'json') - url.searchParams.set('from', String(from)) - url.searchParams.set('to', String(to)) - url.searchParams.set('limit', '200') - - const result = await requestJson(url.toString(), 10000) - const tracks = (result.body?.recenttracks?.track || []) - .map(normalizeLastfmTrack) - .filter(Boolean) - - const uniqueTracks = Array.from(new Map(tracks.map((track) => [track.id, track])).values()) - if (!uniqueTracks.length) { - return { - configured: true, - date, - error: 'No Last.fm tracks found from the last week', - } - } - - const previousIds = new Set((history.picks || []).map((pick) => pick.track?.id).filter(Boolean)) - const available = uniqueTracks.filter((track) => !previousIds.has(track.id)) - 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), - { date, track }, - ] - .sort((a, b) => String(a.date).localeCompare(String(b.date))) - .slice(-366), - } - - writeSongHistory(nextHistory) - return { configured: true, date, track } -} - function sendJson(res, status, body, headers = {}) { send(res, status, JSON.stringify(body), { ...jsonHeaders, ...headers }) } @@ -2085,6 +1889,7 @@ function htmlWithSeo(req, data) { .replaceAll('__SEO_ROBOTS__', escapeHtml(seo.robots)) .replaceAll('__SEO_CANONICAL__', escapeHtml(canonicalUrl)) .replaceAll('__SEO_JSON_LD__', routeStructuredData(origin, seo, canonicalUrl).replace(/ { return } - if (req.method === 'GET' && req.url === '/api/song-of-day') { - if (!isSameOriginRequest(req)) { - sendJson(res, 403, { error: 'Song of the day is restricted to this site' }) - return - } - - if (isRateLimited(req)) { - sendJson(res, 429, { error: 'Too many requests' }, { 'retry-after': String(Math.ceil(API_RATE_LIMIT_WINDOW_MS / 1000)) }) - return - } - - songOfTheDay() - .then((data) => sendJson(res, data.error ? 503 : 200, data)) - .catch((error) => sendJson(res, 502, { configured: Boolean(LASTFM_API_KEY && LASTFM_USERNAME), error: 'Last.fm request failed', detail: error.message })) - return - } - if (req.method === 'POST' && req.url === '/api/viewers/event') { if (!isSameOriginRequest(req)) { sendJson(res, 403, { error: 'Analytics events are restricted to this site' }) diff --git a/vite.config.js b/vite.config.js index a63c2ce..04d6fd0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,4 @@ import crypto from 'node:crypto' -import fs from 'node:fs' -import os from 'node:os' import path from 'node:path' import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' @@ -38,193 +36,6 @@ function obfuscate() { const MAX_TEAM_NAME_LENGTH = 80 -function expandHome(filePath) { - if (filePath === '~') return os.homedir() - if (filePath.startsWith(`~${path.sep}`)) return path.join(os.homedir(), filePath.slice(2)) - if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2)) - return filePath -} - -function devSongHistoryPath(env) { - const storageDir = path.resolve(expandHome(env.UPTIME_STORAGE_DIR || '~/tsswebstorage')) - const fileName = path.basename(env.LASTFM_HISTORY_FILE || 'lastfm-song-of-day.json') - return path.join(storageDir, fileName) -} - -function readDevSongHistory(env) { - try { - return JSON.parse(fs.readFileSync(devSongHistoryPath(env), 'utf8')) - } catch { - return { picks: [] } - } -} - -function writeDevSongHistory(env, history) { - const filePath = devSongHistoryPath(env) - fs.mkdirSync(path.dirname(filePath), { recursive: true }) - fs.writeFileSync(filePath, JSON.stringify(history, null, 2)) -} - -function normalizeLastfmTrack(track) { - const artist = track?.artist?.['#text'] || track?.artist?.name || '' - const name = track?.name || '' - if (!artist || !name) return null - - const images = Array.isArray(track.image) ? track.image : [] - const image = [...images].reverse().find((item) => item?.['#text']) - let imageUrl = '' - try { - const parsed = new URL(image?.['#text'] || '') - if ( - parsed.protocol === 'https:' && - (parsed.hostname === 'lastfm.freetls.fastly.net' || - parsed.hostname.endsWith('.lastfm.freetls.fastly.net')) - ) { - imageUrl = parsed.toString() - } - } catch { - imageUrl = '' - } - - return { - id: `${artist.toLowerCase()}::${name.toLowerCase()}`, - artist, - name, - album: track?.album?.['#text'] || '', - url: track?.url || '', - image: imageUrl, - played_at: track?.date?.uts ? Number(track.date.uts) : null, - } -} - -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 response = await fetch(url) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - const body = await response.json() - const candidates = Array.isArray(body?.data) ? 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 devSongOfTheDay(env) { - const apiKey = env.LASTFM_API_KEY || '' - const username = env.LASTFM_USERNAME || '' - if (!apiKey || !username) { - return { status: 503, body: { configured: false, error: 'Last.fm is not configured' } } - } - - const date = new Date().toISOString().slice(0, 10) - const history = readDevSongHistory(env) - 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 } - writeDevSongHistory(env, history) - } - } catch { - // The card still works without a preview. - } - } - return { status: 200, body: { configured: true, date, track: existing.track } } - } - - const to = Math.floor(Date.now() / 1000) - const from = to - 7 * 24 * 60 * 60 - const url = new URL('https://ws.audioscrobbler.com/2.0/') - url.searchParams.set('method', 'user.getrecenttracks') - url.searchParams.set('user', username) - url.searchParams.set('api_key', apiKey) - url.searchParams.set('format', 'json') - url.searchParams.set('from', String(from)) - url.searchParams.set('to', String(to)) - url.searchParams.set('limit', '200') - - const response = await fetch(url) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - const data = await response.json() - const tracks = (data?.recenttracks?.track || []).map(normalizeLastfmTrack).filter(Boolean) - const uniqueTracks = Array.from(new Map(tracks.map((track) => [track.id, track])).values()) - if (!uniqueTracks.length) { - return { status: 503, body: { configured: true, date, error: 'No Last.fm tracks found from the last week' } } - } - - const previousIds = new Set((history.picks || []).map((pick) => pick.track?.id).filter(Boolean)) - const available = uniqueTracks.filter((track) => !previousIds.has(track.id)) - const pool = available.length ? available : uniqueTracks - let track = pool[crypto.randomInt(0, pool.length)] - 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), - { date, track }, - ] - .sort((a, b) => String(a.date).localeCompare(String(b.date))) - .slice(-366), - } - writeDevSongHistory(env, nextHistory) - - return { status: 200, body: { configured: true, date, track } } -} - function sri() { return { name: 'sri', @@ -267,7 +78,6 @@ function isAllowedApiUrl(req) { if (url.pathname === '/api/viewers' && (req.method === 'GET' || req.method === 'HEAD')) return true if (url.pathname === '/api/viewers/event' && req.method === 'POST') return true - if (url.pathname === '/api/song-of-day' && (req.method === 'GET' || req.method === 'HEAD')) return true if (req.method !== 'GET' && req.method !== 'HEAD') return false @@ -314,7 +124,7 @@ function isAllowedApiUrl(req) { } } -function apiGuard(env) { +function apiGuard() { return { name: 'api-guard', configureServer(server) { @@ -324,27 +134,6 @@ function apiGuard(env) { return } - const url = new URL(req.url, 'http://localhost') - if (url.pathname === '/api/song-of-day' && req.method === 'GET') { - try { - const result = await devSongOfTheDay(env) - res.writeHead(result.status, { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff', - }) - res.end(JSON.stringify(result.body)) - } catch (error) { - res.writeHead(502, { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff', - }) - res.end(JSON.stringify({ error: 'Last.fm request failed', detail: error.message })) - } - return - } - if (isAllowedApiUrl(req)) { next() return @@ -418,7 +207,7 @@ export default defineConfig(({ mode }) => { return { root: path.resolve(__dirname, 'frontend'), publicDir: path.resolve(__dirname, 'frontend/public'), - plugins: [comingSoonDev(comingSoon), apiGuard(env), react(), tailwindcss(), obfuscate(), sri()], + plugins: [comingSoonDev(comingSoon), apiGuard(), react(), tailwindcss(), obfuscate(), sri()], build: { outDir: path.resolve(__dirname, 'dist'), emptyOutDir: true,