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