From 14b1cda4f54a680fc81f3d05fae4361ed3a5501e Mon Sep 17 00:00:00 2001
From: Lee Kelly
Date: Sat, 15 Aug 2026 03:32:27 +0000
Subject: [PATCH 1/6] feat(scrobbling): add local play history and scrobbling
- Record play events from Subsonic playback
- Support Last.fm, ListenBrainz, and Koito connections
- Use local listening history for discovery
---
.tests/auth/listening-history.test.js | 15 +-
.tests/helpers/backendTestHarness.js | 1 +
.tests/history/play-events.test.js | 49 ++++
backend/config/constants.js | 2 +-
backend/config/db-sqlite.js | 20 ++
backend/config/encryption.js | 1 +
backend/db/helpers/users.js | 2 +-
backend/middleware/auth.js | 8 +-
backend/routes/onboarding.js | 7 +-
backend/routes/playEvents.js | 20 ++
backend/routes/scrobbling.js | 122 ++++++++++
backend/routes/subsonic.js | 34 ++-
backend/routes/users.js | 8 +
backend/server.js | 4 +
backend/services/apiClients/config.js | 5 +
backend/services/apiClients/index.js | 9 +-
backend/services/apiClients/lastfm.js | 42 +++-
backend/services/apiClients/listenbrainz.js | 47 ++++
backend/services/appRuntime.js | 2 +
backend/services/discovery/provider.js | 99 ++++----
backend/services/discovery/userDiscovery.js | 40 +++-
.../services/discoveryUserRefreshWorker.js | 1 +
backend/services/honkerDb.js | 30 +++
backend/services/listeningHistory.js | 26 +--
backend/services/playEventOutboxWorker.js | 57 +++++
backend/services/playEventService.js | 126 ++++++++++
backend/services/scrobbleConnectionStore.js | 93 ++++++++
.../content/docs/admin/troubleshooting.mdx | 3 +-
docs/src/content/docs/integrations/koito.mdx | 11 +-
docs/src/content/docs/integrations/lastfm.mdx | 23 +-
.../content/docs/integrations/navidrome.mdx | 18 +-
docs/src/content/docs/using/discover.mdx | 4 +-
frontend/src/contexts/AudioQueueProvider.jsx | 15 ++
frontend/src/pages/LibraryPage.jsx | 3 +
.../components/SettingsAccountTab.jsx | 24 +-
.../components/SettingsConnectTab.jsx | 31 +--
.../components/SettingsPlaybackSection.jsx | 220 ++++++++++++++++++
.../Settings/components/SettingsUsersTab.jsx | 19 +-
.../Settings/hooks/useAccountSettings.js | 2 +-
.../pages/Settings/hooks/useSettingsData.js | 2 +-
.../src/pages/Settings/settingsTabsConfig.js | 12 +-
frontend/src/pages/Settings/utils.js | 1 -
frontend/src/utils/api/endpoints/auth.js | 13 +-
frontend/src/utils/audioQueue.js | 8 +
44 files changed, 1115 insertions(+), 164 deletions(-)
create mode 100644 .tests/history/play-events.test.js
create mode 100644 backend/routes/playEvents.js
create mode 100644 backend/routes/scrobbling.js
create mode 100644 backend/services/playEventOutboxWorker.js
create mode 100644 backend/services/playEventService.js
create mode 100644 backend/services/scrobbleConnectionStore.js
diff --git a/.tests/auth/listening-history.test.js b/.tests/auth/listening-history.test.js
index 410bd9c38..3b0812469 100644
--- a/.tests/auth/listening-history.test.js
+++ b/.tests/auth/listening-history.test.js
@@ -117,7 +117,7 @@ test("legacy lastfm_username still resolves as a lastfm profile", () => {
assert.equal(stored?.lastfmUsername, "legacybob");
});
-test("resolveListenHistorySettings falls back to integrations default username", () => {
+test("resolveListenHistorySettings does not use a global username", () => {
const settings = {
integrations: {
lastfm: {
@@ -128,9 +128,18 @@ test("resolveListenHistorySettings falls back to integrations default username",
};
assert.deepEqual(resolveListenHistorySettings({}, settings), {
listenHistoryProvider: "lastfm",
- listenHistoryUsername: "leefamous",
+ listenHistoryUsername: null,
listenHistoryUrl: null,
- lastfmUsername: "leefamous",
+ lastfmUsername: null,
+ });
+});
+
+test("local history is a valid profile without an external identity", () => {
+ assert.deepEqual(resolveListenHistorySettings({ listenHistoryProvider: "local" }), {
+ listenHistoryProvider: "local",
+ listenHistoryUsername: null,
+ listenHistoryUrl: null,
+ lastfmUsername: null,
});
});
diff --git a/.tests/helpers/backendTestHarness.js b/.tests/helpers/backendTestHarness.js
index 02760f89a..785978cb0 100644
--- a/.tests/helpers/backendTestHarness.js
+++ b/.tests/helpers/backendTestHarness.js
@@ -11,6 +11,7 @@ const repoRoot = join(__dirname, "..", "..");
const RESET_TABLES = [
"sessions",
"subsonic_stars",
+ "play_events",
"honker_task_runs",
"slskd_transfer_history",
"playlist_download_jobs",
diff --git a/.tests/history/play-events.test.js b/.tests/history/play-events.test.js
new file mode 100644
index 000000000..24e2b1796
--- /dev/null
+++ b/.tests/history/play-events.test.js
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ cleanupIsolatedState,
+ resetDatabase,
+ setupIsolatedBackend,
+} from "../helpers/backendTestHarness.js";
+
+const [isolatedState, playEvents] = await setupIsolatedBackend(
+ "play-events",
+ "backend/services/playEventService.js",
+);
+const { db } = await import("../../backend/config/db-sqlite.js");
+
+test.beforeEach(() => {
+ resetDatabase(db);
+ db.prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)").run("listener", "test");
+});
+
+test.after(async () => cleanupIsolatedState(isolatedState));
+
+test("records local plays and aggregates artists without provider access", () => {
+ const first = playEvents.recordPlayEvent(1, {
+ trackId: "song:one",
+ title: "One",
+ artist: "Artist A",
+ album: "Album",
+ durationMs: 180000,
+ playedAt: 1700000000,
+ source: "subsonic",
+ });
+ playEvents.recordPlayEvent(1, {
+ trackId: "song:two",
+ title: "Two",
+ artist: "Artist A",
+ playedAt: 1700000001,
+ source: "native-player",
+ });
+
+ assert.equal(first.playedAt, 1700000000000);
+ assert.equal(playEvents.getPlayHistory(1).length, 2);
+ assert.deepEqual(playEvents.getTopPlayedArtists(1)[0], {
+ artistName: "Artist A",
+ mbid: null,
+ playcount: 2,
+ lastPlayedAt: 1700000001000,
+ });
+});
diff --git a/backend/config/constants.js b/backend/config/constants.js
index 50c63b11c..7e10d5004 100644
--- a/backend/config/constants.js
+++ b/backend/config/constants.js
@@ -40,7 +40,7 @@ export const defaultData = {
},
lastfm: {
apiKey: "",
- username: "",
+ apiSecret: "",
discoveryPeriod: "1month",
discoveryAutoRefreshHours: 168,
discoveryRecommendationsPerRefresh: 200,
diff --git a/backend/config/db-sqlite.js b/backend/config/db-sqlite.js
index cc656c4ba..dd8ff600d 100644
--- a/backend/config/db-sqlite.js
+++ b/backend/config/db-sqlite.js
@@ -85,6 +85,26 @@ db.exec(`
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS play_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ track_id TEXT NOT NULL,
+ title TEXT NOT NULL,
+ artist TEXT NOT NULL,
+ album TEXT,
+ artist_mbid TEXT,
+ album_mbid TEXT,
+ track_mbid TEXT,
+ duration_ms INTEGER,
+ played_at INTEGER NOT NULL,
+ source TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_play_events_user_played_at
+ ON play_events(user_id, played_at DESC);
+
CREATE TABLE IF NOT EXISTS playlist_download_jobs (
id TEXT PRIMARY KEY,
artist_name TEXT NOT NULL,
diff --git a/backend/config/encryption.js b/backend/config/encryption.js
index a9ff1c58a..11bc10afa 100644
--- a/backend/config/encryption.js
+++ b/backend/config/encryption.js
@@ -47,6 +47,7 @@ const SENSITIVE_PATHS = [
["nzbget", "password"],
["gotify", "token"],
["lastfm", "apiKey"],
+ ["lastfm", "apiSecret"],
];
function getAt(obj, path) {
diff --git a/backend/db/helpers/users.js b/backend/db/helpers/users.js
index 603539035..5872610c2 100644
--- a/backend/db/helpers/users.js
+++ b/backend/db/helpers/users.js
@@ -183,7 +183,7 @@ export const userOps = {
: existing.listenHistoryUrl,
);
const resolvedUsername =
- listenHistoryProvider === "koito" ? null : listenHistoryUsername;
+ ["koito", "local"].includes(listenHistoryProvider) ? null : listenHistoryUsername;
const resolvedUrl =
listenHistoryProvider === "koito" ? listenHistoryUrl : null;
const lastfmUsername =
diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js
index e19a6fdc7..f270c511b 100644
--- a/backend/middleware/auth.js
+++ b/backend/middleware/auth.js
@@ -1,7 +1,6 @@
import crypto from "crypto";
import os from "os";
import { dbOps, userOps } from "../db/helpers/index.js";
-import { getDefaultListenHistoryProfile } from "../services/listeningHistory.js";
import { createSession, getSessionByToken } from "../config/session-helpers.js";
import { hashPassword, verifyPassword, needsRehash } from "./passwordHash.js";
@@ -510,11 +509,7 @@ function migrateLegacyAdmin() {
const authPassword = settings.integrations?.general?.authPassword;
if (!onboardingComplete || !authPassword) return;
const hash = hashPassword(authPassword);
- const created = userOps.createUser(authUser, hash, "admin", null);
- const initialListenHistory = getDefaultListenHistoryProfile(settings);
- if (created && initialListenHistory) {
- userOps.updateUser(created.id, initialListenHistory);
- }
+ userOps.createUser(authUser, hash, "admin", null);
}
export function resolveUser(username, password) {
@@ -682,6 +677,7 @@ export const authMiddleware = (req, res, next) => {
req.path === "/api/auth/login" ||
req.path === "/api/auth/oidc/login" ||
req.path === "/api/auth/oidc/exchange"
+ || (req.method === "GET" && req.path === "/api/scrobbling/lastfm/link/callback")
) {
return next();
}
diff --git a/backend/routes/onboarding.js b/backend/routes/onboarding.js
index f8d82934f..9033af97e 100644
--- a/backend/routes/onboarding.js
+++ b/backend/routes/onboarding.js
@@ -1,7 +1,6 @@
import express from "express";
import { dbOps, userOps } from "../db/helpers/index.js";
import { hashPassword } from "../middleware/passwordHash.js";
-import { getDefaultListenHistoryProfile } from "../services/listeningHistory.js";
import { defaultData } from "../config/constants.js";
import { requirePasswordStrength, reconcileLocalNetworkBypassSetting } from "../middleware/auth.js";
import { validateDownloadFolderPath } from "../services/downloadFolderConfig.js";
@@ -180,11 +179,7 @@ router.post("/complete", async (req, res) => {
const authPasswordFinal = integrations?.general?.authPassword || "";
if (authPasswordFinal && userOps.getAllUsers().length === 0) {
const hash = hashPassword(authPasswordFinal);
- const created = userOps.createUser(authUserFinal, hash, "admin", null);
- const initialListenHistory = getDefaultListenHistoryProfile(nextSettings);
- if (created && initialListenHistory) {
- userOps.updateUser(created.id, initialListenHistory);
- }
+ userOps.createUser(authUserFinal, hash, "admin", null);
}
reconcileLocalNetworkBypassSetting();
diff --git a/backend/routes/playEvents.js b/backend/routes/playEvents.js
new file mode 100644
index 000000000..298fb3dbc
--- /dev/null
+++ b/backend/routes/playEvents.js
@@ -0,0 +1,20 @@
+import express from "express";
+import { requireAuth } from "../middleware/requirePermission.js";
+import { getPlayHistory, recordPlayEvent } from "../services/playEventService.js";
+
+const router = express.Router();
+router.use(requireAuth);
+
+router.get("/", (req, res) => {
+ res.json({ events: getPlayHistory(req.user.id, req.query) });
+});
+
+router.post("/", (req, res) => {
+ try {
+ res.status(201).json({ event: recordPlayEvent(req.user.id, req.body) });
+ } catch (error) {
+ res.status(400).json({ error: error.message || "Could not record play event" });
+ }
+});
+
+export default router;
diff --git a/backend/routes/scrobbling.js b/backend/routes/scrobbling.js
new file mode 100644
index 000000000..735b3aaa6
--- /dev/null
+++ b/backend/routes/scrobbling.js
@@ -0,0 +1,122 @@
+import express from "express";
+import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
+import { getLastfmApiKey, getLastfmApiSecret, lastfmGetSession, listenbrainzValidateToken } from "../services/apiClients/index.js";
+import { userOps } from "../db/helpers/index.js";
+import { requireAuth } from "../middleware/requirePermission.js";
+import { validateExternalUrl } from "../middleware/urlValidator.js";
+import { normalizeKoitoBaseUrl } from "../services/koitoClient.js";
+import { getScrobbleEncryptionKey, scrobbleConnectionStore } from "../services/scrobbleConnectionStore.js";
+
+const router = express.Router();
+const encode = (value) => Buffer.from(value).toString("base64url");
+const decode = (value) => Buffer.from(String(value || ""), "base64url").toString("utf8");
+
+const createLinkToken = (userId) => {
+ const payload = `${userId}.${Date.now() + 10 * 60 * 1000}.${randomBytes(12).toString("hex")}`;
+ const signature = createHmac("sha256", getScrobbleEncryptionKey()).update(payload).digest("base64url");
+ return `${encode(payload)}.${signature}`;
+};
+
+const verifyLinkToken = (token) => {
+ const [encodedPayload, signature] = String(token || "").split(".");
+ if (!encodedPayload || !signature) return null;
+ const payload = decode(encodedPayload);
+ const expected = createHmac("sha256", getScrobbleEncryptionKey()).update(payload).digest("base64url");
+ if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null;
+ const [userId, expiresAt] = payload.split(".");
+ if (!Number.isFinite(Number(userId)) || Number(expiresAt) <= Date.now()) return null;
+ return Math.trunc(Number(userId));
+};
+
+const callbackUrl = (req, token) => {
+ const protocol = String(req.get("x-forwarded-proto") || req.protocol).split(",")[0].trim();
+ return `${protocol}://${req.get("host")}/api/scrobbling/lastfm/link/callback?uid=${encode(token)}`;
+};
+
+router.get("/status", requireAuth, (req, res) => {
+ const status = scrobbleConnectionStore.getPublicStatus(req.user.id);
+ status.lastfm.configured = Boolean(getLastfmApiKey() && getLastfmApiSecret());
+ res.json(status);
+});
+
+router.get("/lastfm/link", requireAuth, (req, res) => {
+ const configured = Boolean(getLastfmApiKey() && getLastfmApiSecret());
+ if (!configured) {
+ return res.status(400).json({ error: "Last.fm API key and secret are required first." });
+ }
+ const token = createLinkToken(req.user.id);
+ res.json({
+ configured: true,
+ connected: scrobbleConnectionStore.getConnection(req.user.id, "lastfm") != null,
+ authorizeUrl: `https://www.last.fm/api/auth/?api_key=${encodeURIComponent(getLastfmApiKey())}&cb=${encodeURIComponent(callbackUrl(req, token))}`,
+ });
+});
+
+router.get("/lastfm/link/callback", async (req, res) => {
+ try {
+ const userId = verifyLinkToken(req.query.uid);
+ const token = String(req.query.token || "").trim();
+ if (!userId || !token) return res.status(400).send("Invalid Last.fm link request");
+ const session = await lastfmGetSession(token);
+ const key = session?.session?.key;
+ if (!key) return res.status(400).send("Last.fm did not return a session");
+ scrobbleConnectionStore.saveConnection(userId, "lastfm", {
+ token: key,
+ displayName: session.session.name,
+ });
+ return res.type("html").send("Last.fm connected. You can close this window.
");
+ } catch {
+ return res.status(400).send("Last.fm connection failed");
+ }
+});
+
+router.delete("/lastfm/link", requireAuth, (req, res) => {
+ scrobbleConnectionStore.deleteConnection(req.user.id, "lastfm");
+ res.status(204).end();
+});
+
+router.get("/listenbrainz/link", requireAuth, (req, res) => {
+ const connection = scrobbleConnectionStore.getConnection(req.user.id, "listenbrainz");
+ res.json({ connected: Boolean(connection), displayName: connection?.displayName || null });
+});
+
+router.put("/listenbrainz/link", requireAuth, async (req, res) => {
+ try {
+ const token = String(req.body?.token || "").trim();
+ if (!token) return res.status(400).json({ error: "Token is required" });
+ const validation = await listenbrainzValidateToken(token);
+ if (!validation?.valid) return res.status(400).json({ error: "Invalid token" });
+ const connection = scrobbleConnectionStore.saveConnection(req.user.id, "listenbrainz", {
+ token,
+ displayName: validation.user_name,
+ });
+ return res.json({ connected: true, displayName: connection.displayName });
+ } catch (error) {
+ return res.status(400).json({ error: error.message || "Could not validate token" });
+ }
+});
+
+router.delete("/listenbrainz/link", requireAuth, (req, res) => {
+ scrobbleConnectionStore.deleteConnection(req.user.id, "listenbrainz");
+ res.status(204).end();
+});
+
+router.put("/koito/link", requireAuth, (req, res) => {
+ const rawUrl = String(req.body?.url || userOps.getUserById(req.user.id)?.listenHistoryUrl || "").trim();
+ const validation = validateExternalUrl(rawUrl);
+ const token = String(req.body?.token || "").trim();
+ if (!validation.valid || !token) return res.status(400).json({ error: validation.error || "Token is required" });
+ const connection = scrobbleConnectionStore.saveConnection(req.user.id, "koito", {
+ token,
+ baseUrl: normalizeKoitoBaseUrl(validation.url),
+ displayName: normalizeKoitoBaseUrl(validation.url),
+ });
+ res.json({ connected: true, displayName: connection.displayName });
+});
+
+router.delete("/koito/link", requireAuth, (req, res) => {
+ scrobbleConnectionStore.deleteConnection(req.user.id, "koito");
+ res.status(204).end();
+});
+
+export default router;
diff --git a/backend/routes/subsonic.js b/backend/routes/subsonic.js
index 1a0eeb651..5047f4f8f 100644
--- a/backend/routes/subsonic.js
+++ b/backend/routes/subsonic.js
@@ -24,6 +24,7 @@ import {
starMany,
unstarMany,
} from "../services/subsonicLibraryService.js";
+import { recordPlayEvent } from "../services/playEventService.js";
const SUBSONIC_VERSION = "1.16.1";
const SUBSONIC_NAMESPACE = "http://subsonic.org/restapi";
@@ -186,7 +187,14 @@ async function handleSubsonicRequest(req, res) {
? null
: resolveUser(getParameter(req, "u"), decodedPassword)
: resolveSubsonicTokenUser(getParameter(req, "u"), token, salt);
- if (!user) return sendError(res, format, 40, "Wrong username or password");
+ if (!user) {
+ return sendError(
+ res,
+ format,
+ token && salt ? 41 : 40,
+ token && salt ? "Token authentication failed" : "Wrong username or password",
+ );
+ }
req.user = user;
const method = String(req.params.method || "").replace(/\.view$/i, "").toLowerCase();
@@ -204,7 +212,7 @@ async function handleSubsonicRequest(req, res) {
jukeboxRole: false,
playlistRole: Boolean(req.user.permissions?.accessFlow),
podcastRole: false,
- scrobblingEnabled: false,
+ scrobblingEnabled: true,
settingsRole: isAdmin,
shareRole: false,
streamRole: true,
@@ -213,6 +221,28 @@ async function handleSubsonicRequest(req, res) {
},
});
}
+ if (method === "scrobble") {
+ const ids = getParameters(req, ["id"]);
+ if (ids.length === 0) return sendError(res, format, 10, "Required parameter is missing: id");
+ const times = getParameters(req, ["time"]);
+ const submission = !["false", "0", "no"].includes(getParameter(req, "submission").toLowerCase());
+ if (submission) {
+ ids.forEach((id, index) => {
+ const song = getSong(id, user);
+ if (!song) return;
+ recordPlayEvent(user.id, {
+ trackId: song.id,
+ title: song.title,
+ artist: song.artist,
+ album: song.album,
+ durationMs: Number(song.duration || 0) * 1000,
+ playedAt: times[index] || undefined,
+ source: "subsonic",
+ });
+ });
+ }
+ return sendResponse(res, format);
+ }
if (method === "getlicense") {
return sendResponse(res, format, "ok", null, { license: { valid: true } });
}
diff --git a/backend/routes/users.js b/backend/routes/users.js
index abd6b956c..892042fd0 100644
--- a/backend/routes/users.js
+++ b/backend/routes/users.js
@@ -43,6 +43,14 @@ const buildListenHistoryUpdates = (body, existing) => {
: existing.listenHistoryProvider,
);
+ if (provider === "local") {
+ return {
+ listenHistoryProvider: "local",
+ listenHistoryUsername: null,
+ listenHistoryUrl: null,
+ };
+ }
+
if (provider === "koito") {
const rawUrl = hasListenHistoryUrlUpdate ? body.listenHistoryUrl : existing.listenHistoryUrl;
const trimmedUrl = normalizeListenHistoryUrl(rawUrl);
diff --git a/backend/server.js b/backend/server.js
index 142e639ca..c0155ed4f 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -39,6 +39,8 @@ import lidarrFeedRouter from "./routes/lidarrFeed.js";
import inboxRouter from "./routes/inbox.js";
import newsRouter from "./routes/news.js";
import subsonicRouter from "./routes/subsonic.js";
+import scrobblingRouter from "./routes/scrobbling.js";
+import playEventsRouter from "./routes/playEvents.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -217,6 +219,8 @@ app.use("/api/weekly-flow", (req, res) => {
res.redirect(308, target);
});
app.use("/api/auth", authRouter);
+app.use("/api/scrobbling", scrobblingRouter);
+app.use("/api/play-events", playEventsRouter);
app.use("/api/image-proxy", imageProxyRouter);
app.use("/rest", subsonicRouter);
diff --git a/backend/services/apiClients/config.js b/backend/services/apiClients/config.js
index ac4eb58c8..895fbf34c 100644
--- a/backend/services/apiClients/config.js
+++ b/backend/services/apiClients/config.js
@@ -8,6 +8,11 @@ export const getLastfmApiKey = () => {
return settings.integrations?.lastfm?.apiKey || process.env.LASTFM_API_KEY;
};
+export const getLastfmApiSecret = () => {
+ const settings = dbOps.getSettings();
+ return settings.integrations?.lastfm?.apiSecret || process.env.LASTFM_API_SECRET;
+};
+
export const getTicketmasterApiKey = () => {
const settings = dbOps.getSettings();
const configuredValue = settings.integrations?.ticketmaster?.apiKey;
diff --git a/backend/services/apiClients/index.js b/backend/services/apiClients/index.js
index 4285917f4..11122a4f6 100644
--- a/backend/services/apiClients/index.js
+++ b/backend/services/apiClients/index.js
@@ -1,5 +1,6 @@
export {
getLastfmApiKey,
+ getLastfmApiSecret,
getTicketmasterApiKey,
getNewsSettings,
normalizeNewsFeeds,
@@ -22,9 +23,13 @@ export {
musicbrainzResolveArtistMbidByName,
} from "./musicbrainz.js";
-export { lastfmRequest } from "./lastfm.js";
+export { lastfmRequest, lastfmGetSession, lastfmScrobble } from "./lastfm.js";
-export { listenbrainzRequest } from "./listenbrainz.js";
+export {
+ listenbrainzRequest,
+ listenbrainzSubmit,
+ listenbrainzValidateToken,
+} from "./listenbrainz.js";
export {
getDeezerArtistById,
diff --git a/backend/services/apiClients/lastfm.js b/backend/services/apiClients/lastfm.js
index 08b7e360b..9d5a82368 100644
--- a/backend/services/apiClients/lastfm.js
+++ b/backend/services/apiClients/lastfm.js
@@ -1,10 +1,11 @@
import axios from "../../../lib/axiosFetch.js";
import https from "https";
+import { createHash } from "node:crypto";
import createRateLimiter from "./rateLimiter.js";
import createCache from "./simpleCache.js";
import { logger } from "../logger.js";
import { LASTFM_API } from "../../config/constants.js";
-import { getLastfmApiKey } from "./config.js";
+import { getLastfmApiKey, getLastfmApiSecret } from "./config.js";
import { runSharedInflight } from "../sharedInflight.js";
const lastfmCache = createCache(300);
@@ -26,6 +27,45 @@ const LASTFM_MAX_RETRIES = 2;
const lastfmInflightRequests = new Map();
const lastfmErrorLogAt = new Map();
+const signedLastfmRequest = async (method, params = {}) => {
+ const apiKey = getLastfmApiKey();
+ const apiSecret = getLastfmApiSecret();
+ if (!apiKey || !apiSecret) throw new Error("Last.fm API credentials are not configured");
+ const signedParams = { ...params, api_key: apiKey, method };
+ const signature = Object.keys(signedParams)
+ .sort()
+ .map((key) => `${key}${signedParams[key]}`)
+ .join("");
+ signedParams.api_sig = createHash("md5").update(`${signature}${apiSecret}`).digest("hex");
+ const response = await lastfmLimiter.schedule(() =>
+ axios.post(LASTFM_API, new URLSearchParams({ ...signedParams, format: "json" }), {
+ timeout: LASTFM_TIMEOUT_MS,
+ httpsAgent: lastfmHttpsAgent,
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ validateStatus: (status) => status >= 200 && status < 300,
+ }),
+ );
+ if (response.data?.error) {
+ throw new Error(response.data.message || `Last.fm request failed (${response.data.error})`);
+ }
+ return response.data;
+};
+
+export const lastfmGetSession = (token) =>
+ signedLastfmRequest("auth.getSession", { token: String(token || "").trim() });
+
+export const lastfmScrobble = (event, sessionKey) =>
+ signedLastfmRequest("track.scrobble", {
+ sk: sessionKey,
+ artist: event.artist,
+ track: event.title,
+ timestamp: Math.floor(Number(event.playedAt) / 1000),
+ ...(event.album ? { album: event.album } : {}),
+ ...(event.artistMbid ? { artist_mbid: event.artistMbid } : {}),
+ ...(event.trackMbid ? { mbid: event.trackMbid } : {}),
+ ...(event.durationMs ? { duration: Math.round(event.durationMs / 1000) } : {}),
+ });
+
export async function lastfmRequest(method, params = {}, options = {}) {
const apiKey = getLastfmApiKey();
if (!apiKey) return null;
diff --git a/backend/services/apiClients/listenbrainz.js b/backend/services/apiClients/listenbrainz.js
index 0ee1b0706..6073428c9 100644
--- a/backend/services/apiClients/listenbrainz.js
+++ b/backend/services/apiClients/listenbrainz.js
@@ -14,6 +14,53 @@ const LISTENBRAINZ_MAX_RETRIES = 2;
const listenbrainzInflightRequests = new Map();
const listenbrainzErrorLogAt = new Map();
+const listenbrainzWrite = async (baseUrl, path, { token, data } = {}) => {
+ const response = await listenbrainzLimiter.schedule(() =>
+ axios.post(`${String(baseUrl).replace(/\/+$/, "")}${path}`, data, {
+ headers: { Authorization: `Token ${String(token || "").trim()}` },
+ timeout: LISTENBRAINZ_TIMEOUT_MS,
+ validateStatus: (status) => status >= 200 && status < 300,
+ }),
+ );
+ return response.data;
+};
+
+export const listenbrainzValidateToken = async (token) => {
+ const response = await listenbrainzLimiter.schedule(() =>
+ axios.get(`${LISTENBRAINZ_API}/1/validate-token`, {
+ headers: { Authorization: `Token ${String(token || "").trim()}` },
+ timeout: LISTENBRAINZ_TIMEOUT_MS,
+ validateStatus: (status) => status >= 200 && status < 300,
+ }),
+ );
+ return response.data;
+};
+
+export const listenbrainzSubmit = async ({ token, baseUrl = LISTENBRAINZ_API, event }) => {
+ const payload = {
+ listen_type: "single",
+ payload: [{
+ listened_at: Math.floor(Number(event.playedAt) / 1000),
+ track_metadata: {
+ artist_name: event.artist,
+ track_name: event.title,
+ ...(event.album ? { release_name: event.album } : {}),
+ additional_info: {
+ submission_client: "Aurral",
+ duration_ms: event.durationMs || undefined,
+ recording_mbid: event.trackMbid || undefined,
+ release_mbid: event.albumMbid || undefined,
+ artist_mbids: event.artistMbid ? [event.artistMbid] : undefined,
+ },
+ },
+ }],
+ };
+ return listenbrainzWrite(`${String(baseUrl).replace(/\/+$/, "")}/1`, "/submit-listens", {
+ token,
+ data: payload,
+ });
+};
+
export async function listenbrainzRequest(path, params = {}) {
const cacheKey = `lb:${path}:${JSON.stringify(params)}`;
const cached = listenbrainzCache.get(cacheKey);
diff --git a/backend/services/appRuntime.js b/backend/services/appRuntime.js
index 99268bfeb..313d1a590 100644
--- a/backend/services/appRuntime.js
+++ b/backend/services/appRuntime.js
@@ -8,6 +8,7 @@ import {
import { startSystemTaskWorker } from "./systemTaskWorker.js";
import { startLibraryScanWorker } from "./libraryScanWorker.js";
import { startNotificationOutboxWorker } from "./notificationOutboxWorker.js";
+import { startPlayEventOutboxWorker } from "./playEventOutboxWorker.js";
import { startSlskdOrchestratorWorker } from "./slskdOrchestratorWorker.js";
import { startDiscoveryRefreshWorker } from "./discoveryRefreshWorker.js";
import { startDiscoveryPlaylistBuildWorker } from "./discoveryPlaylistBuildWorker.js";
@@ -33,6 +34,7 @@ const WORKER_STARTS = {
"system-task": startSystemTaskWorker,
"library-scan": startLibraryScanWorker,
"_outbox:notifications": startNotificationOutboxWorker,
+ "_outbox:play-events": startPlayEventOutboxWorker,
"slskd-pipeline": startSlskdOrchestratorWorker,
"discovery-refresh": startDiscoveryRefreshWorker,
"discovery-playlist-build": startDiscoveryPlaylistBuildWorker,
diff --git a/backend/services/discovery/provider.js b/backend/services/discovery/provider.js
index d3658c1e3..31d0669c6 100644
--- a/backend/services/discovery/provider.js
+++ b/backend/services/discovery/provider.js
@@ -9,7 +9,6 @@ import {
} from "../apiClients/index.js";
import { logger } from "../logger.js";
import {
- getDefaultListenHistoryProfile,
getListenHistoryCacheNamespace,
getListenHistoryProfile,
hasListenHistoryProfile,
@@ -65,35 +64,18 @@ import {
} from "./persistence.js";
import { buildTasteProfile, collectSeedTagsAndGenres } from "./tasteProfile.js";
import { buildRecommendationsFromSeeds } from "./recommendations.js";
+import { getTopPlayedArtists } from "../playEventService.js";
export { DISCOVERY_QUALITY_ENRICHED };
const pendingUserDiscoveryProfiles = new Map();
-const hasListeningHistoryUsers = () => {
- const defaultProfile = getDefaultListenHistoryProfile(dbOps.getSettings());
- const globalNamespace = defaultProfile
- ? getListenHistoryCacheNamespace(defaultProfile)
- : null;
- return userOps.getAllListeningHistoryUsers().some((user) => {
- const profile = getListenHistoryProfile(user);
- if (!hasListenHistoryProfile(profile)) return false;
- if (globalNamespace && getListenHistoryCacheNamespace(profile) === globalNamespace) return false;
- return true;
- });
-};
-
const collectListeningHistoryRefreshProfiles = () => {
- const defaultProfile = getDefaultListenHistoryProfile(dbOps.getSettings());
- const globalNamespace = defaultProfile
- ? getListenHistoryCacheNamespace(defaultProfile)
- : null;
const profiles = new Map();
for (const user of userOps.getAllListeningHistoryUsers()) {
const profile = getListenHistoryProfile(user);
const cacheNamespace = getListenHistoryCacheNamespace(profile);
if (!cacheNamespace || !hasListenHistoryProfile(profile)) continue;
- if (globalNamespace && cacheNamespace === globalNamespace) continue;
profiles.set(cacheNamespace, {
profile,
feedbackUserId: user.id || null,
@@ -128,6 +110,7 @@ const enqueueListeningHistoryUserRefreshes = ({
{
listenHistoryProfile: entry.profile,
feedbackUserId: entry.feedbackUserId || null,
+ localOnly: entry.localOnly === true,
requestedAt: Date.now(),
reason,
},
@@ -143,7 +126,7 @@ const enqueueListeningHistoryUserRefreshes = ({
export const requestUserDiscoveryRefresh = (
listenHistoryProfile,
- { feedbackUserId = null } = {},
+ { feedbackUserId = null, localOnly = false } = {},
) => {
const profile = getListenHistoryProfile(listenHistoryProfile);
const cacheNamespace = getListenHistoryCacheNamespace(profile);
@@ -154,11 +137,13 @@ export const requestUserDiscoveryRefresh = (
pendingUserDiscoveryProfiles.set(cacheNamespace, {
profile,
feedbackUserId,
+ localOnly,
});
enqueueDiscoveryUserRefreshJob(
{
listenHistoryProfile: profile,
feedbackUserId,
+ localOnly,
requestedAt: Date.now(),
reason: "global_refresh_in_progress",
},
@@ -524,35 +509,6 @@ export const updateDiscoveryCache = async (options = {}) => {
);
const historyArtists = [];
- const defaultListenHistoryProfile = getDefaultListenHistoryProfile(
- dbOps.getSettings(),
- );
- const discoveryPeriod = getLastfmDiscoveryPeriod();
- const listeningHistoryUsersConfigured = hasListeningHistoryUsers();
- if (
- defaultListenHistoryProfile &&
- discoveryPeriod !== "none" &&
- !listeningHistoryUsersConfigured
- ) {
- try {
- const fetched = await fetchListenHistoryArtists(
- defaultListenHistoryProfile,
- discoveryPeriod,
- lastfmHealth,
- );
- historyArtists.push(
- ...fetched.map((artist) => ({
- ...artist,
- source: defaultListenHistoryProfile.listenHistoryProvider,
- })),
- );
- } catch (error) {
- logger.warn(
- 'discovery',
- `[Discovery] Failed to load default listening history for ${defaultListenHistoryProfile.listenHistoryUsername}: ${error.message}`,
- );
- }
- }
const profileSampleSeedCount = selectDiscoverySeedSample(
buildDiscoverySeedList({
@@ -887,7 +843,7 @@ export const updateUserDiscoveryCache = async (
options = {},
) => {
const { withHonkerLock } = await import("../honkerDb.js");
- const { duringGlobalRefresh = false } = options;
+ const { duringGlobalRefresh = false, localOnly = false } = options;
const profile = getListenHistoryProfile(listenHistoryProfile);
const cacheNamespace = getListenHistoryCacheNamespace(profile);
if (!cacheNamespace) return null;
@@ -911,11 +867,13 @@ export const updateUserDiscoveryCache = async (
pendingUserDiscoveryProfiles.set(cacheNamespace, {
profile,
feedbackUserId: options.feedbackUserId || null,
+ localOnly,
});
enqueueDiscoveryUserRefreshJob(
{
listenHistoryProfile: profile,
feedbackUserId: options.feedbackUserId || null,
+ localOnly,
requestedAt: Date.now(),
reason: "global_refresh_in_progress",
},
@@ -949,7 +907,7 @@ export const updateUserDiscoveryCache = async (
const discoveryPeriod = getLastfmDiscoveryPeriod();
const historyArtists = [];
- if (discoveryPeriod !== "none") {
+ if (!localOnly && discoveryPeriod !== "none") {
logger.info(
'discovery',
`[Discovery] Fetching ${profile.listenHistoryProvider} top artists for ${profile.listenHistoryUsername} (period: ${discoveryPeriod})...`,
@@ -978,6 +936,15 @@ export const updateUserDiscoveryCache = async (
}
}
+ if (options.feedbackUserId) {
+ historyArtists.push(
+ ...getTopPlayedArtists(options.feedbackUserId, { limit: 50 }).map((artist) => ({
+ ...artist,
+ source: "local",
+ })),
+ );
+ }
+
const recommendationRunStartedAt = new Date().toISOString();
const discoveryRunId = createDiscoveryRunId();
const feedback = options.feedbackUserId
@@ -989,7 +956,7 @@ export const updateUserDiscoveryCache = async (
const globalTopTags = globalCache.topTags || [];
const globalTopGenres = globalCache.topGenres || [];
- if (globalPool.length === 0) {
+ if (globalPool.length === 0 && historyArtists.length === 0) {
logger.info(
'discovery',
`[Discovery] Per-user refresh skipped for ${profile.listenHistoryUsername}: global pool is empty.`,
@@ -1006,9 +973,31 @@ export const updateUserDiscoveryCache = async (
return null;
}
- let recommendationsArray = [];
- recommendationsArray = mergeRetainedRecommendationPool({
- freshRecommendations: recommendationsArray,
+ const personalSeeds = buildDiscoverySeedList({
+ libraryArtists: [],
+ historyArtists,
+ });
+ let freshRecommendations = [];
+ if (personalSeeds.length > 0) {
+ const rawRecommendations = await buildRecommendationsFromSeeds({
+ seeds: personalSeeds,
+ existingArtistKeys,
+ lastfmHealth,
+ profileTagWeights: new Map(),
+ seedTagMap: new Map(),
+ discoveryMode: getDiscoveryMode(),
+ includeCandidateTagHydration: false,
+ includeSecondHop: true,
+ });
+ freshRecommendations = await resolveRecommendationCandidates(
+ rawRecommendations,
+ existingArtistKeys,
+ 40,
+ { resolveLimit: getDiscoveryRecommendationsPerRefresh() },
+ );
+ }
+ const recommendationsArray = mergeRetainedRecommendationPool({
+ freshRecommendations: freshRecommendations.length ? freshRecommendations : globalPool,
existingRecommendations:
dbOps.getDiscoveryCache(cacheNamespace).recommendations || [],
existingArtistKeys,
diff --git a/backend/services/discovery/userDiscovery.js b/backend/services/discovery/userDiscovery.js
index ab232c466..9c4bab576 100644
--- a/backend/services/discovery/userDiscovery.js
+++ b/backend/services/discovery/userDiscovery.js
@@ -23,7 +23,6 @@ import {
import {
getListenHistoryCacheNamespace,
getListenHistoryProfile,
- getDefaultListenHistoryProfile,
hasListenHistoryProfile,
} from "../listeningHistory.js";
import { enqueueDiscoveryRefresh } from "./refreshScheduler.js";
@@ -35,25 +34,28 @@ import {
DISCOVERY_REVALIDATE_COOLDOWN_MS,
getDiscoveryStaleMs,
} from "../../routes/discovery/handlers/utils.js";
+import { getTopPlayedArtists } from "../playEventService.js";
export async function getUserDiscovery(userId, limit = 50, offset = 0) {
const hasLastfmKey = !!getLastfmApiKey();
const libraryArtists = await libraryManager.getAllArtists();
const reqUser = userOps.getUserById(userId);
- const listenHistoryProfile = getListenHistoryProfile(reqUser || {});
+ const externalListenHistoryProfile = getListenHistoryProfile(reqUser || {});
+ const localHistoryArtists = getTopPlayedArtists(userId, { limit: 50 });
+ const localOnlyProfile = externalListenHistoryProfile.listenHistoryProvider === "local";
+ const hasExternalListenHistory =
+ !localOnlyProfile && hasListenHistoryProfile(externalListenHistoryProfile);
+ const hasLocalListenHistory = localOnlyProfile || localHistoryArtists.length > 0;
+ const listenHistoryProfile = hasExternalListenHistory
+ ? externalListenHistoryProfile
+ : hasLocalListenHistory
+ ? { listenHistoryProvider: "lastfm", listenHistoryUsername: `__aurral_local_${userId}` }
+ : externalListenHistoryProfile;
+ const localOnly = !hasExternalListenHistory && hasLocalListenHistory;
const userCacheNamespace =
getListenHistoryCacheNamespace(listenHistoryProfile);
- const defaultProfile = getDefaultListenHistoryProfile(dbOps.getSettings());
- const globalNamespace = defaultProfile
- ? getListenHistoryCacheNamespace(defaultProfile)
- : null;
- const identityMatches = userCacheNamespace && globalNamespace && userCacheNamespace === globalNamespace;
- const effectiveCacheNamespace = identityMatches
- ? null
- : hasLastfmKey
- ? userCacheNamespace
- : null;
+ const effectiveCacheNamespace = hasLastfmKey ? userCacheNamespace : null;
if (
hasListenHistoryProfile(listenHistoryProfile) &&
@@ -65,6 +67,7 @@ export async function getUserDiscovery(userId, limit = 50, offset = 0) {
if (staleness > staleMs) {
requestUserDiscoveryRefresh(listenHistoryProfile, {
feedbackUserId: userId || null,
+ localOnly,
}).catch((err) => {
logger.error("discovery", `On-demand refresh for ${listenHistoryProfile.listenHistoryProvider}:${listenHistoryProfile.listenHistoryUsername} failed`, { error: err.message });
});
@@ -149,6 +152,19 @@ export async function getUserDiscovery(userId, limit = 50, offset = 0) {
recommendations: globalTop,
feedback,
});
+ const localBasedOn = localHistoryArtists.map((artist) => ({
+ name: artist.artistName,
+ id: artist.mbid,
+ source: "local",
+ profileBucket: null,
+ }));
+ const seenBasedOn = new Set((basedOn || []).map((artist) => `${artist.id || ""}:${artist.name || ""}`));
+ basedOn = [...(basedOn || []), ...localBasedOn.filter((artist) => {
+ const key = `${artist.id || ""}:${artist.name || ""}`;
+ if (seenBasedOn.has(key)) return false;
+ seenBasedOn.add(key);
+ return true;
+ })];
fallbackGenres = (Array.isArray(fallbackGenres) ? fallbackGenres : []).map((section) => ({
...section,
artists: filterBlockedArtistsForUser(userId || "global", section?.artists || []),
diff --git a/backend/services/discoveryUserRefreshWorker.js b/backend/services/discoveryUserRefreshWorker.js
index 8fc2cc6c0..bcfeb2544 100644
--- a/backend/services/discoveryUserRefreshWorker.js
+++ b/backend/services/discoveryUserRefreshWorker.js
@@ -22,6 +22,7 @@ async function processDiscoveryUserRefresh(payload = {}) {
}
await updateUserDiscoveryCache(profile, {
feedbackUserId: payload?.feedbackUserId || null,
+ localOnly: payload?.localOnly === true,
});
return { refreshed: true };
}
diff --git a/backend/services/honkerDb.js b/backend/services/honkerDb.js
index d2cfbe3aa..98cf8ad90 100644
--- a/backend/services/honkerDb.js
+++ b/backend/services/honkerDb.js
@@ -15,6 +15,7 @@ export const HONKER_QUEUE_NAMES = [
"discovery-playlist-build",
"discovery-user-refresh",
"_outbox:notifications",
+ "_outbox:play-events",
];
function resolveHonkerDbPath() {
@@ -26,6 +27,7 @@ function resolveHonkerDbPath() {
let honkerDb = null;
let openedHonkerDbPath = null;
let notificationOutbox = null;
+let playEventOutbox = null;
let honkerSchedulerStarted = false;
let honkerSchedulerAbort = null;
let honkerSchedulerPromise = null;
@@ -322,6 +324,30 @@ export function enqueueNotification(payload) {
.catch((err) => { console.warn(err); }); return jobId;
}
+export function getPlayEventOutbox() {
+ if (!playEventOutbox) {
+ playEventOutbox = getHonkerDb().outbox(
+ "play-events",
+ async (payload, job) => {
+ const { deliverPlayEvent } = await import("./playEventService.js");
+ const { withJobHeartbeat } = await import("./honkerWorkerRuntime.js");
+ const outbox = getPlayEventOutbox();
+ await withJobHeartbeat(job, outbox.queue, () => deliverPlayEvent(payload));
+ },
+ { visibilityTimeoutS: 120, maxAttempts: 5, baseBackoffS: 30 },
+ );
+ }
+ return playEventOutbox;
+}
+
+export function enqueuePlayEventDelivery(payload) {
+ const jobId = getPlayEventOutbox().enqueue(payload);
+ import("./playEventOutboxWorker.js")
+ .then(({ startPlayEventOutboxWorker }) => startPlayEventOutboxWorker())
+ .catch((err) => { console.warn(err); });
+ return jobId;
+}
+
export function bootstrapHonkerSchedules() {
const scheduler = getHonkerDb().scheduler();
const canonicalByName = new Map(SCHEDULED_SYSTEM_TASKS.map((task) => [task.name, task]));
@@ -436,6 +462,7 @@ export function closeHonkerDb() {
reset();
}
notificationOutbox = null;
+ playEventOutbox = null;
}
export function isHonkerLockHeld(name) {
@@ -554,6 +581,9 @@ export function getHonkerQueueByName(queueName) {
if (queueName === "_outbox:notifications") {
return getNotificationOutbox().queue;
}
+ if (queueName === "_outbox:play-events") {
+ return getPlayEventOutbox().queue;
+ }
return queueByName.get(queueName)?.getQueue() ?? null;
}
diff --git a/backend/services/listeningHistory.js b/backend/services/listeningHistory.js
index e4e524966..84b97899c 100644
--- a/backend/services/listeningHistory.js
+++ b/backend/services/listeningHistory.js
@@ -1,4 +1,4 @@
-export const LISTEN_HISTORY_PROVIDERS = ["lastfm", "listenbrainz", "koito"];
+export const LISTEN_HISTORY_PROVIDERS = ["local", "lastfm", "listenbrainz", "koito"];
export const DEFAULT_LISTEN_HISTORY_PROVIDER = "lastfm";
const CACHE_PREFIX_BY_PROVIDER = {
@@ -58,7 +58,7 @@ export function getListenHistoryProfile(source = {}) {
return {
listenHistoryProvider: provider,
- listenHistoryUsername: provider === "koito" ? null : username,
+ listenHistoryUsername: provider === "koito" || provider === "local" ? null : username,
listenHistoryUrl: provider === "koito" ? url : null,
lastfmUsername: provider === "lastfm" ? username : null,
};
@@ -66,6 +66,7 @@ export function getListenHistoryProfile(source = {}) {
export function hasListenHistoryProfile(profile) {
const normalized = getListenHistoryProfile(profile);
+ if (normalized.listenHistoryProvider === "local") return true;
if (normalized.listenHistoryProvider === "koito") {
return !!normalized.listenHistoryUrl;
}
@@ -84,6 +85,7 @@ export function listenHistoryProfilesEqual(a, b) {
export function getListenHistoryCacheNamespace(profile) {
const normalized = getListenHistoryProfile(profile);
+ if (normalized.listenHistoryProvider === "local") return null;
if (normalized.listenHistoryProvider === "koito") {
if (!normalized.listenHistoryUrl) return null;
return `${CACHE_PREFIX_BY_PROVIDER.koito}:${normalized.listenHistoryUrl}`;
@@ -93,16 +95,7 @@ export function getListenHistoryCacheNamespace(profile) {
return prefix ? `${prefix}:${normalized.listenHistoryUsername}` : null;
}
-export function getDefaultListenHistoryProfile(settings) {
- const username = String(settings?.integrations?.lastfm?.username || "").trim();
- if (!username) return null;
- return {
- listenHistoryProvider: "lastfm",
- listenHistoryUsername: username,
- };
-}
-
-export function resolveListenHistorySettings(user = {}, settings = null) {
+export function resolveListenHistorySettings(user = {}) {
const profile = getListenHistoryProfile(user);
if (hasListenHistoryProfile(profile)) {
return {
@@ -112,15 +105,6 @@ export function resolveListenHistorySettings(user = {}, settings = null) {
lastfmUsername: profile.lastfmUsername,
};
}
- const defaultProfile = settings ? getDefaultListenHistoryProfile(settings) : null;
- if (defaultProfile) {
- return {
- listenHistoryProvider: defaultProfile.listenHistoryProvider,
- listenHistoryUsername: defaultProfile.listenHistoryUsername,
- listenHistoryUrl: null,
- lastfmUsername: defaultProfile.listenHistoryUsername,
- };
- }
return {
listenHistoryProvider: profile.listenHistoryProvider,
listenHistoryUsername: profile.listenHistoryUsername,
diff --git a/backend/services/playEventOutboxWorker.js b/backend/services/playEventOutboxWorker.js
new file mode 100644
index 000000000..eece210f3
--- /dev/null
+++ b/backend/services/playEventOutboxWorker.js
@@ -0,0 +1,57 @@
+import { getPlayEventOutbox, getWorkerId } from "./honkerDb.js";
+import {
+ isHonkerShuttingDown,
+ markHonkerWorkerLoopEnded,
+ registerHonkerWorker,
+} from "./honkerWorkerRuntime.js";
+
+const WORKER_NAME = "play-event-outbox";
+let running = false;
+let stopRequested = false;
+let loopPromise = null;
+let abortController = null;
+
+async function runLoop() {
+ abortController = new AbortController();
+ try {
+ await getPlayEventOutbox().runWorker(getWorkerId(), {
+ idlePollS: 5,
+ signal: abortController.signal,
+ });
+ } catch (error) {
+ if (!stopRequested && !isHonkerShuttingDown()) {
+ console.error("[playEventOutboxWorker] loop error:", error);
+ }
+ } finally {
+ abortController = null;
+ running = false;
+ loopPromise = null;
+ const intentional = stopRequested;
+ stopRequested = false;
+ markHonkerWorkerLoopEnded(WORKER_NAME, startPlayEventOutboxWorker, { intentional });
+ }
+}
+
+export function startPlayEventOutboxWorker() {
+ if (running || isHonkerShuttingDown()) return;
+ running = true;
+ stopRequested = false;
+ loopPromise = runLoop();
+ return loopPromise;
+}
+
+export function stopPlayEventOutboxWorker() {
+ stopRequested = true;
+ abortController?.abort();
+ return loopPromise || Promise.resolve();
+}
+
+export function isPlayEventOutboxWorkerRunning() {
+ return running;
+}
+
+registerHonkerWorker(WORKER_NAME, {
+ start: startPlayEventOutboxWorker,
+ stop: stopPlayEventOutboxWorker,
+ isRunning: isPlayEventOutboxWorkerRunning,
+});
diff --git a/backend/services/playEventService.js b/backend/services/playEventService.js
new file mode 100644
index 000000000..1fbca8541
--- /dev/null
+++ b/backend/services/playEventService.js
@@ -0,0 +1,126 @@
+import { db } from "../config/db-sqlite.js";
+import { logger } from "./logger.js";
+import { enqueuePlayEventDelivery } from "./honkerDb.js";
+import { scrobbleConnectionStore } from "./scrobbleConnectionStore.js";
+import { normalizeKoitoBaseUrl } from "./koitoClient.js";
+
+const insertEventStmt = db.prepare(`
+ INSERT INTO play_events
+ (user_id, track_id, title, artist, album, artist_mbid, album_mbid, track_mbid,
+ duration_ms, played_at, source, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+`);
+const getEventStmt = db.prepare("SELECT * FROM play_events WHERE id = ?");
+const getHistoryStmt = db.prepare(
+ "SELECT * FROM play_events WHERE user_id = ? ORDER BY played_at DESC, id DESC LIMIT ? OFFSET ?",
+);
+const getArtistsStmt = db.prepare(`
+ SELECT artist, MAX(artist_mbid) AS artist_mbid, COUNT(*) AS play_count,
+ MAX(played_at) AS last_played_at
+ FROM play_events
+ WHERE user_id = ?
+ GROUP BY artist
+ ORDER BY play_count DESC, last_played_at DESC
+ LIMIT ?
+`);
+
+const text = (value, max = 500) => String(value || "").trim().slice(0, max);
+const positiveInt = (value, fallback = null) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : fallback;
+};
+
+const toPublicEvent = (row) => row && ({
+ id: row.id,
+ userId: row.user_id,
+ trackId: row.track_id,
+ title: row.title,
+ artist: row.artist,
+ album: row.album,
+ artistMbid: row.artist_mbid,
+ albumMbid: row.album_mbid,
+ trackMbid: row.track_mbid,
+ durationMs: row.duration_ms,
+ playedAt: row.played_at,
+ source: row.source,
+});
+
+export const getPlayHistory = (userId, { limit = 50, offset = 0 } = {}) => {
+ const safeLimit = Math.min(100, Math.max(1, positiveInt(limit, 50)));
+ const safeOffset = Math.max(0, positiveInt(offset, 0));
+ return getHistoryStmt.all(userId, safeLimit, safeOffset).map(toPublicEvent);
+};
+
+export const getTopPlayedArtists = (userId, { limit = 20 } = {}) => {
+ const safeLimit = Math.min(100, Math.max(1, positiveInt(limit, 20)));
+ return getArtistsStmt.all(userId, safeLimit).map((row) => ({
+ artistName: row.artist,
+ mbid: row.artist_mbid || null,
+ playcount: Number(row.play_count) || 0,
+ lastPlayedAt: Number(row.last_played_at) || null,
+ }));
+};
+
+export const recordPlayEvent = (userId, input = {}) => {
+ const trackId = text(input.trackId, 500);
+ const title = text(input.title, 500);
+ const artist = text(input.artist, 500);
+ if (!trackId || !title || !artist) throw new Error("trackId, title, and artist are required");
+ const playedAtValue = Number(input.playedAt);
+ const playedAt = Number.isFinite(playedAtValue)
+ ? (playedAtValue < 10_000_000_000 ? Math.trunc(playedAtValue * 1000) : Math.trunc(playedAtValue))
+ : Date.now();
+ const result = insertEventStmt.run(
+ userId,
+ trackId,
+ title,
+ artist,
+ text(input.album, 500) || null,
+ text(input.artistMbid, 100) || null,
+ text(input.albumMbid, 100) || null,
+ text(input.trackMbid, 100) || null,
+ positiveInt(input.durationMs),
+ playedAt,
+ text(input.source, 50) || "unknown",
+ Date.now(),
+ );
+ const event = toPublicEvent(getEventStmt.get(result.lastInsertRowid));
+ const providers = new Set(Object.keys(scrobbleConnectionStore.getConnections(userId)));
+ for (const provider of providers) {
+ try {
+ enqueuePlayEventDelivery({ eventId: event.id, userId, provider });
+ } catch (error) {
+ logger.warn("play-events", "Could not enqueue scrobble delivery", {
+ userId,
+ provider,
+ error: error?.message || String(error),
+ });
+ }
+ }
+ return event;
+};
+
+export const deliverPlayEvent = async ({ eventId, userId, provider }) => {
+ const event = toPublicEvent(getEventStmt.get(eventId));
+ const connection = scrobbleConnectionStore.getConnection(userId, provider);
+ if (!event) return;
+ if (provider === "lastfm" && connection) {
+ const { lastfmScrobble } = await import("./apiClients/lastfm.js");
+ await lastfmScrobble(event, connection.token);
+ return;
+ }
+ if (provider === "listenbrainz" && connection) {
+ const { listenbrainzSubmit } = await import("./apiClients/listenbrainz.js");
+ await listenbrainzSubmit({ token: connection.token, event });
+ return;
+ }
+ if (provider === "koito" && connection) {
+ const { listenbrainzSubmit } = await import("./apiClients/listenbrainz.js");
+ await listenbrainzSubmit({
+ token: connection.token,
+ baseUrl: normalizeKoitoBaseUrl(connection.baseUrl || ""),
+ event,
+ });
+ return;
+ }
+};
diff --git a/backend/services/scrobbleConnectionStore.js b/backend/services/scrobbleConnectionStore.js
new file mode 100644
index 000000000..fd5b40f61
--- /dev/null
+++ b/backend/services/scrobbleConnectionStore.js
@@ -0,0 +1,93 @@
+import crypto from "node:crypto";
+import { db, dbHelpers } from "../config/db-sqlite.js";
+import { decryptWithKey, encryptWithKey } from "../config/encryption.js";
+
+const SETTINGS_KEY = "scrobbleConnections";
+const PROVIDERS = new Set(["lastfm", "listenbrainz", "koito"]);
+const getSettingStmt = db.prepare("SELECT value FROM settings WHERE key = ?");
+const upsertSettingStmt = db.prepare(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
+);
+const getEncryptionKey = () => {
+ const stored = getSettingStmt.get("_encryptionKey")?.value;
+ if (stored) return Buffer.from(stored, "base64");
+ const key = crypto.randomBytes(32);
+ upsertSettingStmt.run("_encryptionKey", key.toString("base64"));
+ return key;
+};
+
+const readStore = () => {
+ const parsed = dbHelpers.parseJSON(getSettingStmt.get(SETTINGS_KEY)?.value);
+ return parsed && typeof parsed === "object" ? parsed : {};
+};
+
+const writeStore = (store) => upsertSettingStmt.run(SETTINGS_KEY, dbHelpers.stringifyJSON(store));
+const userKey = (userId) => String(Math.trunc(Number(userId)));
+const encryptToken = (token) => encryptWithKey(String(token || ""), getEncryptionKey());
+const decryptToken = (token) => decryptWithKey(token, getEncryptionKey());
+
+export const getScrobbleEncryptionKey = getEncryptionKey;
+
+const normalize = (provider, raw) => {
+ if (!PROVIDERS.has(provider) || !raw || typeof raw !== "object") return null;
+ const token = decryptToken(raw.token);
+ if (!token) return null;
+ return {
+ provider,
+ token,
+ displayName: String(raw.displayName || "").trim() || null,
+ baseUrl: String(raw.baseUrl || "").trim() || null,
+ connectedAt: Number(raw.connectedAt) || null,
+ };
+};
+
+export const scrobbleConnectionStore = {
+ getConnection(userId, provider) {
+ const connection = normalize(provider, readStore()[userKey(userId)]?.[provider]);
+ return connection;
+ },
+
+ getConnections(userId) {
+ const raw = readStore()[userKey(userId)] || {};
+ return Object.fromEntries([...PROVIDERS].map((provider) => {
+ const connection = normalize(provider, raw[provider]);
+ return connection ? [provider, connection] : null;
+ }).filter(Boolean));
+ },
+
+ getPublicStatus(userId) {
+ return Object.fromEntries([...PROVIDERS].map((provider) => {
+ const connection = this.getConnection(userId, provider);
+ return [provider, connection
+ ? { connected: true, displayName: connection.displayName, connectedAt: connection.connectedAt }
+ : { connected: false, displayName: null, connectedAt: null }];
+ }));
+ },
+
+ saveConnection(userId, provider, { token, displayName = null, baseUrl = null } = {}) {
+ if (!PROVIDERS.has(provider)) throw new Error("Unsupported scrobble provider");
+ const safeToken = String(token || "").trim();
+ if (!safeToken) throw new Error("Scrobble token is required");
+ const store = readStore();
+ const key = userKey(userId);
+ store[key] = store[key] || {};
+ store[key][provider] = {
+ token: encryptToken(safeToken),
+ displayName: String(displayName || "").trim() || null,
+ baseUrl: String(baseUrl || "").trim() || null,
+ connectedAt: Date.now(),
+ };
+ writeStore(store);
+ return this.getConnection(userId, provider);
+ },
+
+ deleteConnection(userId, provider) {
+ const store = readStore();
+ const key = userKey(userId);
+ if (!store[key]?.[provider]) return false;
+ delete store[key][provider];
+ if (Object.keys(store[key]).length === 0) delete store[key];
+ writeStore(store);
+ return true;
+ },
+};
diff --git a/docs/src/content/docs/admin/troubleshooting.mdx b/docs/src/content/docs/admin/troubleshooting.mdx
index 34176133c..9619cb913 100644
--- a/docs/src/content/docs/admin/troubleshooting.mdx
+++ b/docs/src/content/docs/admin/troubleshooting.mdx
@@ -14,7 +14,8 @@ description: Common setup and runtime issues.
- Make sure that Aurral can connect to Lidarr.
- Make sure that Lidarr contains artists.
- Add a Last.fm API key for better discovery.
-- Set your listening-history provider in **Profile** (Last.fm or ListenBrainz username, or Koito instance URL).
+- Set your listening-history provider in **Profile** (Local only, a Last.fm or ListenBrainz username,
+ or a Koito instance URL).
- Run a manual refresh from **Settings > Discover**.
## Playlists do not download
diff --git a/docs/src/content/docs/integrations/koito.mdx b/docs/src/content/docs/integrations/koito.mdx
index 0aeba07af..79f941d5b 100644
--- a/docs/src/content/docs/integrations/koito.mdx
+++ b/docs/src/content/docs/integrations/koito.mdx
@@ -9,11 +9,13 @@ Aurral can use your Koito data for personalized discovery. You do not need Last.
## Per-user setup
-Each user must set a Koito instance URL:
+Each user must set a Koito instance URL and API key:
1. Open **Profile > Listening history**.
2. Select **Koito**.
3. Enter a base URL that Aurral can reach.
+4. Open **Settings > Playback > Scrobbling > Koito**.
+5. Enter the Koito API key and select **Connect Koito**.
For example, enter `https://koito.example.com:4110`.
@@ -21,6 +23,9 @@ For example, enter `https://koito.example.com:4110`.
Aurral reads your top artists from the Koito chart API. Aurral uses these artists for recommendations and discover playlists.
+Aurral sends completed local plays to Koito through its ListenBrainz-compatible `submit-listens`
+endpoint. A provider outage does not remove local history.
+
The Aurral server must be able to reach Koito.
## Requirements
@@ -34,7 +39,7 @@ The Aurral server must be able to reach Koito.
| Provider | Admin setup | User setup |
| ------------ | ---------------------------------- | --------------------------- |
| Last.fm | API key in **Settings > Connect** | Username in **Profile** |
-| ListenBrainz | None | Username in **Profile** |
-| Koito | None | Instance URL in **Profile** |
+| ListenBrainz | None | Username in **Profile**; token in **Settings > Playback > Scrobbling** |
+| Koito | None | Instance URL and history choice in **Profile**; API key in **Settings > Playback > Scrobbling** |
Last.fm still supplies tags and similar-artist discovery when you configure an API key. Koito supplies listening-history context only.
diff --git a/docs/src/content/docs/integrations/lastfm.mdx b/docs/src/content/docs/integrations/lastfm.mdx
index 129d16d00..72d3fcfbf 100644
--- a/docs/src/content/docs/integrations/lastfm.mdx
+++ b/docs/src/content/docs/integrations/lastfm.mdx
@@ -5,13 +5,27 @@ description: Personalized discovery, tags, and listening history.
You can use Aurral without Last.fm. A Last.fm API key improves recommendations, tags, and discover playlists.
-## Server setup
+## Recommendations
-Add your Last.fm API key in **Settings > Connect > Last.fm**.
+Add your Last.fm API key and API secret in **Settings > Connect > Last.fm**. Aurral uses the key
+for recommendations and discovery data, and uses both values to connect Last.fm scrobbling.
+
+## Scrobbling
+
+Open **Settings > Playback > Scrobbling** and enable **Last.fm**.
+
+Aurral copies Navidrome's Last.fm flow directly: it uses the API key and API secret from
+**Settings > Connect**, then stores each user's Last.fm session key. Navidrome is not required.
## Per-user listening history
-Each user sets a Last.fm username in **Profile**. Aurral uses the username to make personal recommendations.
+Each user selects a history source in **Profile**. Select **Last.fm** and enter a Last.fm username
+to read external history. Select **Local only** to use only Aurral play events.
+
+Aurral combines local play events with external history when a user selects Last.fm, ListenBrainz,
+or Koito.
+
+Aurral records local plays from the built-in player and Subsonic clients before it contacts Last.fm. A Last.fm outage does not remove local history or stop playback.

@@ -19,4 +33,5 @@ Each user sets a Last.fm username in **Profile**. Aurral uses the username to ma
Admins set the refresh interval and discovery mode in **Settings > Discover**. The available modes are Safer, Balanced, and Deeper.
-If Last.fm is unavailable, Aurral can use ListenBrainz or [Koito](/integrations/koito/) listening history when users have them configured.
+If Last.fm is unavailable, Aurral can use ListenBrainz or [Koito](/integrations/koito/) history
+when the user selects that provider in **Profile**.
diff --git a/docs/src/content/docs/integrations/navidrome.mdx b/docs/src/content/docs/integrations/navidrome.mdx
index f242613cc..d7bfe4af7 100644
--- a/docs/src/content/docs/integrations/navidrome.mdx
+++ b/docs/src/content/docs/integrations/navidrome.mdx
@@ -19,7 +19,23 @@ Use the Aurral URL as the Feishin server URL. Set the server type to `Subsonic`.
The native Subsonic responses include playlist artwork through `getPlaylists`, `getPlaylist`, and `getCoverArt`. They also expose genres from canonical artist, album, and track metadata through artist, album, song, and `getGenres` responses. The native path does not fetch metadata from a provider while browsing.
-Aurral accepts password authentication for every Aurral user. Token authentication is limited to the configured account because Aurral stores other user passwords as one-way hashes.
+## Submit play history
+
+Aurral reports `scrobble.view` submissions from Subsonic clients as local play events. The endpoint accepts repeated `id` and `time` parameters and follows the Subsonic `submission` flag. A successful submission records local history before Aurral delivers the play to any configured scrobbling provider.
+
+Aurral exposes `scrobblingEnabled=true` from `getuser.view`. This reports that the server supports the scrobble endpoint. It does not require a provider connection.
+
+The built-in Aurral player records completed library tracks through the same local play-event path. Aurral keeps local history when Last.fm, ListenBrainz, or Koito is unavailable.
+
+## Navidrome-compatible scrobbling
+
+Open **Settings > Playback > Scrobbling** to connect Last.fm or ListenBrainz. Aurral copies
+Navidrome's provider flows directly, so a Navidrome connection is not required for scrobbling.
+
+Aurral uses the Last.fm API key and API secret from **Settings > Connect > Last.fm**. It validates
+ListenBrainz tokens directly. Completed plays are submitted from Aurral to the selected provider.
+
+Navidrome remains an independent playback and playlist destination.
See [Filesystem and mounts](/getting-started/storage/) for the complete layout. The recommended Docker setup mounts the same host media root at `/data` in Aurral and Navidrome:
diff --git a/docs/src/content/docs/using/discover.mdx b/docs/src/content/docs/using/discover.mdx
index 550619811..08c2f5033 100644
--- a/docs/src/content/docs/using/discover.mdx
+++ b/docs/src/content/docs/using/discover.mdx
@@ -52,7 +52,9 @@ You can add them to your Playlists library for downloads and playback.
## Per-user listening history
-Each user sets a listening-history provider in **Profile**. Enter a Last.fm username, ListenBrainz username, or Koito instance URL.
+Each user sets a listening-history provider in **Profile**. Select **Local only**, enter a Last.fm
+or ListenBrainz username, or enter a Koito instance URL. Aurral uses local play events for every
+profile and adds external history when the selected provider supplies it.
Aurral uses this value to make personal recommendations on a shared instance.
diff --git a/frontend/src/contexts/AudioQueueProvider.jsx b/frontend/src/contexts/AudioQueueProvider.jsx
index 24f1fa158..421545f49 100644
--- a/frontend/src/contexts/AudioQueueProvider.jsx
+++ b/frontend/src/contexts/AudioQueueProvider.jsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "r
import { useAudioPlayerContext } from "react-use-audio-player";
import { getFormatLoadAttempts, getHowlerFormat, normalizeQueueTrack } from "../utils/audioQueue";
import { AudioQueueContext } from "./audioQueueContext";
+import { recordPlayEvent } from "../utils/api/endpoints/auth";
const SHARED_VOLUME_KEY = "aurral.preview.volume";
const SHARED_VOLUME_EVENT = "aurral:shared-volume-change";
@@ -205,6 +206,20 @@ export function AudioQueueProvider({ children }) {
onend: () => {
const cur = stateRef.current;
if (cur.currentIndex < 0) return;
+ if (track.recordHistory) {
+ recordPlayEvent({
+ trackId: track.id,
+ title: track.title,
+ artist: track.artist,
+ album: track.album,
+ artistMbid: track.artistMbid,
+ albumMbid: track.albumMbid,
+ trackMbid: track.trackMbid,
+ durationMs: track.durationMs,
+ playedAt: Date.now(),
+ source: "native-player",
+ }).catch(() => {});
+ }
if (cur.repeatMode === "one") {
loadedSignatureRef.current = null;
diff --git a/frontend/src/pages/LibraryPage.jsx b/frontend/src/pages/LibraryPage.jsx
index 8179c5bcd..d3d35f16c 100644
--- a/frontend/src/pages/LibraryPage.jsx
+++ b/frontend/src/pages/LibraryPage.jsx
@@ -701,6 +701,9 @@ function LibraryPage() {
quality: file?.quality || null,
artistMbid: artist?.mbid || null,
albumMbid: album?.mbid || album?.releaseGroupMbid || null,
+ trackMbid: track.mbid || track.trackMbid || null,
+ durationMs: Number(track.durationMs || file?.durationMs || 0) || null,
+ recordHistory: true,
artwork: getAlbumCover(album),
};
},
diff --git a/frontend/src/pages/Settings/components/SettingsAccountTab.jsx b/frontend/src/pages/Settings/components/SettingsAccountTab.jsx
index a0db983d6..967b918ce 100644
--- a/frontend/src/pages/Settings/components/SettingsAccountTab.jsx
+++ b/frontend/src/pages/Settings/components/SettingsAccountTab.jsx
@@ -63,6 +63,7 @@ export function SettingsAccountTab({
}
const profileSummary = (() => {
+ if (listenHistoryProvider === "local") return "Local only";
if (listenHistoryProvider === "koito" && listenHistoryUrl) {
return `Koito: ${listenHistoryUrl}`;
}
@@ -150,8 +151,16 @@ export function SettingsAccountTab({
setListenHistoryProvider(e.target.value)}
+ onChange={(e) => {
+ const provider = e.target.value;
+ setListenHistoryProvider(provider);
+ if (provider === "local") {
+ setListenHistoryUsername("");
+ setListenHistoryUrl("");
+ }
+ }}
>
+
@@ -160,7 +169,14 @@ export function SettingsAccountTab({
Select the service that supplies your listening history for personalized discovery.
- {listenHistoryProvider === "koito" ? (
+ {listenHistoryProvider === "local" ? (
+
+
+ Use Aurral play events for personalized recommendations. Aurral will not read
+ listening history from an external service.
+
+
+ ) : listenHistoryProvider === "koito" ? (