From 69fc002961286204fee5b0ab42f3b9c71cb5de3e Mon Sep 17 00:00:00 2001 From: Heidi Date: Wed, 27 May 2026 14:49:26 +0100 Subject: [PATCH] meow && add song of the day! --- README.md | 11 +++++ example.env | 5 ++ server.cjs | 128 ++++++++++++++++++++++++++++++++++++++++++++++++- src/App.jsx | 78 ++++++++++++++++++++++++++++++ vite.config.js | 127 ++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 345 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 86d603b..7fcef94 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,17 @@ 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 1d2596a..f1f80a7 100644 --- a/example.env +++ b/example.env @@ -14,6 +14,11 @@ 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/server.cjs b/server.cjs index 12aaf8d..1687a19 100644 --- a/server.cjs +++ b/server.cjs @@ -46,6 +46,9 @@ 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) @@ -123,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", + "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://lastfm.freetls.fastly.net https://*.lastfm.freetls.fastly.net", "font-src 'self' data:", "connect-src 'self' https://challenges.cloudflare.com", "frame-src https://challenges.cloudflare.com", @@ -175,6 +178,112 @@ let uptimeDb = null let analyticsDb = null let latestUptimeSnapshot = null +function songHistoryPath() { + return path.join(uptimeStoragePath(), LASTFM_HISTORY_FILE) +} + +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']) + return image?.['#text'] || '' +} + +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] +} + +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) { + 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)) + const track = randomItem(available.length ? available : uniqueTracks) + 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 }) } @@ -1792,6 +1901,23 @@ const server = http.createServer((req, res) => { return } + if (req.method === 'GET' && req.url === '/api/lastfm/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/src/App.jsx b/src/App.jsx index cfb1111..34abaa3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -16,6 +16,7 @@ const apiEndpoints = { viewers: '/api/viewers', viewerEvent: '/api/viewers/event', viewerDelete: '/api/viewers/delete', + songOfDay: '/api/lastfm/song-of-day', teams: '/api/tss/leaderboard/teams?limit=100', teamsHealth: '/api/tss/leaderboard/teams?limit=1', resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`, @@ -666,6 +667,7 @@ function AppContent() { const [live, setLive] = useState({ status: 'idle', data: null, error: null, updatedAt: 0 }) const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null }) const [viewers, setViewers] = useState({ status: 'idle', data: null, error: null, updatedAt: null }) + const [songOfDay, setSongOfDay] = useState({ status: 'idle', data: null, error: null }) const [analyticsPreferences, setAnalyticsPreferences] = useState(() => storedAnalyticsPreferences()) const [theme, setTheme] = useState(() => storedThemePreference()) const [showFloatingNav, setShowFloatingNav] = useState(() => window.scrollY > 40) @@ -1006,6 +1008,24 @@ function AppContent() { } }, [route.page, teams]) + useEffect(() => { + if (route.page !== 'home') return + if (songOfDay.status === 'ready' || songOfDay.status === 'loading') return + + const controller = new AbortController() + setSongOfDay({ status: 'loading', data: null, error: null }) + + fetchJson(apiEndpoints.songOfDay, controller.signal) + .then((data) => setSongOfDay({ status: 'ready', data, error: null })) + .catch((error) => { + if (!controller.signal.aborted) { + setSongOfDay({ status: 'error', data: null, error: error.message }) + } + }) + + return () => controller.abort() + }, [route.page, songOfDay.status]) + useEffect(() => { if (route.page !== 'team' || !route.teamName) return @@ -1388,6 +1408,7 @@ function AppContent() { onTeamSearch={handleTeamSearch} searchPlaceholder={searchPlaceholder} setTeamQuery={setTeamQuery} + songOfDay={songOfDay} teamSuggestions={teamSuggestions} teams={teams} teamQuery={teamQuery} @@ -1760,6 +1781,7 @@ function Landing({ onTeamSearch, searchPlaceholder, setTeamQuery, + songOfDay, teamSuggestions, teams, teamQuery, @@ -1829,6 +1851,7 @@ function Landing({ Search teams + @@ -1855,6 +1878,61 @@ function Landing({ ) } +function SongOfDayCard({ songOfDay }) { + const track = songOfDay.data?.track + const isLoading = songOfDay.status === 'loading' + const message = + songOfDay.status === 'error' + ? songOfDay.error + : songOfDay.data?.error || 'A random track from the last week of Last.fm scrobbles.' + + return ( +
+
+
+ {track?.image ? ( + + ) : ( + + )} +
+
+

+ Song of the day +

+ {track ? ( + <> +

{track.name}

+

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

+ + ) : ( +

+ {isLoading ? 'Choosing a song' : message} +

+ )} +
+ {track?.url ? ( + + Play + + ) : null} +
+
+ ) +} + function LandingOverview({ teams, matches, navigate }) { const activeTeams = teams.slice(0, 4) const totalPlayers = matches.reduce((sum, match) => sum + Number(match.player_count || 0), 0) diff --git a/vite.config.js b/vite.config.js index 975d8e5..d5e7e91 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,4 +1,7 @@ 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' import tailwindcss from '@tailwindcss/vite' @@ -35,6 +38,102 @@ 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')) + return path.join(storageDir, env.LASTFM_HISTORY_FILE || 'lastfm-song-of-day.json') +} + +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']) + + return { + id: `${artist.toLowerCase()}::${name.toLowerCase()}`, + artist, + name, + album: track?.album?.['#text'] || '', + url: track?.url || '', + image: image?.['#text'] || '', + played_at: track?.date?.uts ? Number(track.date.uts) : null, + } +} + +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) { + 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 + const track = pool[crypto.randomInt(0, pool.length)] + 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', @@ -77,6 +176,7 @@ 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/lastfm/song-of-day' && (req.method === 'GET' || req.method === 'HEAD')) return true if (req.method !== 'GET' && req.method !== 'HEAD') return false @@ -117,16 +217,37 @@ function isAllowedApiUrl(req) { } } -function apiGuard() { +function apiGuard(env) { return { name: 'api-guard', configureServer(server) { - server.middlewares.use((req, res, next) => { + server.middlewares.use(async (req, res, next) => { if (!req.url?.startsWith('/api/')) { next() return } + const url = new URL(req.url, 'http://localhost') + if (url.pathname === '/api/lastfm/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 @@ -198,7 +319,7 @@ export default defineConfig(({ mode }) => { const comingSoon = String(env.comingsoon || env.COMINGSOON || '').toLowerCase() === 'true' return { - plugins: [comingSoonDev(comingSoon), apiGuard(), react(), tailwindcss(), obfuscate(), sri()], + plugins: [comingSoonDev(comingSoon), apiGuard(env), react(), tailwindcss(), obfuscate(), sri()], server: { host: '0.0.0.0', port: 3001,