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
108 changes: 108 additions & 0 deletions .tests/import-lists/lastfm-stations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import test from "node:test";
import assert from "node:assert/strict";

import { parseLastfmStation } from "../../backend/services/importLists/lastfmTracks.js";
import {
cleanupIsolatedState,
resetDatabase,
setupIsolatedBackend,
} from "../helpers/backendTestHarness.js";

const [isolatedState, { db }, { userOps }, { lastfmStationClient }] = await setupIsolatedBackend(
"lastfm-stations",
"backend/config/db-sqlite.js",
"backend/db/helpers/index.js",
"backend/services/importLists/lastfmStations.js",
);

test.beforeEach(() => resetDatabase(db));
test.after(() => cleanupIsolatedState(isolatedState));

test("parseLastfmStation maps tracks, durations, and skipped entries", () => {
const { tracks, stats } = parseLastfmStation({
playlist: [
{
name: "Song A",
duration: 185,
artists: [{ name: "Artist A" }],
primary_album: { name: "Album A" },
},
{
name: "Song A",
duration: 185,
artists: [{ name: "Artist A" }],
primary_album: { name: "Album A" },
},
{ name: "Missing Artist" },
],
});

assert.equal(tracks.length, 1);
assert.equal(tracks[0].durationMs, 185000);
assert.equal(tracks[0].artistName, "Artist A");
assert.deepEqual(stats, { incomplete: 1, duplicate: 1 });
});

test("lastfmStationClient lists the three stations in a stable order", async (t) => {
const calls = [];
t.mock.method(globalThis, "fetch", async (url, options) => {
calls.push({ url, options });
const station = new URL(url).pathname.split("/").pop();
return {
ok: true,
status: 200,
json: async () => ({
playlist: Array.from({ length: station === "recommended" ? 2 : 3 }, (_, index) => ({
name: `${station}-${index}`,
artists: [{ name: "Artist" }],
})),
}),
};
});

const result = await lastfmStationClient.listPlaylists(7, "user+name");

assert.deepEqual(
result.playlists.map(({ id, name, trackCount }) => ({ id, name, trackCount })),
[
{ id: "library", name: "Library", trackCount: 3 },
{ id: "mix", name: "Mix", trackCount: 3 },
{ id: "recommended", name: "Recommended", trackCount: 2 },
],
);
assert.equal(result.user, "user+name");
assert.deepEqual(
calls.map(({ url }) => new URL(url).pathname),
[
"/player/station/user/user%2Bname/library",
"/player/station/user/user%2Bname/mix",
"/player/station/user/user%2Bname/recommended",
],
);
assert.equal(calls[0].options.headers.Accept, "application/json");
});

test("lastfmStationClient reuses a Last.fm username from the profile", async (t) => {
const user = userOps.createUser("profile-user", "hash");
userOps.updateUser(user.id, {
listenHistoryProvider: "lastfm",
listenHistoryUsername: "profile-lastfm",
});
let requestedUrl = "";
t.mock.method(globalThis, "fetch", async (url) => {
requestedUrl = url;
return {
ok: true,
status: 200,
json: async () => ({
playlist: [{ name: "Song", artists: [{ name: "Artist" }] }],
}),
};
});

const result = await lastfmStationClient.getStationTracks(user.id, "library");

assert.equal(result.tracks.length, 1);
assert.equal(result.user, "profile-lastfm");
assert.equal(new URL(requestedUrl).pathname, "/player/station/user/profile-lastfm/library");
});
143 changes: 143 additions & 0 deletions .tests/import-lists/listenbrainz-playlists.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";

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

const [isolatedState, { db }, { scrobbleConnectionStore }, { listenbrainzPlaylistClient }] =
await setupIsolatedBackend(
"listenbrainz-playlists",
"backend/config/db-sqlite.js",
"backend/services/scrobbleConnectionStore.js",
"backend/services/importLists/listenbrainzPlaylists.js",
);

test.beforeEach(() => resetDatabase(db));
test.after(() => cleanupIsolatedState(isolatedState));

