meow && add song of the day!

This commit is contained in:
2026-05-27 14:49:26 +01:00
parent 3a2cd9b1aa
commit 69fc002961
5 changed files with 345 additions and 4 deletions
+127 -1
View File
@@ -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' })