data endpoint for entity 90008 (aka. a website)

feat: use listenbrainz instead of lastfm

ptr.pet de7d14d1 7fc4c0e2

verified
+1
deno.json
··· 1 + {}
+10 -24
deno.lock
··· 22 22 "npm:mdsvex@~0.12.6": "0.12.6_svelte@5.39.11__acorn@8.15.0", 23 23 "npm:nanoid@^5.1.5": "5.1.6", 24 24 "npm:node-fetch@^3.3.2": "3.3.2", 25 - "npm:node-schedule@^2.1.1": "2.1.1", 26 25 "npm:postcss@^8.5.6": "8.5.6", 27 26 "npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.39.11__acorn@8.15.0", 28 27 "npm:prettier@^3.6.2": "3.6.2", ··· 33 32 "npm:svelte@^5.38.2": "5.39.11_acorn@8.15.0", 34 33 "npm:sveltekit-rate-limiter@0.7": "0.7.0_@sveltejs+kit@2.46.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.39.11____acorn@8.15.0___vite@7.1.9____@types+node@22.18.9____picomatch@4.0.3___@types+node@22.18.9__svelte@5.39.11___acorn@8.15.0__vite@7.1.9___@types+node@22.18.9___picomatch@4.0.3__acorn@8.15.0__@types+node@22.18.9_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.11___acorn@8.15.0__vite@7.1.9___@types+node@22.18.9___picomatch@4.0.3__@types+node@22.18.9_svelte@5.39.11__acorn@8.15.0_vite@7.1.9__@types+node@22.18.9__picomatch@4.0.3_@types+node@22.18.9", 35 34 "npm:tailwindcss@^3.4.17": "3.4.18_postcss@8.5.6_jiti@1.21.7", 35 + "npm:toad-scheduler@^3.1.0": "3.1.0", 36 36 "npm:tslib@^2.8.1": "2.8.1", 37 37 "npm:typescript-eslint@^8.40.0": "8.46.0_eslint@9.37.0_typescript@5.9.3_@typescript-eslint+parser@8.46.0__eslint@9.37.0__typescript@5.9.3", 38 38 "npm:typescript-svelte-plugin@~0.3.50": "0.3.50_svelte@5.39.11__acorn@8.15.0_typescript@5.9.3", ··· 1073 1073 "cookie@0.6.0": { 1074 1074 "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" 1075 1075 }, 1076 - "cron-parser@4.9.0": { 1077 - "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", 1078 - "dependencies": [ 1079 - "luxon" 1080 - ] 1076 + "croner@8.1.2": { 1077 + "integrity": "sha512-ypfPFcAXHuAZRCzo3vJL6ltENzniTjwe/qsLleH1V2/7SRDjgvRQyrLmumFTLmjFax4IuSxfGXEn79fozXcJog==" 1081 1078 }, 1082 1079 "cross-spawn@7.0.6": { 1083 1080 "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", ··· 1646 1643 "lodash.merge@4.6.2": { 1647 1644 "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" 1648 1645 }, 1649 - "long-timeout@0.1.1": { 1650 - "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==" 1651 - }, 1652 1646 "long@5.3.2": { 1653 1647 "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" 1654 1648 }, 1655 1649 "lru-cache@10.4.3": { 1656 1650 "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" 1657 - }, 1658 - "luxon@3.7.2": { 1659 - "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==" 1660 1651 }, 1661 1652 "magic-string@0.30.19": { 1662 1653 "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", ··· 1763 1754 "node-releases@2.0.23": { 1764 1755 "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==" 1765 1756 }, 1766 - "node-schedule@2.1.1": { 1767 - "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", 1768 - "dependencies": [ 1769 - "cron-parser", 1770 - "long-timeout", 1771 - "sorted-array-functions" 1772 - ] 1773 - }, 1774 1757 "normalize-path@3.0.0": { 1775 1758 "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" 1776 1759 }, ··· 2122 2105 }, 2123 2106 "snappyjs@0.6.1": { 2124 2107 "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==" 2125 - }, 2126 - "sorted-array-functions@1.3.0": { 2127 - "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==" 2128 2108 }, 2129 2109 "source-map-js@1.2.1": { 2130 2110 "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" ··· 2310 2290 "is-number" 2311 2291 ] 2312 2292 }, 2293 + "toad-scheduler@3.1.0": { 2294 + "integrity": "sha512-ZTwsGMWyKTOokgTmIvjPIvkT3ZiPFgkAi8L0OLONOcSc/BUDPRzNMOfVWZzugIAxyntvY0Nzy1etNk+31Q4FXQ==", 2295 + "dependencies": [ 2296 + "croner" 2297 + ] 2298 + }, 2313 2299 "totalist@3.0.1": { 2314 2300 "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" 2315 2301 }, ··· 2502 2488 "npm:mdsvex@~0.12.6", 2503 2489 "npm:nanoid@^5.1.5", 2504 2490 "npm:node-fetch@^3.3.2", 2505 - "npm:node-schedule@^2.1.1", 2506 2491 "npm:postcss@^8.5.6", 2507 2492 "npm:prettier-plugin-svelte@^3.4.0", 2508 2493 "npm:prettier@^3.6.2", ··· 2513 2498 "npm:svelte@^5.38.2", 2514 2499 "npm:sveltekit-rate-limiter@0.7", 2515 2500 "npm:tailwindcss@^3.4.17", 2501 + "npm:toad-scheduler@^3.1.0", 2516 2502 "npm:tslib@^2.8.1", 2517 2503 "npm:typescript-eslint@^8.40.0", 2518 2504 "npm:typescript-svelte-plugin@~0.3.50",
+2 -2
package.json
··· 48 48 "@types/node-schedule": "^2.1.8", 49 49 "nanoid": "^5.1.5", 50 50 "node-fetch": "^3.3.2", 51 - "node-schedule": "^2.1.1", 52 51 "prometheus-remote-write": "^0.5.1", 53 52 "robots-parser": "^3.0.1", 54 - "steamgriddb": "^2.2.0" 53 + "steamgriddb": "^2.2.0", 54 + "toad-scheduler": "^3.1.0" 55 55 }, 56 56 "trustedDependencies": [ 57 57 "@sveltejs/kit",
+16 -19
src/hooks.server.ts
··· 1 1 import { updateLastPosts } from '$lib/bluesky'; 2 - import { lastFmReadLast, lastFmUpdateNowPlaying } from '$lib/lastfm'; 2 + import { getLastTrack, updateNowPlayingTrack } from '$lib/lastfm'; 3 3 import { steamReadLastGame, steamUpdateNowPlaying } from '$lib/steam'; 4 4 import { updateCommits } from '$lib/activity'; 5 - import { cancelJob, scheduleJob, scheduledJobs } from 'node-schedule'; 5 + import { ToadScheduler, SimpleIntervalJob, Task, AsyncTask } from 'toad-scheduler'; 6 6 import { 7 7 incrementFakeVisitCount, 8 8 incrementLegitVisitCount, ··· 20 20 import { error } from '@sveltejs/kit'; 21 21 import { _fetchEntries } from './routes/(site)/guestbook/+page.server'; 22 22 23 - const UPDATE_LAST_JOB_NAME = 'update steam game, lastfm track, bsky posts, git activity'; 24 - 25 - if (UPDATE_LAST_JOB_NAME in scheduledJobs) { 26 - console.log(`${UPDATE_LAST_JOB_NAME} is already running, cancelling so we can start a new one`); 27 - cancelJob(UPDATE_LAST_JOB_NAME); 28 - } 29 - 30 - await steamReadLastGame(); 31 - await lastFmReadLast(); 32 - 33 - console.log(`starting ${UPDATE_LAST_JOB_NAME} job...`); 34 - scheduleJob(UPDATE_LAST_JOB_NAME, '*/1 * * * *', async () => { 35 - console.log(`running ${UPDATE_LAST_JOB_NAME} job...`); 23 + const update = async () => { 36 24 try { 37 25 await Promise.all([ 38 26 steamUpdateNowPlaying(), 39 - lastFmUpdateNowPlaying(), 27 + updateNowPlayingTrack(), 40 28 updateLastPosts(), 41 29 _fetchEntries(), 42 30 updateCommits(), 43 - sendAllMetrics() // send all metrics every minute 31 + sendAllMetrics() 44 32 ]); 45 33 } catch (err) { 46 - console.log(`error while running ${UPDATE_LAST_JOB_NAME} job: ${err}`); 34 + console.log(`error while updating: ${err}`); 47 35 } 48 - }).invoke(); // invoke once immediately 36 + }; 37 + 38 + await update(); 39 + 40 + const scheduler = new ToadScheduler(); 41 + const task = new AsyncTask('update task', update, (err) => 42 + console.log(`error while updating: ${err}`) 43 + ); 44 + const job = new SimpleIntervalJob({ seconds: 5 }, task); 45 + scheduler.addSimpleIntervalJob(job); 49 46 50 47 export const handle = async ({ event, resolve }) => { 51 48 notifyDarkVisitors(event.url, event.request); // no await so it doesnt block
+29 -13
src/lib/lastfm.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 2 import { get, writable } from 'svelte/store'; 3 3 4 - const GET_RECENT_TRACKS_ENDPOINT = 5 - 'https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=yusdacra&api_key=da1911d405b5b37383e200b8f36ee9ec&format=json&limit=1'; 4 + const GET_RECENT_TRACKS_ENDPOINT = 'https://api.listenbrainz.org/1/user/90008/playing-now'; 6 5 const LAST_TRACK_FILE = `${env.WEBSITE_DATA_DIR}/last_track.json`; 7 6 8 7 type LastTrack = { ··· 15 14 }; 16 15 const lastTrack = writable<LastTrack | null>(null); 17 16 18 - export const lastFmReadLast = async () => { 17 + export const getLastTrack = async () => { 19 18 try { 20 19 const data = await Deno.readTextFile(LAST_TRACK_FILE); 21 20 lastTrack.set(JSON.parse(data)); 22 21 } catch (why) { 23 - console.log('could not read last fm: ', why); 22 + console.log('could not read last track: ', why); 24 23 lastTrack.set(null); 25 24 } 26 25 }; 27 26 28 - export const lastFmUpdateNowPlaying = async () => { 27 + const getTrackCoverArt = (track: any) => { 28 + // parse origin url to see if it matches youtube.com / music.youtube.com and extract video id 29 + const originUrl = track.additional_info?.origin_url ?? null; 30 + if (originUrl && (originUrl.includes('youtube.com') || originUrl.includes('music.youtube.com'))) { 31 + const videoId = new URL(originUrl).searchParams.get('v'); 32 + if (!videoId) return null; 33 + return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`; 34 + } 35 + return null; 36 + }; 37 + 38 + export const updateNowPlayingTrack = async () => { 29 39 try { 30 40 const resp = await (await fetch(GET_RECENT_TRACKS_ENDPOINT)).json(); 31 - const track = resp.recenttracks.track[0] ?? null; 32 - if (!((track['@attr'] ?? {}).nowplaying ?? null)) { 33 - throw 'no nowplaying track found'; 41 + const track = resp.payload.listens[0]?.track_metadata; 42 + if (!track) { 43 + lastTrack.update((t) => { 44 + if (t !== null) { 45 + t.playing = false; 46 + } 47 + return t; 48 + }); 49 + return; 34 50 } 35 51 const data = { 36 - name: track.name, 37 - artist: track.artist['#text'], 38 - image: track.image[2]['#text'] ?? null, 39 - link: track.url, 52 + name: track.track_name, 53 + artist: track.artist_name, 54 + image: getTrackCoverArt(track), 55 + link: track.additional_info?.origin_url ?? null, 40 56 when: Date.now(), 41 57 playing: true 42 58 }; ··· 53 69 } 54 70 }; 55 71 56 - export const getNowPlaying = () => { 72 + export const getNowPlayingTrack = () => { 57 73 return get(lastTrack); 58 74 };
+7 -1
src/lib/steam.ts
··· 37 37 try { 38 38 const profile = (await (await fetch(GET_PLAYER_SUMMARY_ENDPOINT)).json()).response.players[0]; 39 39 if (!profile.gameid) { 40 - throw 'no game is being played'; 40 + lastGame.update((t) => { 41 + if (t !== null) { 42 + t.playing = false; 43 + } 44 + return t; 45 + }); 46 + return; 41 47 } 42 48 const icons = await griddbClient.getIconsBySteamAppId(profile.gameid, ['official', 'custom']); 43 49 //console.log(icons)
+1 -1
src/lib/visits.ts
··· 131 131 console.log('failed sending dark visitors analytics:', why); 132 132 return null; 133 133 }) 134 - .then(async (resp) => { 134 + .then((resp) => { 135 135 if (resp !== null) { 136 136 const host = `(${request.headers.get('host')}|${request.headers.get('x-real-ip')}|${request.headers.get('user-agent')})`; 137 137 console.log(`sent visitor analytic to dark visitors: ${resp.statusText}; ${host}`);
+2 -2
src/routes/(site)/+page.server.ts
··· 1 1 import { getLastPosts } from '$lib/bluesky.js'; 2 - import { getNowPlaying } from '$lib/lastfm'; 2 + import { getNowPlayingTrack } from '$lib/lastfm'; 3 3 import { getLastGame } from '$lib/steam'; 4 4 import { noteFromBskyPost } from '$components/note.svelte'; 5 5 import { pushNotification } from '$lib/pushnotif'; ··· 8 8 import { useToken as checkApiToken } from '$lib/apiToken.js'; 9 9 10 10 export const load = async () => { 11 - const lastTrack = getNowPlaying(); 11 + const lastTrack = getNowPlayingTrack(); 12 12 const lastGame = getLastGame(); 13 13 const lastPosts = getLastPosts(); 14 14 const lastNote = lastPosts.length > 0 ? noteFromBskyPost(lastPosts[0]) : null;
+2 -2
src/routes/(site)/+page.svelte
··· 162 162 <!-- svelte-ignore a11y_missing_attribute --> 163 163 {#if data.lastTrack.image} 164 164 <img 165 - class="border-4 w-[4.5rem] h-[4.5rem]" 165 + class="border-4 w-[4.5rem] h-[4.5rem] object-cover" 166 166 style="border-style: none double none none;" 167 167 src={data.lastTrack.image} 168 168 /> ··· 182 182 > 183 183 <a 184 184 title={data.lastTrack.name} 185 - href="https://www.last.fm/user/yusdacra" 185 + href={data.lastTrack.link ?? 'https://listenbrainz.org/user/90008/'} 186 186 class="hover:underline motion-safe:hover:animate-squiggle">{data.lastTrack.name}</a 187 187 > 188 188 </p>