data endpoint for entity 90008 (aka. a website)

feat: use listenbrainz instead of lastfm

ptr.pet de7d14d1 7fc4c0e2

verified
+1
deno.json
···
+
{}
+10 -24
deno.lock
···
"npm:mdsvex@~0.12.6": "0.12.6_svelte@5.39.11__acorn@8.15.0",
"npm:nanoid@^5.1.5": "5.1.6",
"npm:node-fetch@^3.3.2": "3.3.2",
-
"npm:node-schedule@^2.1.1": "2.1.1",
"npm:postcss@^8.5.6": "8.5.6",
"npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.39.11__acorn@8.15.0",
"npm:prettier@^3.6.2": "3.6.2",
···
"npm:svelte@^5.38.2": "5.39.11_acorn@8.15.0",
"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",
"npm:tailwindcss@^3.4.17": "3.4.18_postcss@8.5.6_jiti@1.21.7",
+
"npm:toad-scheduler@^3.1.0": "3.1.0",
"npm:tslib@^2.8.1": "2.8.1",
"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",
"npm:typescript-svelte-plugin@~0.3.50": "0.3.50_svelte@5.39.11__acorn@8.15.0_typescript@5.9.3",
···
"cookie@0.6.0": {
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="
},
-
"cron-parser@4.9.0": {
-
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
-
"dependencies": [
-
"luxon"
-
]
+
"croner@8.1.2": {
+
"integrity": "sha512-ypfPFcAXHuAZRCzo3vJL6ltENzniTjwe/qsLleH1V2/7SRDjgvRQyrLmumFTLmjFax4IuSxfGXEn79fozXcJog=="
},
"cross-spawn@7.0.6": {
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
···
"lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
-
"long-timeout@0.1.1": {
-
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w=="
-
},
"long@5.3.2": {
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
},
"lru-cache@10.4.3": {
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
-
},
-
"luxon@3.7.2": {
-
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="
},
"magic-string@0.30.19": {
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
···
"node-releases@2.0.23": {
"integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="
},
-
"node-schedule@2.1.1": {
-
"integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==",
-
"dependencies": [
-
"cron-parser",
-
"long-timeout",
-
"sorted-array-functions"
-
]
-
},
"normalize-path@3.0.0": {
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
},
···
},
"snappyjs@0.6.1": {
"integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg=="
-
},
-
"sorted-array-functions@1.3.0": {
-
"integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA=="
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
···
"is-number"
},
+
"toad-scheduler@3.1.0": {
+
"integrity": "sha512-ZTwsGMWyKTOokgTmIvjPIvkT3ZiPFgkAi8L0OLONOcSc/BUDPRzNMOfVWZzugIAxyntvY0Nzy1etNk+31Q4FXQ==",
+
"dependencies": [
+
"croner"
+
]
+
},
"totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
···
"npm:mdsvex@~0.12.6",
"npm:nanoid@^5.1.5",
"npm:node-fetch@^3.3.2",
-
"npm:node-schedule@^2.1.1",
"npm:postcss@^8.5.6",
"npm:prettier-plugin-svelte@^3.4.0",
"npm:prettier@^3.6.2",
···
"npm:svelte@^5.38.2",
"npm:sveltekit-rate-limiter@0.7",
"npm:tailwindcss@^3.4.17",
+
"npm:toad-scheduler@^3.1.0",
"npm:tslib@^2.8.1",
"npm:typescript-eslint@^8.40.0",
"npm:typescript-svelte-plugin@~0.3.50",
+2 -2
package.json
···
"@types/node-schedule": "^2.1.8",
"nanoid": "^5.1.5",
"node-fetch": "^3.3.2",
-
"node-schedule": "^2.1.1",
"prometheus-remote-write": "^0.5.1",
"robots-parser": "^3.0.1",
-
"steamgriddb": "^2.2.0"
+
"steamgriddb": "^2.2.0",
+
"toad-scheduler": "^3.1.0"
},
"trustedDependencies": [
"@sveltejs/kit",
+16 -19
src/hooks.server.ts
···
import { updateLastPosts } from '$lib/bluesky';
-
import { lastFmReadLast, lastFmUpdateNowPlaying } from '$lib/lastfm';
+
import { getLastTrack, updateNowPlayingTrack } from '$lib/lastfm';
import { steamReadLastGame, steamUpdateNowPlaying } from '$lib/steam';
import { updateCommits } from '$lib/activity';
-
import { cancelJob, scheduleJob, scheduledJobs } from 'node-schedule';
+
import { ToadScheduler, SimpleIntervalJob, Task, AsyncTask } from 'toad-scheduler';
import {
incrementFakeVisitCount,
incrementLegitVisitCount,
···
import { error } from '@sveltejs/kit';
import { _fetchEntries } from './routes/(site)/guestbook/+page.server';
-
const UPDATE_LAST_JOB_NAME = 'update steam game, lastfm track, bsky posts, git activity';
-
-
if (UPDATE_LAST_JOB_NAME in scheduledJobs) {
-
console.log(`${UPDATE_LAST_JOB_NAME} is already running, cancelling so we can start a new one`);
-
cancelJob(UPDATE_LAST_JOB_NAME);
-
}
-
-
await steamReadLastGame();
-
await lastFmReadLast();
-
-
console.log(`starting ${UPDATE_LAST_JOB_NAME} job...`);
-
scheduleJob(UPDATE_LAST_JOB_NAME, '*/1 * * * *', async () => {
-
console.log(`running ${UPDATE_LAST_JOB_NAME} job...`);
+
const update = async () => {
try {
await Promise.all([
steamUpdateNowPlaying(),
-
lastFmUpdateNowPlaying(),
+
updateNowPlayingTrack(),
updateLastPosts(),
_fetchEntries(),
updateCommits(),
-
sendAllMetrics() // send all metrics every minute
+
sendAllMetrics()
]);
} catch (err) {
-
console.log(`error while running ${UPDATE_LAST_JOB_NAME} job: ${err}`);
+
console.log(`error while updating: ${err}`);
}
-
}).invoke(); // invoke once immediately
+
};
+
+
await update();
+
+
const scheduler = new ToadScheduler();
+
const task = new AsyncTask('update task', update, (err) =>
+
console.log(`error while updating: ${err}`)
+
);
+
const job = new SimpleIntervalJob({ seconds: 5 }, task);
+
scheduler.addSimpleIntervalJob(job);
export const handle = async ({ event, resolve }) => {
notifyDarkVisitors(event.url, event.request); // no await so it doesnt block
+29 -13
src/lib/lastfm.ts
···
import { env } from '$env/dynamic/private';
import { get, writable } from 'svelte/store';
-
const GET_RECENT_TRACKS_ENDPOINT =
-
'https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=yusdacra&api_key=da1911d405b5b37383e200b8f36ee9ec&format=json&limit=1';
+
const GET_RECENT_TRACKS_ENDPOINT = 'https://api.listenbrainz.org/1/user/90008/playing-now';
const LAST_TRACK_FILE = `${env.WEBSITE_DATA_DIR}/last_track.json`;
type LastTrack = {
···
};
const lastTrack = writable<LastTrack | null>(null);
-
export const lastFmReadLast = async () => {
+
export const getLastTrack = async () => {
try {
const data = await Deno.readTextFile(LAST_TRACK_FILE);
lastTrack.set(JSON.parse(data));
} catch (why) {
-
console.log('could not read last fm: ', why);
+
console.log('could not read last track: ', why);
lastTrack.set(null);
}
};
-
export const lastFmUpdateNowPlaying = async () => {
+
const getTrackCoverArt = (track: any) => {
+
// parse origin url to see if it matches youtube.com / music.youtube.com and extract video id
+
const originUrl = track.additional_info?.origin_url ?? null;
+
if (originUrl && (originUrl.includes('youtube.com') || originUrl.includes('music.youtube.com'))) {
+
const videoId = new URL(originUrl).searchParams.get('v');
+
if (!videoId) return null;
+
return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`;
+
}
+
return null;
+
};
+
+
export const updateNowPlayingTrack = async () => {
try {
const resp = await (await fetch(GET_RECENT_TRACKS_ENDPOINT)).json();
-
const track = resp.recenttracks.track[0] ?? null;
-
if (!((track['@attr'] ?? {}).nowplaying ?? null)) {
-
throw 'no nowplaying track found';
+
const track = resp.payload.listens[0]?.track_metadata;
+
if (!track) {
+
lastTrack.update((t) => {
+
if (t !== null) {
+
t.playing = false;
+
}
+
return t;
+
});
+
return;
}
const data = {
-
name: track.name,
-
artist: track.artist['#text'],
-
image: track.image[2]['#text'] ?? null,
-
link: track.url,
+
name: track.track_name,
+
artist: track.artist_name,
+
image: getTrackCoverArt(track),
+
link: track.additional_info?.origin_url ?? null,
when: Date.now(),
playing: true
};
···
}
};
-
export const getNowPlaying = () => {
+
export const getNowPlayingTrack = () => {
return get(lastTrack);
};
+7 -1
src/lib/steam.ts
···
try {
const profile = (await (await fetch(GET_PLAYER_SUMMARY_ENDPOINT)).json()).response.players[0];
if (!profile.gameid) {
-
throw 'no game is being played';
+
lastGame.update((t) => {
+
if (t !== null) {
+
t.playing = false;
+
}
+
return t;
+
});
+
return;
}
const icons = await griddbClient.getIconsBySteamAppId(profile.gameid, ['official', 'custom']);
//console.log(icons)
+1 -1
src/lib/visits.ts
···
console.log('failed sending dark visitors analytics:', why);
return null;
})
-
.then(async (resp) => {
+
.then((resp) => {
if (resp !== null) {
const host = `(${request.headers.get('host')}|${request.headers.get('x-real-ip')}|${request.headers.get('user-agent')})`;
console.log(`sent visitor analytic to dark visitors: ${resp.statusText}; ${host}`);
+2 -2
src/routes/(site)/+page.server.ts
···
import { getLastPosts } from '$lib/bluesky.js';
-
import { getNowPlaying } from '$lib/lastfm';
+
import { getNowPlayingTrack } from '$lib/lastfm';
import { getLastGame } from '$lib/steam';
import { noteFromBskyPost } from '$components/note.svelte';
import { pushNotification } from '$lib/pushnotif';
···
import { useToken as checkApiToken } from '$lib/apiToken.js';
export const load = async () => {
-
const lastTrack = getNowPlaying();
+
const lastTrack = getNowPlayingTrack();
const lastGame = getLastGame();
const lastPosts = getLastPosts();
const lastNote = lastPosts.length > 0 ? noteFromBskyPost(lastPosts[0]) : null;
+2 -2
src/routes/(site)/+page.svelte
···
<!-- svelte-ignore a11y_missing_attribute -->
{#if data.lastTrack.image}
<img
-
class="border-4 w-[4.5rem] h-[4.5rem]"
+
class="border-4 w-[4.5rem] h-[4.5rem] object-cover"
style="border-style: none double none none;"
src={data.lastTrack.image}
/>
···
>
<a
title={data.lastTrack.name}
-
href="https://www.last.fm/user/yusdacra"
+
href={data.lastTrack.link ?? 'https://listenbrainz.org/user/90008/'}
class="hover:underline motion-safe:hover:animate-squiggle">{data.lastTrack.name}</a
>
</p>