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. ![Aurral profile listening history](../../../assets/screenshots/profile-listening.webp) @@ -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" ? (