meow && add song of the day!
This commit is contained in:
+127
-1
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user