a
This commit is contained in:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal 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
13
Dockerfile
Normal 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
15
README.md
Normal 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
30
bun.lock
Normal 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
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
kris-scrobbler:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
18
package.json
Normal file
18
package.json
Normal 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
213
src/index.ts
Normal 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
29
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user