Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .tests/auth/listening-history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
});
});

Expand Down
25 changes: 25 additions & 0 deletions .tests/discovery/discovery-refresh-scheduler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
discoveryNeedsRefresh,
enqueueDiscoveryRefresh,
markDiscoveryRefreshDequeued,
recoverDeadDiscoveryRefresh,
scheduleNextDiscoveryRefresh,
} = refreshScheduler;
const { getDiscoveryCache } = discoveryIndex;
Expand Down Expand Up @@ -124,6 +125,30 @@ test("enqueueDiscoveryRefresh treats force as success when already updating", ()
assert.equal(result.reason, "already_updating");
});

test("recoverDeadDiscoveryRefresh clears jobs and locks owned by dead local workers", () => {
clearDiscoveryRefreshJobs();
const workerId = "aurral-99999999";
const lock = honkerDbModule.getHonkerDb().tryLock(
"discovery-global-refresh",
workerId,
3600,
);
assert.ok(lock);
const jobId = honkerDbModule.getDiscoveryRefreshQueue().enqueue({ reason: "manual" });
const claimed = honkerDbModule.getDiscoveryRefreshQueue().claimOne(workerId);
assert.equal(claimed?.id, jobId);

assert.equal(recoverDeadDiscoveryRefresh(), true);
assert.equal(
honkerDbModule.getHonkerDb().query(
"SELECT COUNT(*) AS count FROM _honker_live WHERE id = ?",
[jobId],
)[0]?.count,
0,
);
assert.equal(honkerDbModule.isHonkerLockHeld("discovery-global-refresh"), false);
});

test("enqueueDiscoveryRefresh deduplicates when refresh queue lock is held", () => {
const first = enqueueDiscoveryRefresh({ reason: "manual" });
assert.equal(first.enqueued, true);
Expand Down
2 changes: 2 additions & 0 deletions .tests/helpers/backendTestHarness.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const repoRoot = join(__dirname, "..", "..");

const RESET_TABLES = [
"sessions",
"lastfm_link_states",
"subsonic_stars",
"play_events",
"honker_task_runs",
"slskd_transfer_history",
"playlist_download_jobs",
Expand Down
71 changes: 71 additions & 0 deletions .tests/history/play-events.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
cleanupIsolatedState,
resetDatabase,
setupIsolatedBackend,
} from "../helpers/backendTestHarness.js";

const [isolatedState, playEvents, scrobbleStore, honkerDbModule] = await setupIsolatedBackend(
"play-events",
"backend/services/playEventService.js",
"backend/services/scrobbleConnectionStore.js",
"backend/services/honkerDb.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,
});
});

test("pins each scrobble delivery to the connection active when the play was recorded", () => {
const userId = db.prepare("SELECT id FROM users WHERE username = ?").get("listener").id;
const connection = scrobbleStore.scrobbleConnectionStore.saveConnection(userId, "lastfm", {
token: "session-token",
displayName: "listener",
});
const event = playEvents.recordPlayEvent(userId, {
trackId: "song:one",
title: "One",
artist: "Artist A",
});
const row = honkerDbModule.getHonkerDb().query(
"SELECT payload FROM _honker_live WHERE queue = ?",
["_outbox:play-events"],
)[0];

assert.equal(JSON.parse(row.payload).eventId, event.id);
assert.equal(JSON.parse(row.payload).connectionRevision, connection.connectionRevision);
});
34 changes: 34 additions & 0 deletions .tests/scrobbling/callback.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";

import { callbackUrl } from "../../backend/routes/scrobbling.js";

test("Last.fm callback uses the request host and preserves signed state", () => {
const request = {
protocol: "http",
get(name) {
return {
host: "192.168.4.115:3009",
"x-forwarded-host": "attacker.example",
"x-forwarded-proto": "https",
}[String(name).toLowerCase()] || undefined;
},
};

const callback = new URL(callbackUrl(request, "encoded-payload.signature"));

assert.equal(callback.origin, "http://192.168.4.115:3009");
assert.equal(callback.searchParams.get("uid"), "encoded-payload.signature");
});

test("Last.fm callback uses the configured public origin when available", () => {
const previous = process.env.AURRAL_PUBLIC_URL;
process.env.AURRAL_PUBLIC_URL = "https://aurral.example.com";
try {
const callback = new URL(callbackUrl({ protocol: "http", get: () => "attacker.example" }, "state"));
assert.equal(callback.origin, "https://aurral.example.com");
} finally {
if (previous === undefined) delete process.env.AURRAL_PUBLIC_URL;
else process.env.AURRAL_PUBLIC_URL = previous;
}
});
2 changes: 1 addition & 1 deletion backend/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const defaultData = {
},
lastfm: {
apiKey: "",
username: "",
apiSecret: "",
discoveryPeriod: "1month",
discoveryAutoRefreshHours: 168,
discoveryRecommendationsPerRefresh: 200,
Expand Down
33 changes: 33 additions & 0 deletions backend/config/db-sqlite.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ db.exec(`
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS lastfm_link_states (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
browser_nonce_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_lastfm_link_states_expiry
ON lastfm_link_states(expires_at);

CREATE TABLE IF NOT EXISTS subsonic_stars (
user_id INTEGER NOT NULL,
entity_kind TEXT NOT NULL,
Expand All @@ -85,6 +98,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,
Expand Down
1 change: 1 addition & 0 deletions backend/config/encryption.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const SENSITIVE_PATHS = [
["nzbget", "password"],
["gotify", "token"],
["lastfm", "apiKey"],
["lastfm", "apiSecret"],
];

function getAt(obj, path) {
Expand Down
2 changes: 1 addition & 1 deletion backend/db/helpers/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
8 changes: 2 additions & 6 deletions backend/middleware/auth.js
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
return next();
}
Expand Down
2 changes: 1 addition & 1 deletion backend/routes/discovery/handlers/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function registerAdmin(router) {
reason: "manual",
force: true,
});
if (!result.enqueued) {
if (!result.enqueued || result.reason === "already_updating") {
return res.status(409).json({
message: "Discovery update already in progress",
isUpdating: true,
Expand Down
7 changes: 1 addition & 6 deletions backend/routes/onboarding.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions backend/routes/playEvents.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading