From 18d46d6bd695203b66e7a4fa94aa3fcdde07e8b0 Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Thu, 20 Aug 2026 06:25:14 +0000 Subject: [PATCH 1/3] feat(playlists): add ListenBrainz and Last.fm imports --- .tests/import-lists/lastfm-stations.test.js | 107 +++++ .../listenbrainz-playlists.test.js | 143 +++++++ .../import-lists/listenbrainz-tracks.test.js | 55 +++ .../weekly-flow/playlist-import-order.test.js | 110 ++++++ README.md | 2 +- .../weeklyFlow/handlers/lastfmImport.js | 94 +++++ .../weeklyFlow/handlers/listenbrainzImport.js | 96 +++++ .../weeklyFlow/handlers/spotifyImport.js | 41 +- backend/routes/weeklyFlow/index.js | 4 + backend/services/apiClients/listenbrainz.js | 31 +- .../services/importLists/importListSync.js | 22 +- .../services/importLists/importPlaylist.js | 70 ++++ .../services/importLists/lastfmStations.js | 97 +++++ backend/services/importLists/lastfmTracks.js | 38 ++ .../importLists/listenbrainzPlaylists.js | 215 ++++++++++ .../importLists/listenbrainzTracks.js | 43 ++ .../weeklyFlow/weeklyFlowPlaylistConfig.js | 3 + docs/src/content/docs/api/endpoints.mdx | 16 + docs/src/content/docs/using/overview.mdx | 2 +- .../content/docs/using/playlist-imports.mdx | 33 +- docs/src/content/docs/using/playlists.mdx | 8 +- frontend/src/components/PlaylistModals.jsx | 15 +- frontend/src/index.css | 207 ++++++---- frontend/src/pages/FlowPage.jsx | 32 +- frontend/src/pages/flows/FlowPlaylistUI.jsx | 76 ++-- .../flows/flowComponents/FlowEmptyState.jsx | 6 +- .../flows/import/PlaylistImportModal.jsx | 367 ++++++++++++++---- frontend/src/utils/api/endpoints/playlists.js | 26 ++ 28 files changed, 1732 insertions(+), 227 deletions(-) create mode 100644 .tests/import-lists/lastfm-stations.test.js create mode 100644 .tests/import-lists/listenbrainz-playlists.test.js create mode 100644 .tests/import-lists/listenbrainz-tracks.test.js create mode 100644 backend/routes/weeklyFlow/handlers/lastfmImport.js create mode 100644 backend/routes/weeklyFlow/handlers/listenbrainzImport.js create mode 100644 backend/services/importLists/importPlaylist.js create mode 100644 backend/services/importLists/lastfmStations.js create mode 100644 backend/services/importLists/lastfmTracks.js create mode 100644 backend/services/importLists/listenbrainzPlaylists.js create mode 100644 backend/services/importLists/listenbrainzTracks.js diff --git a/.tests/import-lists/lastfm-stations.test.js b/.tests/import-lists/lastfm-stations.test.js new file mode 100644 index 000000000..d39f0926e --- /dev/null +++ b/.tests/import-lists/lastfm-stations.test.js @@ -0,0 +1,107 @@ +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(new URL(requestedUrl).pathname, "/player/station/user/profile-lastfm/library"); +}); diff --git a/.tests/import-lists/listenbrainz-playlists.test.js b/.tests/import-lists/listenbrainz-playlists.test.js new file mode 100644 index 000000000..6147041ce --- /dev/null +++ b/.tests/import-lists/listenbrainz-playlists.test.js @@ -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"); +}); diff --git a/.tests/import-lists/listenbrainz-tracks.test.js b/.tests/import-lists/listenbrainz-tracks.test.js new file mode 100644 index 000000000..8bd9df8d1 --- /dev/null +++ b/.tests/import-lists/listenbrainz-tracks.test.js @@ -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 }); +}); diff --git a/.tests/weekly-flow/playlist-import-order.test.js b/.tests/weekly-flow/playlist-import-order.test.js index 3789ca337..cb746ee73 100644 --- a/.tests/weekly-flow/playlist-import-order.test.js +++ b/.tests/weekly-flow/playlist-import-order.test.js @@ -21,6 +21,8 @@ const [ playlistManagerModule, spotifyClientModule, importSyncModule, + listenbrainzPlaylistsModule, + lastfmStationsModule, ] = await setupIsolatedBackend( "playlist-import-order", "backend/config/db-sqlite.js", @@ -33,6 +35,8 @@ const [ "backend/services/weeklyFlow/weeklyFlowPlaylistManager.js", "backend/services/spotify/spotifyClient.js", "backend/services/importLists/importListSync.js", + "backend/services/importLists/listenbrainzPlaylists.js", + "backend/services/importLists/lastfmStations.js", ); const { downloadTracker } = trackerModule; @@ -46,6 +50,8 @@ const { weeklyFlowWorker } = workerModule; const { playlistSource } = playlistSourceModule; const { playlistManager } = playlistManagerModule; const { spotifyClient } = spotifyClientModule; +const { listenbrainzPlaylistClient } = listenbrainzPlaylistsModule; +const { lastfmStationClient } = lastfmStationsModule; const { syncSharedPlaylistImport } = importSyncModule; const weeklyFlowRoot = process.env.WEEKLY_FLOW_FOLDER; @@ -357,6 +363,110 @@ test("replacing a shared playlist removes Spotify tracks and honors file retenti } }); +test("ListenBrainz sync uses the shared import update path", async () => { + const originalStart = weeklyFlowWorker.start; + const originalGetGeneratedPlaylistTracks = + listenbrainzPlaylistClient.getGeneratedPlaylistTracks; + weeklyFlowWorker.start = async () => false; + try { + const playlist = flowPlaylistConfig.createSharedPlaylist({ + name: "ListenBrainz Mix", + ownerUserId: 7, + tracks: [{ artistName: "Old Artist", trackName: "Old Song" }], + importSource: { + provider: "listenbrainz-createdfor", + externalId: "weekly-jams", + syncEnabled: true, + syncIntervalHours: 24, + }, + }); + listenbrainzPlaylistClient.getGeneratedPlaylistTracks = async () => ({ + tracks: [{ artistName: "New Artist", trackName: "New Song" }], + stats: { incomplete: 0, duplicate: 0 }, + }); + + await syncSharedPlaylistImport({ + playlistId: playlist.id, + user: { id: 7 }, + force: true, + }); + + assert.deepEqual(flowPlaylistConfig.getSharedPlaylist(playlist.id).tracks, [ + { + artistName: "New Artist", + trackName: "New Song", + albumName: null, + artistMbid: null, + albumMbid: null, + trackMbid: null, + releaseYear: null, + durationMs: null, + artistAliases: [], + reason: null, + }, + ]); + } finally { + listenbrainzPlaylistClient.getGeneratedPlaylistTracks = originalGetGeneratedPlaylistTracks; + weeklyFlowWorker.start = originalStart; + weeklyFlowWorker.stop(); + } +}); + +test("Last.fm station sync refreshes the saved station and username", async () => { + const originalStart = weeklyFlowWorker.start; + const originalGetStationTracks = lastfmStationClient.getStationTracks; + weeklyFlowWorker.start = async () => false; + try { + const playlist = flowPlaylistConfig.createSharedPlaylist({ + name: "Last.fm Mix", + ownerUserId: 7, + tracks: [{ artistName: "Old Artist", trackName: "Old Song" }], + importSource: { + provider: "lastfm-station", + externalId: "mix", + externalUsername: "station-user", + syncEnabled: true, + syncIntervalHours: 24, + }, + }); + let requested; + lastfmStationClient.getStationTracks = async (userId, station, username) => { + requested = { userId, station, username }; + return { + tracks: [{ artistName: "New Artist", trackName: "New Song" }], + stats: { incomplete: 0, duplicate: 0 }, + }; + }; + + await syncSharedPlaylistImport({ + playlistId: playlist.id, + user: { id: 7 }, + force: true, + }); + + assert.deepEqual(requested, { + userId: 7, + station: "mix", + username: "station-user", + }); + assert.deepEqual( + flowPlaylistConfig.getSharedPlaylist(playlist.id).tracks.map(({ artistName, trackName }) => ({ + artistName, + trackName, + })), + [{ artistName: "New Artist", trackName: "New Song" }], + ); + assert.equal( + flowPlaylistConfig.getSharedPlaylist(playlist.id).importSource.externalUsername, + "station-user", + ); + } finally { + lastfmStationClient.getStationTracks = originalGetStationTracks; + weeklyFlowWorker.start = originalStart; + weeklyFlowWorker.stop(); + } +}); + test("Spotify sync keeps a retention change made while Spotify is pending", async () => { const originalStart = weeklyFlowWorker.start; const originalListPlaylistTracks = spotifyClient.listPlaylistTracks; diff --git a/README.md b/README.md index 7fdf97d97..069d0d9ec 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Aurral is the Lidarr companion for self-hosted music discovery. Best-in-class re - **Discover**: Best-in-class personalized recommendations, trends, tags, recent releases, discover playlists, and nearby shows. - **Search**: Find artists and albums, preview tracks, and add to Lidarr with your defaults. - **Library**: Browse and search artists already in Lidarr. -- **Playlists**: Run scheduled flows, adopt discover playlists like Release Radar, import spotify playlists, and convert flows to fixed tracklists. +- **Playlists**: Run scheduled flows, adopt discover playlists like Release Radar, import Spotify, Last.fm, or ListenBrainz playlists, and convert flows to fixed tracklists. - **Activity**: Queue and history for Lidarr requests, yt-dlp / slskd / Usenet downloads, plus Wanted actions for Aurral playlist jobs. - **Integrations**: Lidarr, Last.fm, ListenBrainz, Koito, yt-dlp, slskd, SABnzbd/NZBGet, Navidrome, Plex, Ticketmaster, Gotify, and webhooks. - **Playback**: Stream through API-synced Navidrome or Plex/Plexamp playlists from a dedicated download folder. diff --git a/backend/routes/weeklyFlow/handlers/lastfmImport.js b/backend/routes/weeklyFlow/handlers/lastfmImport.js new file mode 100644 index 000000000..831c817b3 --- /dev/null +++ b/backend/routes/weeklyFlow/handlers/lastfmImport.js @@ -0,0 +1,94 @@ +import { + enqueueImportedPlaylist, + fetchImportedPlaylistTracks, +} from "../../../services/importLists/importPlaylist.js"; +import { lastfmStationClient } from "../../../services/importLists/lastfmStations.js"; + +const getErrorStatus = (error) => error?.statusCode || error?.response?.status || 500; + +const getPlaylistImport = (body) => ({ + provider: "lastfm-station", + externalId: String(body?.playlistId || "").trim(), + externalUsername: String(body?.username || "").trim(), +}); + +export function registerLastfmImport(router) { + router.get("/import/lastfm/playlists", async (req, res) => { + try { + res.json(await lastfmStationClient.listPlaylists(req.user.id, req.query?.username)); + } catch (error) { + res.status(getErrorStatus(error)).json({ + error: "Failed to fetch Last.fm stations", + message: error?.message || "Unknown error", + }); + } + }); + + router.post("/import/lastfm/preview", async (req, res) => { + try { + const playlistImport = getPlaylistImport(req.body); + const { tracks, stats } = await fetchImportedPlaylistTracks({ + userId: req.user.id, + ...playlistImport, + }); + res.json({ + trackCount: tracks.length, + skipped: stats.incomplete + stats.duplicate, + previewTracks: tracks.slice(0, 3), + }); + } catch (error) { + res.status(getErrorStatus(error)).json({ + error: "Failed to preview Last.fm station", + message: error?.message || "Unknown error", + }); + } + }); + + router.post("/import/lastfm", async (req, res) => { + try { + const playlistImport = getPlaylistImport(req.body); + const name = String(req.body?.name || "").trim(); + const externalName = String(req.body?.externalName || "").trim(); + const syncIntervalHours = Number(req.body?.syncIntervalHours ?? 24); + const keepRemovedTracks = req.body?.keepRemovedTracks !== false; + const syncEnabled = req.body?.syncEnabled === false ? false : syncIntervalHours > 0; + if (!playlistImport.externalId) { + return res.status(400).json({ error: "playlistId is required" }); + } + if (!name) return res.status(400).json({ error: "name is required" }); + const { tracks } = await fetchImportedPlaylistTracks({ + userId: req.user.id, + ...playlistImport, + }); + const result = await enqueueImportedPlaylist({ + ownerUserId: req.user.id, + name, + sourceName: "Last.fm", + ...playlistImport, + externalName, + tracks, + syncEnabled, + syncIntervalHours, + keepRemovedTracks, + }); + res.json({ + success: true, + playlist: result?.playlist || null, + tracksQueued: Number(result?.tracksQueued || 0), + tracksReused: Number(result?.tracksReused || 0), + queued: result?.queued === true, + }); + } catch (error) { + if (error?.code === "SHARED_PLAYLIST_NAME_CONFLICT") { + return res.status(409).json({ + error: "Playlist name already exists", + message: error.message, + }); + } + res.status(getErrorStatus(error)).json({ + error: "Failed to import Last.fm station", + message: error?.message || "Unknown error", + }); + } + }); +} diff --git a/backend/routes/weeklyFlow/handlers/listenbrainzImport.js b/backend/routes/weeklyFlow/handlers/listenbrainzImport.js new file mode 100644 index 000000000..7ecd8b885 --- /dev/null +++ b/backend/routes/weeklyFlow/handlers/listenbrainzImport.js @@ -0,0 +1,96 @@ +import { + enqueueImportedPlaylist, + fetchImportedPlaylistTracks, +} from "../../../services/importLists/importPlaylist.js"; +import { listenbrainzPlaylistClient } from "../../../services/importLists/listenbrainzPlaylists.js"; + +const getErrorStatus = (error) => error?.statusCode || error?.response?.status || 500; + +const getPlaylistImport = (body) => { + const playlistType = String(body?.playlistType || "").trim(); + return playlistType + ? { provider: "listenbrainz-createdfor", externalId: playlistType } + : { + provider: "listenbrainz-playlist", + externalId: String(body?.playlistId || "").trim(), + }; +}; + +export function registerListenBrainzImport(router) { + router.get("/import/listenbrainz/playlists", async (req, res) => { + try { + res.json(await listenbrainzPlaylistClient.listPlaylists(req.user.id)); + } catch (error) { + res.status(getErrorStatus(error)).json({ + error: "Failed to fetch ListenBrainz playlists", + message: error?.message || "Unknown error", + }); + } + }); + + router.post("/import/listenbrainz/preview", async (req, res) => { + try { + const playlistImport = getPlaylistImport(req.body); + const { tracks, stats } = await fetchImportedPlaylistTracks({ + userId: req.user.id, + ...playlistImport, + }); + res.json({ + trackCount: tracks.length, + skipped: stats.incomplete + stats.duplicate, + previewTracks: tracks.slice(0, 3), + }); + } catch (error) { + res.status(getErrorStatus(error)).json({ + error: "Failed to preview ListenBrainz playlist", + message: error?.message || "Unknown error", + }); + } + }); + + router.post("/import/listenbrainz", async (req, res) => { + try { + const playlistImport = getPlaylistImport(req.body); + const name = String(req.body?.name || "").trim(); + const externalName = String(req.body?.externalName || "").trim(); + const syncIntervalHours = Number(req.body?.syncIntervalHours ?? 24); + const keepRemovedTracks = req.body?.keepRemovedTracks !== false; + const syncEnabled = + req.body?.syncEnabled === false ? false : syncIntervalHours > 0; + if (!name) return res.status(400).json({ error: "name is required" }); + const { tracks } = await fetchImportedPlaylistTracks({ + userId: req.user.id, + ...playlistImport, + }); + const result = await enqueueImportedPlaylist({ + ownerUserId: req.user.id, + name, + sourceName: "ListenBrainz", + ...playlistImport, + externalName, + tracks, + syncEnabled, + syncIntervalHours, + keepRemovedTracks, + }); + res.json({ + success: true, + playlist: result?.playlist || null, + tracksQueued: Number(result?.tracksQueued || 0), + tracksReused: Number(result?.tracksReused || 0), + queued: result?.queued === true, + }); + } catch (error) { + if (error?.code === "SHARED_PLAYLIST_NAME_CONFLICT") { + return res.status(409).json({ + error: "Playlist name already exists", + message: error.message, + }); + } + res.status(getErrorStatus(error)).json({ + error: "Failed to import ListenBrainz playlist", + message: error?.message || "Unknown error", + }); + } + }); +} diff --git a/backend/routes/weeklyFlow/handlers/spotifyImport.js b/backend/routes/weeklyFlow/handlers/spotifyImport.js index 984f098ba..cc273ccfb 100644 --- a/backend/routes/weeklyFlow/handlers/spotifyImport.js +++ b/backend/routes/weeklyFlow/handlers/spotifyImport.js @@ -2,11 +2,11 @@ import { buildSpotifyOAuthUrl, SPOTIFY_API_BASE } from "../../../services/spotif import { spotifyConnectionStore } from "../../../services/spotify/spotifyConnectionStore.js"; import { spotifyClient } from "../../../services/spotify/spotifyClient.js"; import { logger } from "../../../services/logger.js"; -import { parseSpotifyPlaylistItems } from "../../../services/importLists/spotifyTracks.js"; +import { + enqueueImportedPlaylist, + fetchImportedPlaylistTracks, +} from "../../../services/importLists/importPlaylist.js"; import { syncSharedPlaylistImport } from "../../../services/importLists/importListSync.js"; -import { normalizeImportSource } from "../../../services/weeklyFlow/weeklyFlowPlaylistConfig.js"; -import { weeklyFlowOperationQueue } from "../../../services/weeklyFlow/weeklyFlowOperationQueue.js"; -import { randomUUID } from "crypto"; import { getAccessibleSharedPlaylist } from "./utils.js"; const parseExpiresAt = (value) => { @@ -104,8 +104,11 @@ export function registerSpotifyImport(router) { if (!playlistId) { return res.status(400).json({ error: "playlistId is required" }); } - const items = await spotifyClient.listPlaylistTracks(req.user.id, playlistId); - const { tracks, stats } = parseSpotifyPlaylistItems(items); + const { tracks, stats } = await fetchImportedPlaylistTracks({ + provider: "spotify-playlist", + userId: req.user.id, + externalId: playlistId, + }); const skipped = stats.unavailable + stats.podcast + stats.incomplete + stats.duplicate; res.json({ @@ -138,28 +141,22 @@ export function registerSpotifyImport(router) { if (!name) { return res.status(400).json({ error: "name is required" }); } - const items = await spotifyClient.listPlaylistTracks(req.user.id, playlistId); - const tracks = parseSpotifyPlaylistItems(items).tracks; - const safePlaylistId = randomUUID(); - const importSource = normalizeImportSource({ + const { tracks } = await fetchImportedPlaylistTracks({ provider: "spotify-playlist", + userId: req.user.id, externalId: playlistId, - externalName: externalName || name, - syncEnabled, - syncIntervalHours: syncEnabled ? syncIntervalHours : 0, - keepRemovedTracks, - lastSyncAt: Date.now(), - lastSyncTrackCount: tracks.length, }); - const result = await weeklyFlowOperationQueue.enqueuePayload({ - kind: "shared-playlist-create", - label: "shared-playlist:create", - playlistId: safePlaylistId, + const result = await enqueueImportedPlaylist({ + ownerUserId: req.user.id, name, sourceName: "Spotify", + provider: "spotify-playlist", + externalId: playlistId, + externalName, tracks, - ownerUserId: req.user.id, - importSource, + syncEnabled, + syncIntervalHours, + keepRemovedTracks, }); res.json({ success: true, diff --git a/backend/routes/weeklyFlow/index.js b/backend/routes/weeklyFlow/index.js index de603ab53..7a4c348a4 100644 --- a/backend/routes/weeklyFlow/index.js +++ b/backend/routes/weeklyFlow/index.js @@ -6,6 +6,8 @@ import { registerArtworkManagement } from "./handlers/artworkManagement.js"; import { registerFlows } from "./handlers/flows.js"; import { registerSharedPlaylists } from "./handlers/sharedPlaylists.js"; import { registerSpotifyImport } from "./handlers/spotifyImport.js"; +import { registerListenBrainzImport } from "./handlers/listenbrainzImport.js"; +import { registerLastfmImport } from "./handlers/lastfmImport.js"; import { registerJobs } from "./handlers/jobs.js"; const router = express.Router(); @@ -20,6 +22,8 @@ registerArtworkManagement(router); registerFlows(router); registerSharedPlaylists(router); registerSpotifyImport(router); +registerListenBrainzImport(router); +registerLastfmImport(router); registerJobs(router); export default router; diff --git a/backend/services/apiClients/listenbrainz.js b/backend/services/apiClients/listenbrainz.js index 153ad2cd3..a86807c09 100644 --- a/backend/services/apiClients/listenbrainz.js +++ b/backend/services/apiClients/listenbrainz.js @@ -79,12 +79,20 @@ export const listenbrainzSubmit = async ({ token, baseUrl = LISTENBRAINZ_API, ev }); }; -export async function listenbrainzRequest(path, params = {}) { - const cacheKey = `lb:${path}:${JSON.stringify(params)}`; - const cached = listenbrainzCache.get(cacheKey); - if (cached !== undefined) return cached; - const inflight = listenbrainzInflightRequests.get(cacheKey); - if (inflight) return inflight; +export async function listenbrainzRequest( + path, + params = {}, + { token = null, baseUrl = LISTENBRAINZ_API } = {}, +) { + const root = normalizeListenbrainzBaseUrl(baseUrl); + const isAuthenticated = Boolean(String(token || "").trim()); + const cacheKey = isAuthenticated ? null : `lb:${path}:${JSON.stringify(params)}`; + if (cacheKey) { + const cached = listenbrainzCache.get(cacheKey); + if (cached !== undefined) return cached; + const inflight = listenbrainzInflightRequests.get(cacheKey); + if (inflight) return inflight; + } const requestPromise = (async () => { const isRetryable = (error) => { @@ -118,15 +126,18 @@ export async function listenbrainzRequest(path, params = {}) { ) { try { const response = await listenbrainzLimiter.schedule(() => - axios.get(`${LISTENBRAINZ_API}${path}`, { + axios.get(`${root}${path}`, { params, + ...(isAuthenticated + ? { headers: { Authorization: `Token ${String(token).trim()}` } } + : {}), timeout: LISTENBRAINZ_TIMEOUT_MS, validateStatus: (status) => (status >= 200 && status < 300) || status === 204, }), ); const payload = response.status === 204 ? null : response.data; - listenbrainzCache.set(cacheKey, payload); + if (cacheKey) listenbrainzCache.set(cacheKey, payload); return payload; } catch (error) { lastError = error; @@ -153,11 +164,11 @@ export async function listenbrainzRequest(path, params = {}) { throw lastError; })(); - listenbrainzInflightRequests.set(cacheKey, requestPromise); + if (cacheKey) listenbrainzInflightRequests.set(cacheKey, requestPromise); try { return await requestPromise; } finally { - listenbrainzInflightRequests.delete(cacheKey); + if (cacheKey) listenbrainzInflightRequests.delete(cacheKey); } } diff --git a/backend/services/importLists/importListSync.js b/backend/services/importLists/importListSync.js index f3ba769ec..3bccdf561 100644 --- a/backend/services/importLists/importListSync.js +++ b/backend/services/importLists/importListSync.js @@ -1,6 +1,5 @@ import { flowPlaylistConfig } from "../weeklyFlow/weeklyFlowPlaylistConfig.js"; -import { spotifyClient } from "../spotify/spotifyClient.js"; -import { parseSpotifyPlaylistItems } from "./spotifyTracks.js"; +import { fetchImportedPlaylistTracks } from "./importPlaylist.js"; import { updateSharedPlaylist } from "../weeklyFlow/weeklyFlowOperations.js"; const HOUR_MS = 60 * 60 * 1000; @@ -34,12 +33,15 @@ export async function syncSharedPlaylistImport({ const ownerUserId = playlist.ownerUserId ?? user?.id; try { const externalPlaylistId = String(playlist.importSource?.externalId || "").trim(); - const items = await spotifyClient.listPlaylistTracks( - ownerUserId, - externalPlaylistId, - { forceRefresh: true }, - ); - const tracks = parseSpotifyPlaylistItems(items).tracks; + const tracks = ( + await fetchImportedPlaylistTracks({ + provider: playlist.importSource.provider, + userId: ownerUserId, + externalId: externalPlaylistId, + externalUsername: playlist.importSource?.externalUsername, + forceRefresh: true, + }) + ).tracks; const syncImportSource = { lastSyncAt: Date.now(), lastSyncError: null, @@ -64,7 +66,7 @@ export async function syncSharedPlaylistImport({ flowPlaylistConfig.updateSharedPlaylist(playlist.id, { importSource: { ...(latestPlaylist?.importSource || playlist.importSource), - lastSyncError: String(error?.message || "Spotify sync failed"), + lastSyncError: String(error?.message || "Playlist sync failed"), }, }); throw error; @@ -88,7 +90,7 @@ export async function runDueImportSourceSyncs() { } catch (error) { results.push({ playlistId: playlist.id, - error: String(error?.message || "Spotify sync failed"), + error: String(error?.message || "Playlist sync failed"), }); } } diff --git a/backend/services/importLists/importPlaylist.js b/backend/services/importLists/importPlaylist.js new file mode 100644 index 000000000..20b32e0ee --- /dev/null +++ b/backend/services/importLists/importPlaylist.js @@ -0,0 +1,70 @@ +import { randomUUID } from "crypto"; +import { spotifyClient } from "../spotify/spotifyClient.js"; +import { parseSpotifyPlaylistItems } from "./spotifyTracks.js"; +import { listenbrainzPlaylistClient } from "./listenbrainzPlaylists.js"; +import { lastfmStationClient } from "./lastfmStations.js"; +import { normalizeImportSource } from "../weeklyFlow/weeklyFlowPlaylistConfig.js"; +import { weeklyFlowOperationQueue } from "../weeklyFlow/weeklyFlowOperationQueue.js"; + +export async function fetchImportedPlaylistTracks({ + provider, + userId, + externalId, + externalUsername, + forceRefresh = false, +} = {}) { + if (provider === "spotify-playlist") { + const items = await spotifyClient.listPlaylistTracks(userId, externalId, { forceRefresh }); + const parsed = parseSpotifyPlaylistItems(items); + return { tracks: parsed.tracks, stats: parsed.stats }; + } + if (provider === "listenbrainz-playlist") { + return listenbrainzPlaylistClient.getPlaylistTracks(userId, externalId); + } + if (provider === "listenbrainz-createdfor") { + return listenbrainzPlaylistClient.getGeneratedPlaylistTracks(userId, externalId); + } + if (provider === "lastfm-station") { + return lastfmStationClient.getStationTracks(userId, externalId, externalUsername); + } + const error = new Error(`Unsupported playlist import provider: ${provider || "unknown"}`); + error.statusCode = 400; + throw error; +} + +export async function enqueueImportedPlaylist({ + ownerUserId, + name, + sourceName, + provider, + externalId, + externalUsername, + externalName, + tracks, + syncEnabled, + syncIntervalHours, + keepRemovedTracks, +} = {}) { + const safePlaylistId = randomUUID(); + const importSource = normalizeImportSource({ + provider, + externalId, + externalUsername, + externalName: externalName || name, + syncEnabled, + syncIntervalHours: syncEnabled ? syncIntervalHours : 0, + keepRemovedTracks, + lastSyncAt: Date.now(), + lastSyncTrackCount: tracks.length, + }); + return weeklyFlowOperationQueue.enqueuePayload({ + kind: "shared-playlist-create", + label: "shared-playlist:create", + playlistId: safePlaylistId, + name, + sourceName, + tracks, + ownerUserId, + importSource, + }); +} diff --git a/backend/services/importLists/lastfmStations.js b/backend/services/importLists/lastfmStations.js new file mode 100644 index 000000000..9e88df401 --- /dev/null +++ b/backend/services/importLists/lastfmStations.js @@ -0,0 +1,97 @@ +import { userOps } from "../../db/helpers/index.js"; +import { parseLastfmStation } from "./lastfmTracks.js"; + +const LASTFM_STATIONS = [ + { id: "library", name: "Library" }, + { id: "mix", name: "Mix" }, + { id: "recommended", name: "Recommended" }, +]; +const LASTFM_STATION_IDS = new Set(LASTFM_STATIONS.map((station) => station.id)); +const LASTFM_STATION_URL = "https://www.last.fm/player/station/user"; +const LASTFM_TIMEOUT_MS = 15000; + +const invalidUsernameError = () => { + const error = new Error("A valid Last.fm username is required"); + error.statusCode = 400; + return error; +}; + +export function normalizeLastfmUsername(value) { + const username = String(value || "").trim(); + if (!username || username.length > 100 || /[/?#\u0000-\u001f\u007f]/.test(username)) { + throw invalidUsernameError(); + } + return username; +} + +export function normalizeLastfmStation(value) { + const station = String(value || "").trim(); + if (!LASTFM_STATION_IDS.has(station)) { + const error = new Error("Unsupported Last.fm station"); + error.statusCode = 400; + throw error; + } + return station; +} + +function resolveUsername(userId, requestedUsername) { + const requested = String(requestedUsername || "").trim(); + if (requested) return normalizeLastfmUsername(requested); + const user = userOps.getUserById(userId); + if (user?.listenHistoryProvider === "lastfm" && user.listenHistoryUsername) { + return normalizeLastfmUsername(user.listenHistoryUsername); + } + throw invalidUsernameError(); +} + +async function requestStation(username, stationId) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LASTFM_TIMEOUT_MS); + try { + const response = await fetch( + `${LASTFM_STATION_URL}/${encodeURIComponent(username)}/${stationId}`, + { + headers: { Accept: "application/json" }, + signal: controller.signal, + }, + ); + if (!response.ok) { + const error = new Error(`Last.fm station request failed (${response.status})`); + error.statusCode = response.status >= 400 && response.status < 500 ? 404 : 502; + throw error; + } + return parseLastfmStation(await response.json()); + } catch (error) { + if (error?.name === "AbortError") { + const timeoutError = new Error("Last.fm station request timed out"); + timeoutError.statusCode = 504; + throw timeoutError; + } + throw error; + } finally { + clearTimeout(timeout); + } +} + +export const lastfmStationClient = { + async listPlaylists(userId, requestedUsername) { + const username = resolveUsername(userId, requestedUsername); + const playlists = await Promise.all( + LASTFM_STATIONS.map(async (station) => { + const { tracks } = await requestStation(username, station.id); + return { + id: station.id, + name: station.name, + sourceType: "lastfm-station", + trackCount: tracks.length, + }; + }), + ); + return { user: username, playlists }; + }, + + async getStationTracks(userId, stationId, requestedUsername) { + const username = resolveUsername(userId, requestedUsername); + return requestStation(username, normalizeLastfmStation(stationId)); + }, +}; diff --git a/backend/services/importLists/lastfmTracks.js b/backend/services/importLists/lastfmTracks.js new file mode 100644 index 000000000..35472deb5 --- /dev/null +++ b/backend/services/importLists/lastfmTracks.js @@ -0,0 +1,38 @@ +import { dedupeSharedTracks } from "../weeklyFlow/weeklyFlowPlaylistConfig.js"; + +const getArtistName = (track) => + String(track?.artists?.[0]?.name || track?.artists?.[0]?._name || track?.artist?.name || track?.artist?._name || "").trim(); + +const getAlbumName = (track) => { + const album = track?.primary_album ?? track?.album; + return String(album?.name || album?._name || album || "").trim() || null; +}; + +export function parseLastfmStation(payload) { + const stats = { incomplete: 0, duplicate: 0 }; + const raw = []; + const tracks = Array.isArray(payload?.playlist) ? payload.playlist : []; + + for (const track of tracks) { + const artistName = getArtistName(track); + const trackName = String(track?.name || track?._name || "").trim(); + if (!artistName || !trackName) { + stats.incomplete += 1; + continue; + } + const duration = Number(track?.duration); + raw.push({ + artistName, + trackName, + albumName: getAlbumName(track), + trackMbid: String(track?.mbid || "").trim() || null, + artistMbid: String(track?.artist?.mbid || track?.artists?.[0]?.mbid || "").trim() || null, + albumMbid: String(track?.primary_album?.mbid || track?.album?.mbid || "").trim() || null, + durationMs: Number.isFinite(duration) && duration >= 0 ? Math.round(duration * 1000) : null, + }); + } + + const normalized = dedupeSharedTracks(raw); + stats.duplicate = Math.max(0, raw.length - normalized.length); + return { tracks: normalized, stats }; +} diff --git a/backend/services/importLists/listenbrainzPlaylists.js b/backend/services/importLists/listenbrainzPlaylists.js new file mode 100644 index 000000000..71a856db5 --- /dev/null +++ b/backend/services/importLists/listenbrainzPlaylists.js @@ -0,0 +1,215 @@ +import { listenbrainzRequest } from "../apiClients/listenbrainz.js"; +import { LISTENBRAINZ_API } from "../../config/constants.js"; +import { scrobbleConnectionStore } from "../scrobbleConnectionStore.js"; +import { parseListenBrainzPlaylist } from "./listenbrainzTracks.js"; + +const PLAYLIST_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PLAYLIST_EXTENSION = "https://musicbrainz.org/doc/jspf#playlist"; +const GENERATED_PLAYLIST_TYPES = ["weekly-jams", "weekly-exploration"]; +const GENERATED_PLAYLIST_NAMES = { + "weekly-jams": "Weekly Jams", + "weekly-exploration": "Weekly Exploration", +}; + +const getConnection = (userId) => { + const connection = scrobbleConnectionStore.getConnection(userId, "listenbrainz"); + if (connection) return connection; + const error = new Error("ListenBrainz is not connected"); + error.statusCode = 401; + throw error; +}; + +const unwrapPlaylist = (entry) => + entry?.playlist && typeof entry.playlist === "object" ? entry.playlist : entry; + +const getPlaylistId = (playlist) => { + const identifier = String(playlist?.identifier || "").trim(); + const match = identifier.match(/\/playlist\/([^/]+)\/?$/i); + return match?.[1] || ""; +}; + +const getGeneratedPlaylistType = (playlist) => { + const sourcePatch = String( + playlist?.extension?.[PLAYLIST_EXTENSION]?.additional_metadata?.algorithm_metadata + ?.source_patch || "", + ).trim().toLowerCase(); + return GENERATED_PLAYLIST_TYPES.includes(sourcePatch) ? sourcePatch : null; +}; + +const getPlaylistTimestamp = (playlist) => { + const value = + playlist?.date || playlist?.extension?.[PLAYLIST_EXTENSION]?.last_modified_at || ""; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; +}; + +const validatePlaylistId = (playlistId) => { + const id = String(playlistId || "").trim(); + if (PLAYLIST_ID_PATTERN.test(id)) return id; + const error = new Error("A valid ListenBrainz playlist ID is required"); + error.statusCode = 400; + throw error; +}; + +const fetchPlaylistPages = async ({ username, token, path }) => { + const count = 100; + const playlists = []; + let offset = 0; + let playlistCount = null; + do { + const payload = await listenbrainzRequest( + `/1/user/${encodeURIComponent(username)}/playlists${path}`, + { count, offset }, + { token, baseUrl: LISTENBRAINZ_API }, + ); + const page = Array.isArray(payload?.playlists) ? payload.playlists : []; + playlists.push(...page); + offset += page.length; + playlistCount = Number.isFinite(Number(payload?.playlist_count)) + ? Number(payload.playlist_count) + : null; + if (!page.length) break; + if (playlistCount == null && page.length < count) break; + } while (playlistCount == null || offset < playlistCount); + return playlists; +}; + +const fetchPlaylistTrackCount = async ({ playlistId, token }) => { + const payload = await listenbrainzRequest( + `/1/playlist/${encodeURIComponent(playlistId)}`, + { fetch_metadata: false }, + { token, baseUrl: LISTENBRAINZ_API }, + ); + const playlist = unwrapPlaylist(payload); + return Array.isArray(playlist?.track) ? playlist.track.length : null; +}; + +const getLatestGeneratedPlaylists = (entries) => { + const latest = new Map(); + for (const entry of entries) { + const playlist = unwrapPlaylist(entry); + const sourceType = getGeneratedPlaylistType(playlist); + const id = getPlaylistId(playlist); + if (!sourceType || !id) continue; + const candidate = { + id, + name: GENERATED_PLAYLIST_NAMES[sourceType], + sourceType, + trackCount: + Array.isArray(playlist?.track) && playlist.track.length > 0 + ? playlist.track.length + : null, + timestamp: getPlaylistTimestamp(playlist), + }; + const previous = latest.get(sourceType); + if (!previous || candidate.timestamp > previous.timestamp) { + latest.set(sourceType, candidate); + } + } + return GENERATED_PLAYLIST_TYPES + .map((sourceType) => latest.get(sourceType)) + .filter(Boolean) + .map(({ timestamp: _timestamp, ...playlist }) => playlist); +}; + +const getGeneratedPlaylistTypeOrThrow = (value) => { + const sourceType = String(value || "").trim().toLowerCase(); + if (GENERATED_PLAYLIST_TYPES.includes(sourceType)) return sourceType; + const error = new Error("A valid ListenBrainz generated playlist type is required"); + error.statusCode = 400; + throw error; +}; + +export const listenbrainzPlaylistClient = { + async listPlaylists(userId) { + const connection = getConnection(userId); + const username = String(connection.displayName || "").trim(); + if (!username) { + const error = new Error("ListenBrainz connection has no username"); + error.statusCode = 502; + throw error; + } + + const playlists = await fetchPlaylistPages({ username, token: connection.token, path: "" }); + const generatedPlaylists = getLatestGeneratedPlaylists( + await fetchPlaylistPages({ username, token: connection.token, path: "/createdfor" }), + ); + + const normalizedPlaylists = new Map(); + for (const entry of playlists) { + const playlist = unwrapPlaylist(entry); + const normalized = { + id: getPlaylistId(playlist), + name: String(playlist?.title || "").trim(), + trackCount: + Array.isArray(playlist?.track) && playlist.track.length > 0 + ? playlist.track.length + : null, + }; + if (normalized.id && normalized.name && !normalizedPlaylists.has(normalized.id)) { + normalizedPlaylists.set(normalized.id, normalized); + } + } + for (const playlist of generatedPlaylists) { + if (playlist.id && playlist.name && !normalizedPlaylists.has(playlist.id)) { + normalizedPlaylists.set(playlist.id, playlist); + } + } + const playlistsWithCounts = await Promise.all( + [...normalizedPlaylists.values()].map(async (playlist) => { + if (playlist.trackCount != null) return playlist; + try { + return { + ...playlist, + trackCount: await fetchPlaylistTrackCount({ + playlistId: playlist.id, + token: connection.token, + }), + }; + } catch { + return playlist; + } + }), + ); + return { + user: username, + playlists: playlistsWithCounts, + }; + }, + + async getGeneratedPlaylistTracks(userId, sourceType) { + const connection = getConnection(userId); + const username = String(connection.displayName || "").trim(); + if (!username) { + const error = new Error("ListenBrainz connection has no username"); + error.statusCode = 502; + throw error; + } + const type = getGeneratedPlaylistTypeOrThrow(sourceType); + const entries = await fetchPlaylistPages({ + username, + token: connection.token, + path: "/createdfor", + }); + const playlist = getLatestGeneratedPlaylists(entries).find( + (candidate) => candidate.sourceType === type, + ); + if (!playlist) { + const error = new Error(`No current ListenBrainz ${type} playlist was found`); + error.statusCode = 404; + throw error; + } + return this.getPlaylistTracks(userId, playlist.id); + }, + + async getPlaylistTracks(userId, playlistId) { + const connection = getConnection(userId); + const id = validatePlaylistId(playlistId); + const payload = await listenbrainzRequest( + `/1/playlist/${encodeURIComponent(id)}`, + {}, + { token: connection.token, baseUrl: LISTENBRAINZ_API }, + ); + return parseListenBrainzPlaylist(payload); + }, +}; diff --git a/backend/services/importLists/listenbrainzTracks.js b/backend/services/importLists/listenbrainzTracks.js new file mode 100644 index 000000000..e625c8228 --- /dev/null +++ b/backend/services/importLists/listenbrainzTracks.js @@ -0,0 +1,43 @@ +import { dedupeSharedTracks } from "../weeklyFlow/weeklyFlowPlaylistConfig.js"; + +const PLAYLIST_TRACK_EXTENSION = "https://musicbrainz.org/doc/jspf#track"; +const TRACK_URI_PREFIX = "https://musicbrainz.org/recording/"; +const ARTIST_URI_PREFIX = "https://musicbrainz.org/artist/"; +const RELEASE_URI_PREFIX = "https://musicbrainz.org/release/"; + +const getExtension = (track) => track?.extension?.[PLAYLIST_TRACK_EXTENSION] || {}; + +const getIdentifier = (value, prefix) => { + const candidate = Array.isArray(value) ? value[0] : value; + const text = String(candidate || "").trim(); + return text.startsWith(prefix) ? text.slice(prefix.length) : text || null; +}; + +export function parseListenBrainzPlaylist(payload) { + const stats = { incomplete: 0, duplicate: 0 }; + const raw = []; + const tracks = Array.isArray(payload?.playlist?.track) ? payload.playlist.track : []; + + for (const track of tracks) { + const extension = getExtension(track); + const artistName = String(track?.creator || "").trim(); + const trackName = String(track?.title || "").trim(); + if (!artistName || !trackName) { + stats.incomplete += 1; + continue; + } + raw.push({ + artistName, + trackName, + albumName: String(track?.album || "").trim() || null, + trackMbid: getIdentifier(track?.identifier, TRACK_URI_PREFIX), + artistMbid: getIdentifier(extension.artist_identifiers, ARTIST_URI_PREFIX), + albumMbid: getIdentifier(extension.release_identifier, RELEASE_URI_PREFIX), + durationMs: Number.isFinite(Number(track?.duration)) ? Number(track.duration) : null, + }); + } + + const normalized = dedupeSharedTracks(raw); + stats.duplicate = Math.max(0, raw.length - normalized.length); + return { tracks: normalized, stats }; +} diff --git a/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js b/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js index 36a821cd6..6d2fd1eb5 100644 --- a/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js +++ b/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js @@ -444,6 +444,9 @@ export function normalizeImportSource(value) { return { provider, externalId: String(value.externalId || "").trim() || null, + ...(provider === "lastfm-station" + ? { externalUsername: String(value.externalUsername || "").trim() || null } + : {}), externalName: String(value.externalName || "").trim() || null, syncEnabled: hasSync, syncIntervalHours: hasSync diff --git a/docs/src/content/docs/api/endpoints.mdx b/docs/src/content/docs/api/endpoints.mdx index 706a306f2..2d630f618 100644 --- a/docs/src/content/docs/api/endpoints.mdx +++ b/docs/src/content/docs/api/endpoints.mdx @@ -244,6 +244,22 @@ The repeated `playlists` segment in | `POST` | `/api/playlists/import/spotify/preview` | Preview a Spotify import | | `POST` | `/api/playlists/import/spotify` | Import Spotify playlists | +### ListenBrainz import + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/playlists/import/listenbrainz/playlists` | List playlists for the linked ListenBrainz account | +| `POST` | `/api/playlists/import/listenbrainz/preview` | Preview a ListenBrainz import | +| `POST` | `/api/playlists/import/listenbrainz` | Import a ListenBrainz playlist | + +### Last.fm import + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/playlists/import/lastfm/playlists` | List Last.fm Library, Mix, and Recommended stations | +| `POST` | `/api/playlists/import/lastfm/preview` | Preview a Last.fm station import | +| `POST` | `/api/playlists/import/lastfm` | Import a Last.fm station | + ## Lidarr feed | Method | Path | Purpose | diff --git a/docs/src/content/docs/using/overview.mdx b/docs/src/content/docs/using/overview.mdx index 0638c4e5d..d6c3142ff 100644 --- a/docs/src/content/docs/using/overview.mdx +++ b/docs/src/content/docs/using/overview.mdx @@ -36,7 +36,7 @@ Library shows the artists in Lidarr. It includes search, sort controls, artwork, Library > Playlists contains static playlists, in-app playback, and their download status. Flows is a separate section for scheduled discovery mixes. -Flows regenerate on a schedule. Static playlists keep fixed tracklists or synchronize with Spotify. +Flows regenerate on a schedule. Static playlists keep fixed tracklists or synchronize with Spotify or ListenBrainz. ### Activity diff --git a/docs/src/content/docs/using/playlist-imports.mdx b/docs/src/content/docs/using/playlist-imports.mdx index 1ce2b9040..7885ea46a 100644 --- a/docs/src/content/docs/using/playlist-imports.mdx +++ b/docs/src/content/docs/using/playlist-imports.mdx @@ -1,14 +1,14 @@ --- title: Playlist imports -description: Spotify connect, JSON formats, retries, and file reuse. +description: Spotify, Last.fm, and ListenBrainz imports, JSON formats, retries, and file reuse. --- Imported playlists are separate from flows. - They do not regenerate from flow settings. -- Spotify imports can synchronize on their own schedule. +- Spotify, Last.fm, and ListenBrainz imports can synchronize on their own schedule. - They do not use source mix, focus filters, or deep dive. -- Spotify imports keep their playlist membership equal to Spotify. +- Synced imports keep their playlist membership equal to the provider. - JSON imports keep the exact imported tracklist. - They can reuse completed Aurral tracks or existing Lidarr files when worker settings allow. @@ -36,6 +36,33 @@ Aurral stores the connection for each user. Use **Disconnect** in the import mod Allow the OAuth pop-up window if your browser blocks it. The Aurral origin must provide `oauth.html`. +## Last.fm import + +The Last.fm tab offers three current stations: **Library**, **Mix**, and **Recommended**. If Last.fm is selected as your listening-history provider in your profile, Aurral reuses that username. Otherwise, enter your Last.fm username in the import modal. Aurral saves the username with the imported playlist for future syncs. + +1. Open the create menu. +2. Select **Import playlist**. +3. Select **Last.fm**. +4. Enter your username if Aurral does not find it in your profile. +5. Select a station. +6. Start the import. + +Last.fm syncs fetch the current station again, so a synced **Library**, **Mix**, or **Recommended** playlist follows Last.fm as it changes. Choose **None** for a fixed tracklist. **Keep removed tracks in library** works the same way as Spotify and ListenBrainz imports. + +## ListenBrainz import + +Link ListenBrainz in **Settings > Playback** before you import a playlist. Aurral uses the ListenBrainz user token you already use for scrobbling. + +The import menu lists your owned playlists and two current generated choices: **Weekly Jams** and **Weekly Exploration**. Dated recommendation snapshots from **Created for you** are left out of the menu. + +1. Open the create menu. +2. Select **Import playlist**. +3. Select **ListenBrainz**. +4. Select a playlist. +5. Start the import. + +**Weekly Jams** and **Weekly Exploration** are selectors rather than dated playlists. Aurral resolves the newest matching ListenBrainz playlist when it previews, imports, or syncs, so their schedule follows future generated playlists. Other owned playlists sync the specific playlist you selected. Choose **None** for a fixed tracklist. All ListenBrainz imports support **Keep removed tracks in library**. + ### Manual CSV import When OAuth is not an option: diff --git a/docs/src/content/docs/using/playlists.mdx b/docs/src/content/docs/using/playlists.mdx index bc2cb1f0f..fcd325724 100644 --- a/docs/src/content/docs/using/playlists.mdx +++ b/docs/src/content/docs/using/playlists.mdx @@ -14,12 +14,12 @@ Aurral does not write these files to your main music library. | | Flows | Static playlists | | --------- | -------------------------------------- | ----------------------------------------- | -| Tracklist | Regenerates on a schedule | Fixed from import or follows Spotify | +| Tracklist | Regenerates on a schedule | Fixed from import or follows a provider | | Downloads | Can generate replacement picks | Keeps the exact imported tracks | | Controls | Schedule, source mix, focus, deep dive | Import, sync, and retention settings | | Worker | Shared download worker | Shared download worker | -Flows refresh on a schedule and use your flow settings. Static playlists come from Spotify, JSON files, or exported flow tracklists. Spotify playlists follow Spotify membership during sync; their removed-track file-retention setting is available in the import flow and playlist menu. +Flows refresh on a schedule and use your flow settings. Static playlists come from Spotify, ListenBrainz, JSON files, or exported flow tracklists. Synced imports follow provider membership; their removed-track file-retention setting is available in the import flow and playlist menu. Flow names and static playlist names must be unique across both types. Aurral uses the same bare name for Navidrome and Plex playlists. @@ -29,7 +29,7 @@ Select **Re-search missing** from the playlist menu to try the track again. ## Import and Lidarr sync -Use **Import playlist** for Spotify (connect in-app) or a JSON tracklist. See [Playlist imports](/using/playlist-imports/). +Use **Import playlist** for Spotify, ListenBrainz, or a JSON tracklist. Connect Spotify in the import flow. Connect ListenBrainz in **Settings > Playback**. See [Playlist imports](/using/playlist-imports/). Flows can expose a **Lidarr import URL** so Lidarr polls the current flow tracklist as a custom import list. See [Lidarr: Import list feeds](/integrations/lidarr/#import-list-feeds). @@ -107,4 +107,4 @@ yt-dlp can supply an acceptable first file. Aurral does not use yt-dlp for upgra ## Next steps - [Flows](/using/flows/): schedule, source mix, focus, and generation behavior -- [Playlist imports](/using/playlist-imports/): Spotify connect, JSON formats, retries, and file reuse +- [Playlist imports](/using/playlist-imports/): Spotify and ListenBrainz imports, JSON formats, retries, and file reuse diff --git a/frontend/src/components/PlaylistModals.jsx b/frontend/src/components/PlaylistModals.jsx index 73e0328c2..2c93f1913 100644 --- a/frontend/src/components/PlaylistModals.jsx +++ b/frontend/src/components/PlaylistModals.jsx @@ -10,6 +10,7 @@ export function ModalShell({ children, footer, disableClose = false, + className = "", }) { const titleId = useId(); const descriptionId = useId(); @@ -24,7 +25,7 @@ export function ModalShell({
)} - Create Playlist + Create playlist } >
- + { diff --git a/frontend/src/index.css b/frontend/src/index.css index 2e18a96eb..703a22698 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10128,18 +10128,21 @@ button.native-library-genre-row:focus-visible { .flow-page__library-create-btn { display: inline-flex; - width: 2.25rem; - height: 2.25rem; + min-width: 2.25rem; + min-height: 2.25rem; align-items: center; justify-content: center; - padding: 0; + gap: 0.375rem; + padding: 0 0.625rem; border: 1px solid var(--aurral-border-strong); border-radius: var(--aurral-radius-sm); - background-color: var(--aurral-surface-popover); + background-color: var(--aurral-surface-raised); color: var(--aurral-text); + font-size: 0.8125rem; + font-weight: 650; + cursor: pointer; transition: transform 0.15s ease, - filter 0.15s ease, background-color 0.15s ease; } @@ -10156,15 +10159,37 @@ button.native-library-genre-row:focus-visible { color: var(--aurral-text); } +.flow-page__library-create-btn:focus-visible, +.flow-page__library-create-action:focus-visible { + outline: 2px solid var(--aurral-ring); + outline-offset: 2px; +} + .flow-page__library-create-icon { - width: 1.125rem; - height: 1.125rem; - stroke-width: 2.75; + width: 1rem; + height: 1rem; + stroke-width: 2.5; +} + +.flow-page__library-create-label { + line-height: 1; +} + +.flow-page__library-create-chevron { + width: 0.875rem; + height: 0.875rem; + color: var(--aurral-text-muted); + transition: transform 0.15s ease; +} + +.flow-page__library-create-chevron.is-open { + transform: rotate(180deg); } .flow-page__library-create.is-compact .flow-page__library-create-btn { width: 2.5rem; height: 2.5rem; + padding: 0; background-color: var(--aurral-surface-raised); color: var(--aurral-text-muted); } @@ -10188,17 +10213,17 @@ button.native-library-genre-row:focus-visible { .flow-page__library-create-menu { position: absolute; z-index: 40; - top: calc(100% + 0.625rem); + top: calc(100% + 0.5rem); right: 0; - width: min(18.5rem, calc(100vw - 2rem)); - padding: 0.75rem; + width: min(15.5rem, calc(100vw - 2rem)); + padding: 0.375rem; background-color: var(--aurral-surface-popover); border-radius: var(--aurral-radius-sm); } .flow-page__library-create-menu-label { - margin: 0 0 0.5rem; - padding-inline: 0.25rem; + margin: 0.25rem 0 0.25rem; + padding-inline: 0.5rem; color: var(--aurral-text-muted); font-size: 0.6875rem; font-weight: 700; @@ -10207,24 +10232,27 @@ button.native-library-genre-row:focus-visible { } .flow-page__library-create-menu-label--import { - margin-top: 0.75rem; + margin-top: 0.375rem; + padding-top: 0.625rem; + border-top: 1px solid var(--aurral-border); } .flow-page__library-create-primary { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.125rem; } .flow-page__library-create-action { display: flex; width: 100%; align-items: center; - gap: 0.75rem; - padding: 0.75rem; + gap: 0.625rem; + min-height: 2.5rem; + padding: 0.5rem; border: none; border-radius: var(--aurral-radius-sm); - background-color: var(--aurral-surface-raised); + background-color: transparent; color: var(--aurral-text); text-align: left; transition: @@ -10233,42 +10261,22 @@ button.native-library-genre-row:focus-visible { } .flow-page__library-create-action:hover:not(:disabled) { - background-color: var(--aurral-text-subtle); -} - -.flow-page__library-create-action--flow { - background-color: var(--aurral-surface-raised); -} - -.flow-page__library-create-action--flow:hover:not(:disabled) { - background-color: var(--aurral-text-subtle); + background-color: var(--aurral-surface-hover); } .flow-page__library-create-action-icon { display: inline-flex; - width: 2.5rem; - height: 2.5rem; + width: 1.5rem; + height: 1.5rem; flex-shrink: 0; align-items: center; justify-content: center; - border-radius: var(--aurral-radius-sm); - background-color: var(--aurral-text-subtle); - color: var(--aurral-text); -} - -.flow-page__library-create-action-icon--flow { - background-color: var(--aurral-surface-selected); - color: var(--aurral-text); -} - -.flow-page__library-create-action-icon--import { - background-color: var(--aurral-surface-selected); - color: var(--aurral-text); + color: var(--aurral-text-muted); } .flow-page__library-create-action-glyph { - width: 1.25rem; - height: 1.25rem; + width: 1.125rem; + height: 1.125rem; } .flow-page__library-create-action-copy { @@ -10280,17 +10288,11 @@ button.native-library-genre-row:focus-visible { .flow-page__library-create-action-title { color: var(--aurral-text); - font-size: 0.9375rem; - font-weight: 700; + font-size: 0.8125rem; + font-weight: 600; line-height: 1.25; } -.flow-page__library-create-action-desc { - color: var(--aurral-text-muted); - font-size: 0.75rem; - line-height: 1.35; -} - .flow-page__library-filters { width: 100%; background-color: var(--aurral-surface-raised); @@ -11917,7 +11919,7 @@ button.native-library-genre-row:focus-visible { .playlist-import { display: grid; - gap: 0.625rem; + gap: 0.75rem; } .playlist-import__spotify, @@ -11927,11 +11929,20 @@ button.native-library-genre-row:focus-visible { } .playlist-import__segmented { - width: fit-content; + width: 100%; + gap: 0.125rem; + padding: 0.125rem; +} + +.playlist-import__segmented .artist-segmented-button { + min-width: 0; + flex: 1; + padding-inline: 0.5rem; } .playlist-modal:has(.playlist-import) { - max-height: min(90vh, 40rem); + max-width: min(38rem, calc(100vw - 2rem)); + max-height: min(90vh, 42rem); display: flex; flex-direction: column; } @@ -12005,17 +12016,35 @@ button.native-library-genre-row:focus-visible { line-height: 1.45; } +.playlist-import__lastfm-setup { + display: grid; + gap: 0.75rem; + padding: 1rem; + background: var(--aurral-surface); + border-radius: var(--aurral-radius); +} + .playlist-import__account { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; - padding: 0.5rem 0.75rem; - background: var(--aurral-surface); - border-radius: var(--aurral-radius-sm); + padding: 0 0 0.625rem; + border-bottom: 1px solid var(--aurral-border); +} + +.playlist-import__account-status { + width: 0.4375rem; + height: 0.4375rem; + flex-shrink: 0; + margin-left: 0.125rem; + border-radius: var(--aurral-radius-round); + background: var(--aurral-success); } .playlist-import__account-label { + min-width: 0; + flex: 1; color: var(--aurral-text-muted); font-size: 0.8125rem; } @@ -12027,7 +12056,7 @@ button.native-library-genre-row:focus-visible { .playlist-import__list-panel { display: grid; overflow: hidden; - background: var(--aurral-surface); + background: var(--aurral-surface-raised); border: 1px solid var(--aurral-border); border-radius: var(--aurral-radius); } @@ -12036,7 +12065,7 @@ button.native-library-genre-row:focus-visible { border: none; border-bottom: 1px solid var(--aurral-border); border-radius: 0; - background: var(--aurral-surface-popover); + background: transparent; } .playlist-import__search:focus { @@ -12057,7 +12086,8 @@ button.native-library-genre-row:focus-visible { justify-content: space-between; gap: 0.75rem; width: 100%; - padding: 0.55rem 0.75rem; + min-height: 2.75rem; + padding: 0.5rem 0.75rem; border: none; border-bottom: 1px solid var(--aurral-border); border-radius: 0; @@ -12072,7 +12102,14 @@ button.native-library-genre-row:focus-visible { } .playlist-import__playlist-option:hover:not(:disabled) { - background: var(--aurral-surface-popover); + background: var(--aurral-surface-hover); +} + +.playlist-import__playlist-option:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--aurral-ring); + outline-offset: -2px; } .playlist-import__selected { @@ -12081,8 +12118,8 @@ button.native-library-genre-row:focus-visible { justify-content: space-between; gap: 0.75rem; padding: 0.625rem 0.75rem; - background: var(--aurral-surface); - border: 1px solid var(--aurral-border-strong); + background: var(--aurral-surface-raised); + border: 1px solid var(--aurral-border); border-radius: var(--aurral-radius); } @@ -12115,6 +12152,14 @@ button.native-library-genre-row:focus-visible { font-weight: 600; } +.playlist-import__playlist-meta { + flex-shrink: 0; + color: var(--aurral-text-muted); + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + .playlist-import__list-status { display: flex; align-items: center; @@ -12134,15 +12179,25 @@ button.native-library-genre-row:focus-visible { .playlist-import__config { display: grid; - gap: 0.625rem; - padding: 0.75rem; - background: var(--aurral-surface); - border: 1px solid var(--aurral-border); - border-radius: var(--aurral-radius); + gap: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--aurral-border); +} + +.playlist-import__config-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(9rem, 0.45fr); + gap: 0.75rem; +} + +@media (max-width: 520px) { + .playlist-import__config-fields { + grid-template-columns: 1fr; + } } .playlist-import__config .input { - background: var(--aurral-surface-popover); + background: var(--aurral-surface-raised); border: 1px solid var(--aurral-border); } @@ -12205,11 +12260,19 @@ button.native-library-genre-row:focus-visible { .playlist-import__summary-sample { margin: 0; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; color: var(--aurral-text-muted); font-size: 0.75rem; line-height: 1.45; } +.playlist-modal:has(.playlist-import) .playlist-modal__footer .btn { + min-height: 2.25rem; +} + .playlist-import__dropzone { display: grid; justify-items: center; @@ -13507,6 +13570,10 @@ button.native-library-genre-row:focus-visible { overflow: hidden; } +.playlist-modal--create { + max-width: 26rem; +} + .playlist-modal__header { display: flex; align-items: flex-start; diff --git a/frontend/src/pages/FlowPage.jsx b/frontend/src/pages/FlowPage.jsx index d46809a88..92082700b 100644 --- a/frontend/src/pages/FlowPage.jsx +++ b/frontend/src/pages/FlowPage.jsx @@ -98,6 +98,12 @@ const SYNC_INTERVAL_OPTIONS = [ const FLOW_MOBILE_LAYOUT_QUERY = "(max-width: 767px)"; +function getImportedProviderLabel(provider) { + if (String(provider || "").startsWith("listenbrainz-")) return "ListenBrainz"; + if (provider === "lastfm-station") return "Last.fm"; + return "Spotify"; +} + function useFlowMobileLayout() { const [isMobileLayout, setIsMobileLayout] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" @@ -1109,9 +1115,10 @@ function FlowPage({ mode = "all" }) { } }; - const handleSyncSpotifyPlaylist = async (playlist) => { + const handleSyncImportedPlaylist = async (playlist) => { if (!playlist?.id || syncingImportPlaylistId) return; setSyncingImportPlaylistId(playlist.id); + const providerLabel = getImportedProviderLabel(playlist.importSource?.provider); try { const result = await syncSharedPlaylistImport(playlist.id); if (result?.skipped) { @@ -1120,8 +1127,8 @@ function FlowPage({ mode = "all" }) { const queued = Number(result?.tracksQueued || 0); showSuccess( queued > 0 - ? `Synced ${queued} new track${queued !== 1 ? "s" : ""} from Spotify` - : "Spotify playlist synced", + ? `Synced ${queued} new track${queued !== 1 ? "s" : ""} from ${providerLabel}` + : `${providerLabel} playlist synced`, ); } await fetchStatus(); @@ -1133,7 +1140,7 @@ function FlowPage({ mode = "all" }) { } }; - const handleUpdateSpotifySyncInterval = async (playlist, syncIntervalHours) => { + const handleUpdateImportedSyncInterval = async (playlist, syncIntervalHours) => { if (!playlist?.id || updatingSyncIntervalPlaylistId) return; const current = playlist.importSource?.syncIntervalHours ?? 0; if (syncIntervalHours === current) return; @@ -1154,7 +1161,7 @@ function FlowPage({ mode = "all" }) { } }; - const handleUpdateSpotifyRetention = async (playlist, keepRemovedTracks) => { + const handleUpdateImportedRetention = async (playlist, keepRemovedTracks) => { if (!playlist?.id || updatingSyncIntervalPlaylistId) return; const current = playlist.importSource?.keepRemovedTracks !== false; if (keepRemovedTracks === current) return; @@ -1639,12 +1646,17 @@ function FlowPage({ mode = "all" }) { ) : selectedPlaylist ? ( <> - {selectedPlaylist?.importSource?.provider === "spotify-playlist" ? ( + {[ + "spotify-playlist", + "listenbrainz-playlist", + "listenbrainz-createdfor", + "lastfm-station", + ].includes(selectedPlaylist?.importSource?.provider) ? ( <>
diff --git a/frontend/src/pages/flows/FlowPlaylistUI.jsx b/frontend/src/pages/flows/FlowPlaylistUI.jsx index 05a5b1cf9..0aa488e0f 100644 --- a/frontend/src/pages/flows/FlowPlaylistUI.jsx +++ b/frontend/src/pages/flows/FlowPlaylistUI.jsx @@ -1,14 +1,16 @@ import { ArrowRight, Clock, + ChevronDown, ListMusic, Loader2, Plus, Sparkles, Upload, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import PillToggle from "../../components/PillToggle"; +import TooltipButton from "../../components/TooltipButton"; import { PlaylistArtworkThumb } from "./flowComponents/PlaylistArtworkThumb.jsx"; import { formatTrackCountLabel, @@ -67,21 +69,56 @@ export function FlowLibraryCreateMenu({ }) { const [isOpen, setIsOpen] = useState(false); const close = () => setIsOpen(false); + const triggerLabel = showFlows ? "Create playlist or flow" : "Create playlist"; + + useEffect(() => { + if (!isOpen) return undefined; + const handleKeyDown = (event) => { + if (event.key === "Escape") setIsOpen(false); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); + + const triggerContent = ( + <> +
- {source === "spotify" ? ( + {source !== "json" ? (
- {!spotifyStatus.connected ? ( + {source === "spotify" && !spotifyStatus.connected ? (
+ ) : source === "listenbrainz" && !listenBrainzStatus.connected ? ( +
+ +

Connect ListenBrainz first

+

+ Link your ListenBrainz user token in Settings to import your playlists. +

+ + Open ListenBrainz settings + +
+ ) : source === "lastfm" && !lastfmUsername ? ( +
+
+

Enter your Last.fm username

+

+ Aurral will load your Library, Mix, and Recommended stations. +

+
+
+ + setLastfmUsernameInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") loadLastfmPlaylists(lastfmUsernameInput); + }} + autoComplete="off" + disabled={lastfmProfileLoading || lastfmLoading || importing} + /> +

+ Saved with the imported playlist for future syncs. +

+
+ +
) : ( <>
+
{selectedPlaylist ? ( @@ -472,7 +700,7 @@ export function PlaylistImportModal({
{selectedPlaylist.name} - {selectedPlaylist.trackCount} on Spotify + {getPlaylistMeta(selectedPlaylist)}
)) @@ -527,37 +759,39 @@ export function PlaylistImportModal({ {selectedPlaylist ? (
-
- - setPlaylistName(event.target.value)} - disabled={importing} - /> -
+
+
+ + setPlaylistName(event.target.value)} + disabled={importing} + /> +
-
- - +
+ + +
@@ -598,8 +832,11 @@ export function PlaylistImportModal({
{previewSkipped > 0 ? (

- Spotify also lists unavailable entries, podcast episodes, and duplicates - Aurral cannot download. + {externalSource === "Spotify" + ? "Spotify also lists unavailable entries, podcast episodes, and duplicates Aurral cannot download." + : externalSource === "Last.fm" + ? "Some Last.fm entries are missing the artist or track data Aurral needs." + : "Some ListenBrainz entries are missing the artist or track data Aurral needs."}

) : null} {previewTracks.length > 0 ? ( diff --git a/frontend/src/utils/api/endpoints/playlists.js b/frontend/src/utils/api/endpoints/playlists.js index 7fc1c9c62..34370b15f 100644 --- a/frontend/src/utils/api/endpoints/playlists.js +++ b/frontend/src/utils/api/endpoints/playlists.js @@ -168,6 +168,32 @@ export const previewSpotifyPlaylist = (playlistId) => export const importSpotifyPlaylist = (payload) => postData("/playlists/import/spotify", payload); +export const getListenBrainzPlaylists = () => + getData("/playlists/import/listenbrainz/playlists"); + +export const previewListenBrainzPlaylist = (playlistId, playlistType = null) => + postData("/playlists/import/listenbrainz/preview", { + playlistId, + ...(playlistType ? { playlistType } : {}), + }); + +export const importListenBrainzPlaylist = (payload) => + postData("/playlists/import/listenbrainz", payload); + +export const getLastfmPlaylists = (username = "") => + getData("/playlists/import/lastfm/playlists", { + params: username ? { username } : undefined, + }); + +export const previewLastfmPlaylist = (playlistId, username = "") => + postData("/playlists/import/lastfm/preview", { + playlistId, + username, + }); + +export const importLastfmPlaylist = (payload) => + postData("/playlists/import/lastfm", payload); + export const syncSharedPlaylistImport = (playlistId) => postData(`/playlists/shared-playlists/${encodeURIComponent(playlistId)}/sync`); From 3e5ad81723e810447983ba22db05b19e98792703 Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Thu, 20 Aug 2026 06:54:44 +0000 Subject: [PATCH 2/3] fix(playlists): address import review findings --- .tests/import-lists/lastfm-stations.test.js | 1 + .tests/weekly-flow/playlist-config.test.js | 12 ++++++++++ .../weeklyFlow/handlers/lastfmImport.js | 8 +++++-- .../weeklyFlow/handlers/listenbrainzImport.js | 6 +++++ backend/services/apiClients/listenbrainz.js | 2 +- .../services/importLists/lastfmStations.js | 5 ++++- .../weeklyFlow/weeklyFlowPlaylistConfig.js | 8 ++++++- docs/src/content/docs/using/overview.mdx | 2 +- docs/src/content/docs/using/playlists.mdx | 6 ++--- .../flows/import/PlaylistImportModal.jsx | 22 +++++++++++++++++-- 10 files changed, 61 insertions(+), 11 deletions(-) diff --git a/.tests/import-lists/lastfm-stations.test.js b/.tests/import-lists/lastfm-stations.test.js index d39f0926e..0bd76f73f 100644 --- a/.tests/import-lists/lastfm-stations.test.js +++ b/.tests/import-lists/lastfm-stations.test.js @@ -103,5 +103,6 @@ test("lastfmStationClient reuses a Last.fm username from the profile", async (t) 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"); }); diff --git a/.tests/weekly-flow/playlist-config.test.js b/.tests/weekly-flow/playlist-config.test.js index 861d1b5d4..a0510ae27 100644 --- a/.tests/weekly-flow/playlist-config.test.js +++ b/.tests/weekly-flow/playlist-config.test.js @@ -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", diff --git a/backend/routes/weeklyFlow/handlers/lastfmImport.js b/backend/routes/weeklyFlow/handlers/lastfmImport.js index 831c817b3..5b5aea0b7 100644 --- a/backend/routes/weeklyFlow/handlers/lastfmImport.js +++ b/backend/routes/weeklyFlow/handlers/lastfmImport.js @@ -15,7 +15,10 @@ const getPlaylistImport = (body) => ({ export function registerLastfmImport(router) { router.get("/import/lastfm/playlists", async (req, res) => { try { - res.json(await lastfmStationClient.listPlaylists(req.user.id, req.query?.username)); + const requestedUsername = Array.isArray(req.query?.username) + ? req.query.username[0] + : req.query?.username; + res.json(await lastfmStationClient.listPlaylists(req.user.id, requestedUsername)); } catch (error) { res.status(getErrorStatus(error)).json({ error: "Failed to fetch Last.fm stations", @@ -56,7 +59,7 @@ export function registerLastfmImport(router) { return res.status(400).json({ error: "playlistId is required" }); } if (!name) return res.status(400).json({ error: "name is required" }); - const { tracks } = await fetchImportedPlaylistTracks({ + const { tracks, user } = await fetchImportedPlaylistTracks({ userId: req.user.id, ...playlistImport, }); @@ -65,6 +68,7 @@ export function registerLastfmImport(router) { name, sourceName: "Last.fm", ...playlistImport, + externalUsername: playlistImport.externalUsername || user || "", externalName, tracks, syncEnabled, diff --git a/backend/routes/weeklyFlow/handlers/listenbrainzImport.js b/backend/routes/weeklyFlow/handlers/listenbrainzImport.js index 7ecd8b885..78915ee49 100644 --- a/backend/routes/weeklyFlow/handlers/listenbrainzImport.js +++ b/backend/routes/weeklyFlow/handlers/listenbrainzImport.js @@ -31,6 +31,9 @@ export function registerListenBrainzImport(router) { router.post("/import/listenbrainz/preview", async (req, res) => { try { const playlistImport = getPlaylistImport(req.body); + if (!playlistImport.externalId) { + return res.status(400).json({ error: "playlistId is required" }); + } const { tracks, stats } = await fetchImportedPlaylistTracks({ userId: req.user.id, ...playlistImport, @@ -57,6 +60,9 @@ export function registerListenBrainzImport(router) { const keepRemovedTracks = req.body?.keepRemovedTracks !== false; const syncEnabled = req.body?.syncEnabled === false ? false : syncIntervalHours > 0; + if (!playlistImport.externalId) { + return res.status(400).json({ error: "playlistId is required" }); + } if (!name) return res.status(400).json({ error: "name is required" }); const { tracks } = await fetchImportedPlaylistTracks({ userId: req.user.id, diff --git a/backend/services/apiClients/listenbrainz.js b/backend/services/apiClients/listenbrainz.js index a86807c09..fcb7403c0 100644 --- a/backend/services/apiClients/listenbrainz.js +++ b/backend/services/apiClients/listenbrainz.js @@ -86,7 +86,7 @@ export async function listenbrainzRequest( ) { const root = normalizeListenbrainzBaseUrl(baseUrl); const isAuthenticated = Boolean(String(token || "").trim()); - const cacheKey = isAuthenticated ? null : `lb:${path}:${JSON.stringify(params)}`; + const cacheKey = isAuthenticated ? null : `lb:${root}:${path}:${JSON.stringify(params)}`; if (cacheKey) { const cached = listenbrainzCache.get(cacheKey); if (cached !== undefined) return cached; diff --git a/backend/services/importLists/lastfmStations.js b/backend/services/importLists/lastfmStations.js index 9e88df401..24d10f0c5 100644 --- a/backend/services/importLists/lastfmStations.js +++ b/backend/services/importLists/lastfmStations.js @@ -92,6 +92,9 @@ export const lastfmStationClient = { async getStationTracks(userId, stationId, requestedUsername) { const username = resolveUsername(userId, requestedUsername); - return requestStation(username, normalizeLastfmStation(stationId)); + return { + ...(await requestStation(username, normalizeLastfmStation(stationId))), + user: username, + }; }, }; diff --git a/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js b/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js index 6d2fd1eb5..49e1f007d 100644 --- a/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js +++ b/backend/services/weeklyFlow/weeklyFlowPlaylistConfig.js @@ -5,6 +5,12 @@ import { getDiscoverPlaylistPreset } from "../../config/discoverPlaylistPresets. import { EDITORIAL_PLAYLIST_POOL } from "../../config/editorialPlaylistPresets.js"; const LEGACY_TYPES = ["discover", "mix", "trending"]; +export const IMPORT_SOURCE_PROVIDERS = new Set([ + "spotify-playlist", + "listenbrainz-playlist", + "listenbrainz-createdfor", + "lastfm-station", +]); const DEFAULT_MIX = { discover: 34, mix: 33, trending: 33, focus: 0 }; export const DEFAULT_SIZE = 30; const DEFAULT_SCHEDULE_TIME = "00:00"; @@ -434,7 +440,7 @@ export const filterMissingSharedTracks = (existingTracks, incomingTracks) => { export function normalizeImportSource(value) { if (!value || typeof value !== "object") return null; const provider = String(value.provider || "").trim(); - if (!provider) return null; + if (!IMPORT_SOURCE_PROVIDERS.has(provider)) return null; const syncIntervalHours = Number(value.syncIntervalHours); const lastSyncAt = Number(value.lastSyncAt); const hasSync = diff --git a/docs/src/content/docs/using/overview.mdx b/docs/src/content/docs/using/overview.mdx index d6c3142ff..467d30888 100644 --- a/docs/src/content/docs/using/overview.mdx +++ b/docs/src/content/docs/using/overview.mdx @@ -36,7 +36,7 @@ Library shows the artists in Lidarr. It includes search, sort controls, artwork, Library > Playlists contains static playlists, in-app playback, and their download status. Flows is a separate section for scheduled discovery mixes. -Flows regenerate on a schedule. Static playlists keep fixed tracklists or synchronize with Spotify or ListenBrainz. +Flows regenerate on a schedule. Static playlists keep fixed tracklists or synchronize with Spotify, Last.fm, or ListenBrainz. ### Activity diff --git a/docs/src/content/docs/using/playlists.mdx b/docs/src/content/docs/using/playlists.mdx index fcd325724..9d46f74f1 100644 --- a/docs/src/content/docs/using/playlists.mdx +++ b/docs/src/content/docs/using/playlists.mdx @@ -19,7 +19,7 @@ Aurral does not write these files to your main music library. | Controls | Schedule, source mix, focus, deep dive | Import, sync, and retention settings | | Worker | Shared download worker | Shared download worker | -Flows refresh on a schedule and use your flow settings. Static playlists come from Spotify, ListenBrainz, JSON files, or exported flow tracklists. Synced imports follow provider membership; their removed-track file-retention setting is available in the import flow and playlist menu. +Flows refresh on a schedule and use your flow settings. Static playlists come from Spotify, Last.fm, ListenBrainz, JSON files, or exported flow tracklists. Synced imports follow provider membership; their removed-track file-retention setting is available in the import flow and playlist menu. Flow names and static playlist names must be unique across both types. Aurral uses the same bare name for Navidrome and Plex playlists. @@ -29,7 +29,7 @@ Select **Re-search missing** from the playlist menu to try the track again. ## Import and Lidarr sync -Use **Import playlist** for Spotify, ListenBrainz, or a JSON tracklist. Connect Spotify in the import flow. Connect ListenBrainz in **Settings > Playback**. See [Playlist imports](/using/playlist-imports/). +Use **Import playlist** for Spotify, Last.fm, ListenBrainz, or a JSON tracklist. Connect Spotify in the import flow. Connect ListenBrainz in **Settings > Playback**; configure a Last.fm username in **Profile**. See [Playlist imports](/using/playlist-imports/). Flows can expose a **Lidarr import URL** so Lidarr polls the current flow tracklist as a custom import list. See [Lidarr: Import list feeds](/integrations/lidarr/#import-list-feeds). @@ -107,4 +107,4 @@ yt-dlp can supply an acceptable first file. Aurral does not use yt-dlp for upgra ## Next steps - [Flows](/using/flows/): schedule, source mix, focus, and generation behavior -- [Playlist imports](/using/playlist-imports/): Spotify and ListenBrainz imports, JSON formats, retries, and file reuse +- [Playlist imports](/using/playlist-imports/): Spotify, Last.fm, and ListenBrainz imports, JSON formats, retries, and file reuse diff --git a/frontend/src/pages/flows/import/PlaylistImportModal.jsx b/frontend/src/pages/flows/import/PlaylistImportModal.jsx index 920c086b1..78f22f2af 100644 --- a/frontend/src/pages/flows/import/PlaylistImportModal.jsx +++ b/frontend/src/pages/flows/import/PlaylistImportModal.jsx @@ -154,6 +154,7 @@ export function PlaylistImportModal({ const [jsonReview, setJsonReview] = useState(null); const sourceRef = useRef(source); const sourceRequestIdRef = useRef(0); + const loadedLastfmUsernameRef = useRef(""); const reservedNameKeys = useMemo( () => @@ -166,6 +167,7 @@ export function PlaylistImportModal({ const resetState = useCallback(() => { sourceRef.current = "spotify"; sourceRequestIdRef.current += 1; + loadedLastfmUsernameRef.current = ""; setSource("spotify"); setLastfmUsername(""); setLastfmUsernameInput(""); @@ -185,6 +187,7 @@ export function PlaylistImportModal({ const selectSource = (nextSource) => { sourceRef.current = nextSource; sourceRequestIdRef.current += 1; + loadedLastfmUsernameRef.current = ""; setSource(nextSource); setPlaylists([]); setPlaylistQuery(""); @@ -242,9 +245,13 @@ export function PlaylistImportModal({ }, [open, source, spotifyStatus.connected, loadSpotifyPlaylists]); const loadListenBrainzPlaylists = useCallback(async () => { + const requestId = sourceRequestIdRef.current; + const isCurrent = () => + sourceRef.current === "listenbrainz" && requestId === sourceRequestIdRef.current; setListenBrainzLoading(true); try { const statusPayload = await getScrobbleStatus(); + if (!isCurrent()) return; const status = statusPayload?.listenbrainz || { connected: false }; setListenBrainzStatus(status); if (!status.connected) { @@ -252,11 +259,13 @@ export function PlaylistImportModal({ return; } const payload = await getListenBrainzPlaylists(); + if (!isCurrent()) return; setPlaylists(Array.isArray(payload?.playlists) ? payload.playlists : []); if (payload?.user) { setListenBrainzStatus((prev) => ({ ...prev, connected: true, displayName: payload.user })); } } catch (error) { + if (!isCurrent()) return; setListenBrainzStatus({ connected: false, displayName: null }); setPlaylists([]); showError?.( @@ -265,21 +274,29 @@ export function PlaylistImportModal({ "Failed to load ListenBrainz playlists", ); } finally { - setListenBrainzLoading(false); + if (isCurrent()) setListenBrainzLoading(false); } }, [showError]); const loadLastfmPlaylists = useCallback(async (requestedUsername) => { const username = String(requestedUsername || "").trim(); if (!username) return; + if (loadedLastfmUsernameRef.current === username) return; + const requestId = sourceRequestIdRef.current; + const isCurrent = () => + sourceRef.current === "lastfm" && requestId === sourceRequestIdRef.current; + loadedLastfmUsernameRef.current = username; setLastfmProfileChecked(true); setLastfmLoading(true); try { const payload = await getLastfmPlaylists(username); + if (!isCurrent()) return; setLastfmUsername(username); setLastfmUsernameInput(username); setPlaylists(Array.isArray(payload?.playlists) ? payload.playlists : []); } catch (error) { + if (!isCurrent()) return; + loadedLastfmUsernameRef.current = ""; setPlaylists([]); showError?.( error?.response?.data?.message || @@ -287,7 +304,7 @@ export function PlaylistImportModal({ "Failed to load Last.fm stations", ); } finally { - setLastfmLoading(false); + if (isCurrent()) setLastfmLoading(false); } }, [showError]); @@ -705,6 +722,7 @@ export function PlaylistImportModal({ type="button" className="btn btn-ghost btn-sm" onClick={() => { + loadedLastfmUsernameRef.current = ""; setLastfmUsername(""); setLastfmUsernameInput(""); setLastfmProfileChecked(true); From d998885bde503d2def131081c1b2d3900bff8ca1 Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Thu, 20 Aug 2026 06:59:11 +0000 Subject: [PATCH 3/3] fix(playlists): guard stale Spotify imports --- frontend/src/pages/flows/import/PlaylistImportModal.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/flows/import/PlaylistImportModal.jsx b/frontend/src/pages/flows/import/PlaylistImportModal.jsx index 78f22f2af..1fdc35700 100644 --- a/frontend/src/pages/flows/import/PlaylistImportModal.jsx +++ b/frontend/src/pages/flows/import/PlaylistImportModal.jsx @@ -224,18 +224,22 @@ export function PlaylistImportModal({ const loadSpotifyPlaylists = useCallback(async () => { const requestId = sourceRequestIdRef.current; + const isCurrent = () => + sourceRef.current === "spotify" && requestId === sourceRequestIdRef.current; setSpotifyLoading(true); try { const payload = await getSpotifyPlaylists(); + if (!isCurrent()) return; setPlaylists(Array.isArray(payload?.playlists) ? payload.playlists : []); if (payload?.user) { setSpotifyStatus((prev) => ({ ...prev, connected: true, displayName: payload.user })); } } catch (error) { + if (!isCurrent()) return; handleSpotifyAuthRequired(error, requestId); showError?.(error?.response?.data?.message || error?.message || "Failed to load Spotify playlists"); } finally { - setSpotifyLoading(false); + if (isCurrent()) setSpotifyLoading(false); } }, [handleSpotifyAuthRequired, showError]);