test("lists owned and created-for ListenBrainz playlists without duplicates", async (t) => {
const playlistExtension = "https://musicbrainz.org/doc/jspf#playlist";
const ownedId = "00000000-0000-4000-8000-000000000001";
const oldExplorationId = "00000000-0000-4000-8000-000000000002";
const explorationId = "00000000-0000-4000-8000-000000000003";
const jamsId = "00000000-0000-4000-8000-000000000004";
const noiseId = "00000000-0000-4000-8000-000000000005";
const makeEntry = ({ id, title, date, sourcePatch }) => ({
playlist: {
identifier: `https://listenbrainz.org/playlist/${id}`,
title,
date,
...(sourcePatch
? {
extension: {
[playlistExtension]: {
additional_metadata: {
algorithm_metadata: { source_patch: sourcePatch },
},
},
},
}
: {}),
},
});
scrobbleConnectionStore.saveConnection(7, "listenbrainz", {
token: "test-token",
displayName: "playlist-user",
});
const calls = [];
t.mock.method(axios, "get", async (url, options) => {
calls.push({ url, options });
if (url.includes("/1/playlist/")) {
const id = new URL(url).pathname.split("/").pop();
const trackCount = new Map([
[ownedId, 3],
[explorationId, 7],
[jamsId, 8],
]).get(id);
return {
data: {
playlist: {
track: Array.from({ length: trackCount || 0 }, () => ({})),
},
},
};
}
if (url.endsWith("/playlists/createdfor")) {
return {
data: {
playlist_count: 4,
playlists: [
makeEntry({
id: oldExplorationId,
title: "Weekly Exploration for playlist-user, week of 2026-07-01",
date: "2026-07-01T00:00:00Z",
sourcePatch: "weekly-exploration",
}),
makeEntry({
id: explorationId,
title: "Weekly Exploration for playlist-user, week of 2026-07-28",
date: "2026-07-28T00:00:00Z",
sourcePatch: "weekly-exploration",
}),
makeEntry({
id: jamsId,
title: "Weekly Jams for playlist-user, week of 2026-07-28",
date: "2026-07-28T00:00:00Z",
sourcePatch: "weekly-jams",
}),
makeEntry({
id: noiseId,
title: "Top Discoveries of 2025 for playlist-user",
date: "2025-12-31T00:00:00Z",
}),
],
},
};
}
return {
data: {
playlist_count: 1,
playlists: [
makeEntry({ id: ownedId, title: "Mine" }),
],
},
};
});

const result = await listenbrainzPlaylistClient.listPlaylists(7);

assert.deepEqual(result.playlists, [
{
id: ownedId,
name: "Mine",
trackCount: 3,
},
{
id: jamsId,
name: "Weekly Jams",
sourceType: "weekly-jams",
trackCount: 8,
},
{
id: explorationId,
name: "Weekly Exploration",
sourceType: "weekly-exploration",
trackCount: 7,
},
]);
assert.deepEqual(
calls
.filter(({ url }) => url.includes("/1/user/"))
.map(({ url }) => new URL(url).pathname),
[
"/1/user/playlist-user/playlists",
"/1/user/playlist-user/playlists/createdfor",
],
);
assert.equal(calls.filter(({ url }) => url.includes("/1/playlist/")).length, 3);
assert.equal(calls[0].options.headers.Authorization, "Token test-token");
});
55 changes: 55 additions & 0 deletions .tests/import-lists/listenbrainz-tracks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseListenBrainzPlaylist } from "../../backend/services/importLists/listenbrainzTracks.js";

test("parseListenBrainzPlaylist maps JSPF tracks and reports skipped entries", () => {
const { tracks, stats } = parseListenBrainzPlaylist({
playlist: {
track: [
{
creator: "Artist A",
title: "Song A",
album: "Album A",
duration: 185000,
identifier: ["https://musicbrainz.org/recording/track-mbid"],
extension: {
"https://musicbrainz.org/doc/jspf#track": {
artist_identifiers: ["https://musicbrainz.org/artist/artist-mbid"],
release_identifier: "https://musicbrainz.org/release/album-mbid",
},
},
},
{
creator: "Artist A",
title: "Song A",
album: "Album A",
duration: 185000,
identifier: ["https://musicbrainz.org/recording/track-mbid"],
extension: {
"https://musicbrainz.org/doc/jspf#track": {
artist_identifiers: ["https://musicbrainz.org/artist/artist-mbid"],
release_identifier: "https://musicbrainz.org/release/album-mbid",
},
},
},
{ creator: "Artist B" },
],
},
});

assert.deepEqual(tracks, [
{
artistName: "Artist A",
trackName: "Song A",
albumName: "Album A",
artistMbid: "artist-mbid",
albumMbid: "album-mbid",
trackMbid: "track-mbid",
releaseYear: null,
durationMs: 185000,
artistAliases: [],
reason: null,
},
]);
assert.deepEqual(stats, { incomplete: 1, duplicate: 1 });
});
12 changes: 12 additions & 0 deletions .tests/weekly-flow/playlist-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,18 @@ test("defaults Spotify removed-track retention on and preserves an explicit opt-
assert.equal(optedOut.keepRemovedTracks, false);
});

test("rejects unsupported playlist import providers", () => {
assert.equal(
normalizeImportSource({
provider: "unknown-provider",
externalId: "playlist-id",
syncEnabled: true,
syncIntervalHours: 24,
}),
null,
);
});

test("preserves rich track metadata when shared playlists are updated", () => {
const playlist = flowPlaylistConfig.createSharedPlaylist({
name: "Metadata Mix",
Expand Down
Loading
Loading