diff --git a/.tests/import-lists/lastfm-stations.test.js b/.tests/import-lists/lastfm-stations.test.js new file mode 100644 index 00000000..0bd76f73 --- /dev/null +++ b/.tests/import-lists/lastfm-stations.test.js @@ -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"); +}); diff --git a/.tests/import-lists/listenbrainz-playlists.test.js b/.tests/import-lists/listenbrainz-playlists.test.js new file mode 100644 index 00000000..6147041c --- /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 00000000..8bd9df8d --- /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-config.test.js b/.tests/weekly-flow/playlist-config.test.js index 861d1b5d..a0510ae2 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/.tests/weekly-flow/playlist-import-order.test.js b/.tests/weekly-flow/playlist-import-order.test.js index 3789ca33..cb746ee7 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 7fdf97d9..069d0d9e 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 00000000..5b5aea0b --- /dev/null +++ b/backend/routes/weeklyFlow/handlers/lastfmImport.js @@ -0,0 +1,98 @@ +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 { + 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", + 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, user } = await fetchImportedPlaylistTracks({ + userId: req.user.id, + ...playlistImport, + }); + const result = await enqueueImportedPlaylist({ + ownerUserId: req.user.id, + name, + sourceName: "Last.fm", + ...playlistImport, + externalUsername: playlistImport.externalUsername || user || "", + 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 00000000..78915ee4 --- /dev/null +++ b/backend/routes/weeklyFlow/handlers/listenbrainzImport.js @@ -0,0 +1,102 @@ +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); + if (!playlistImport.externalId) { + return res.status(400).json({ error: "playlistId is required" }); + } + 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 (!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: "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 7a334788..6995c337 100644 --- a/backend/routes/weeklyFlow/handlers/spotifyImport.js +++ b/backend/routes/weeklyFlow/handlers/spotifyImport.js @@ -5,11 +5,11 @@ 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) => { @@ -118,8 +118,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({ @@ -149,28 +152,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 de603ab5..7a4c348a 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 153ad2cd..fcb7403c 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:${root}:${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 f3ba769e..3bccdf56 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 00000000..20b32e0e --- /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 00000000..24d10f0c --- /dev/null +++ b/backend/services/importLists/lastfmStations.js @@ -0,0 +1,100 @@ +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 { + ...(await requestStation(username, normalizeLastfmStation(stationId))), + user: username, + }; + }, +}; diff --git a/backend/services/importLists/lastfmTracks.js b/backend/services/importLists/lastfmTracks.js new file mode 100644 index 00000000..35472deb --- /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 00000000..71a856db --- /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 00000000..e625c822 --- /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 36a821cd..49e1f007 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 = @@ -444,6 +450,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 706a306f..2d630f61 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 0638c4e5..467d3088 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, Last.fm, or ListenBrainz. ### Activity diff --git a/docs/src/content/docs/using/playlist-imports.mdx b/docs/src/content/docs/using/playlist-imports.mdx index 5c56f0f8..7138f0ba 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. @@ -37,6 +37,33 @@ If Spotify expires or revokes the connection, Aurral disconnects it and asks you 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 bc2cb1f0..9d46f74f 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, 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 (connect in-app) or a JSON tracklist. 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 connect, 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/components/PlaylistModals.jsx b/frontend/src/components/PlaylistModals.jsx index 73e0328c..2c93f191 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 2e18a96e..703a2269 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 d46809a8..92082700 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 05a5b1cf..0aa488e0 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 ? ( @@ -497,7 +745,7 @@ export function PlaylistImportModal({
{selectedPlaylist.name} - {selectedPlaylist.trackCount} on Spotify + {getPlaylistMeta(selectedPlaylist)}
)) @@ -552,37 +804,39 @@ export function PlaylistImportModal({ {selectedPlaylist ? (
-
- - setPlaylistName(event.target.value)} - disabled={importing} - /> -
- -
- - +
+
+ + setPlaylistName(event.target.value)} + disabled={importing} + /> +
+ +
+ + +
@@ -623,8 +877,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 7fc1c9c6..34370b15 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`);