This commit is contained in:
2026-02-22 12:33:20 +02:00
commit 81065df132
9 changed files with 362 additions and 0 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM oven/bun:1
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["bun", "run", "start"]

15
README.md Normal file
View File

@@ -0,0 +1,15 @@
# kris-scrobbler
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run src/index.ts
```
This project was created using `bun init` in bun v1.3.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

30
bun.lock Normal file
View File

@@ -0,0 +1,30 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "kris-scrobbler",
"dependencies": {
"lastfm-ts-api": "^2.6.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"lastfm-ts-api": ["lastfm-ts-api@2.6.0", "", {}, "sha512-Kr4B4/LlEHumvmnzLawXGRQAzn2ApsQ/n+locPyDpGIQgJg/oK9OSAVTS1d47D00NLnZCNoa/+9YB/bkeXL46Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
kris-scrobbler:
build:
context: .
dockerfile: Dockerfile
environment:
- NODE_ENV=production
restart: unless-stopped

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "kris-scrobbler",
"module": "src/index.ts",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"private": true,
"scripts": {
"dev": "bun run --watch src/index.ts"
},
"type": "module",
"dependencies": {
"lastfm-ts-api": "^2.6.0"
}
}

213
src/index.ts Normal file
View File

@@ -0,0 +1,213 @@
import { LastFMTrack, LastFMUser } from 'lastfm-ts-api';
export const ROCKSKY_ENDPOINT = 'https://audioscrobbler.rocksky.app/2.0';
const LASTFM_USER = process.env.LASTFM_USER ?? 'user';
const POLL_INTERVAL_MS = 5_000;
const MAX_SCROBBLES_PER_POLL = 1;
const MAX_SCROBBLE_RETRIES = 3;
const SCROBBLE_RETRY_DELAY_MS = 10_000;
const LOOKBACK_SECONDS = Number(process.env.LOOKBACK_SECONDS ?? 24 * 60 * 60);
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
function formatError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error);
} catch {
return 'Unknown error';
}
}
const lastfm = new LastFMUser(requireEnv('LASTFM_KEY'));
const rocksky = new LastFMTrack(
requireEnv('ROCKSKY_KEY'),
requireEnv('ROCKSKY_SECRET'),
requireEnv('ROCKSKY_SESSION_KEY'),
{
hostname: 'audioscrobbler.rocksky.app',
path: '/2.0'
}
);
let lastTimestamp = Math.floor(Date.now() / 1000) - LOOKBACK_SECONDS;
let isSyncing = false;
let lastNowPlayingKey: string | null = null;
async function scrobble(): Promise<void> {
if (isSyncing) {
return;
}
isSyncing = true;
try {
const response = await lastfm.getRecentTracks({
user: LASTFM_USER,
from: String(lastTimestamp),
limit: 200
});
const recentTracks = Array.isArray(response.recenttracks.track)
? response.recenttracks.track
: response.recenttracks.track
? [response.recenttracks.track]
: [];
const tracks = recentTracks
.map(track => {
const nowPlaying = Boolean(track['@attr']?.nowplaying);
const timestamp = nowPlaying
? Math.max(1, Math.floor(Date.now() / 1000) - 30)
: Number(track.date?.uts);
const artist = track.artist?.['#text']?.trim();
const title = track.name?.trim();
const album = track.album?.['#text']?.trim();
const mbid = track.mbid?.trim();
const nowPlayingKey = nowPlaying ? mbid || `${artist}::${title}` : null;
if (!Number.isFinite(timestamp) || !artist || !title) {
return null;
}
if (nowPlaying && nowPlayingKey === lastNowPlayingKey) {
return null;
}
return {
artist,
track: title,
timestamp,
nowPlaying,
nowPlayingKey,
...(album ? { album } : {}),
...(mbid ? { mbid } : {})
};
})
.filter((track): track is NonNullable<typeof track> => track !== null)
.sort((a, b) => a.timestamp - b.timestamp);
if (tracks.length === 0) {
return;
}
const seen = new Set<string>();
const uniqueTracks = tracks.filter(track => {
const dedupeId = track.mbid || `${track.artist}::${track.track}`;
const key = `${track.timestamp}::${dedupeId}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
const completedTracks = uniqueTracks.filter(track => !track.nowPlaying);
const nowPlayingTracks = uniqueTracks.filter(track => track.nowPlaying);
const tracksToScrobble =
completedTracks.length > 0
? completedTracks.slice(-MAX_SCROBBLES_PER_POLL)
: nowPlayingTracks.slice(-MAX_SCROBBLES_PER_POLL);
const latestTrack = tracksToScrobble[tracksToScrobble.length - 1];
if (!latestTrack) {
return;
}
const scrobblePayload = tracksToScrobble.map(({ nowPlaying, nowPlayingKey, ...track }) => track);
let scrobbleSucceeded = false;
for (let attempt = 1; attempt <= MAX_SCROBBLE_RETRIES; attempt += 1) {
try {
const result = await rocksky.scrobbleMany(scrobblePayload);
const accepted = result.scrobbles['@attr'].accepted;
const ignored = result.scrobbles['@attr'].ignored;
console.log(`Scrobbled ${accepted} track(s), ignored ${ignored}.`);
const scrobbles = Array.isArray(result.scrobbles.scrobble)
? result.scrobbles.scrobble
: [result.scrobbles.scrobble];
for (const [index, scrobbleResult] of scrobbles.entries()) {
const fallbackTrack = tracksToScrobble[index];
const artist = scrobbleResult.artist?.['#text']?.trim() || fallbackTrack?.artist || 'Unknown artist';
const track = scrobbleResult.track?.['#text']?.trim() || fallbackTrack?.track || 'Unknown track';
const ignoredCode = Number(scrobbleResult.ignoredMessage?.code ?? 0);
const ignoredMessage = scrobbleResult.ignoredMessage?.['#text']?.trim();
const status =
ignoredCode === 0
? 'accepted'
: `ignored (${ignoredCode}${ignoredMessage ? `: ${ignoredMessage}` : ''})`;
const nowPlayingSuffix = fallbackTrack?.nowPlaying ? ' (now playing)' : '';
console.log(`Scrobble result: ${artist} - ${track}${nowPlayingSuffix}: ${status}`);
}
scrobbleSucceeded = true;
break;
} catch (error) {
console.error(
`Scrobble attempt ${attempt}/${MAX_SCROBBLE_RETRIES} failed: ${formatError(error)}`
);
if (attempt < MAX_SCROBBLE_RETRIES) {
await sleep(SCROBBLE_RETRY_DELAY_MS);
}
}
}
if (!latestTrack.nowPlaying) {
lastTimestamp = latestTrack.timestamp + 1;
}
if (latestTrack.nowPlaying && latestTrack.nowPlayingKey) {
lastNowPlayingKey = latestTrack.nowPlayingKey;
}
if (!scrobbleSucceeded) {
console.error(
`Skipping failed scrobble after ${MAX_SCROBBLE_RETRIES} attempts: ${latestTrack.artist} - ${latestTrack.track}`
);
}
console.log(`Synced ${tracksToScrobble.length} track(s) up to ${lastTimestamp}.`);
} catch (error) {
console.error(`Scrobble sync failed: ${formatError(error)}`);
} finally {
isSyncing = false;
}
}
async function run(): Promise<void> {
console.log(`Syncing Last.fm user ${LASTFM_USER} to ${ROCKSKY_ENDPOINT}`);
await scrobble();
setInterval(() => {
void scrobble();
}, POLL_INTERVAL_MS);
}
void run();

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}