From c49ddb0b233ab4c97c492a38688927b3f9a5a3ad Mon Sep 17 00:00:00 2001 From: lklynet Date: Tue, 24 Feb 2026 11:05:48 -0500 Subject: [PATCH 01/11] feat(library): integrate SignalR for real-time sync and add in-library indicators Add @microsoft/signalr dependency for real-time communication with Lidarr. Implement SignalR service to listen for artist/album updates and trigger local cache synchronization. Create new database tables (lidarr_artists, lidarr_albums, lidarr_tracks) to cache library data locally. Replace direct Lidarr API calls with libraryManager methods that use local cache, reducing external API load. Add 'inLibrary' field to search results and discovery endpoints by checking against local cache. Implement periodic full sync and start SignalR connection on server startup. Remove client-side batch lookup in favor of server-side checks. --- backend/config/db-sqlite.js | 40 ++ backend/package-lock.json | 148 +++++ backend/package.json | 1 + backend/routes/artists/handlers/details.js | 27 +- backend/routes/artists/handlers/search.js | 9 + backend/routes/artists/handlers/stream.js | 210 +++---- backend/routes/discovery.js | 39 +- backend/routes/library/handlers/downloads.js | 21 +- backend/routes/requests.js | 97 +-- backend/server.js | 62 +- backend/services/libraryManager.js | 608 ++++++++++++++++++- backend/services/lidarrClient.js | 293 +++++++++ frontend/src/pages/SearchResultsPage.jsx | 38 +- package-lock.json | 4 +- 14 files changed, 1337 insertions(+), 260 deletions(-) diff --git a/backend/config/db-sqlite.js b/backend/config/db-sqlite.js index 8823aee..e342c05 100644 --- a/backend/config/db-sqlite.js +++ b/backend/config/db-sqlite.js @@ -70,9 +70,49 @@ db.exec(` updated_at INTEGER ); + CREATE TABLE IF NOT EXISTS lidarr_artists ( + id TEXT PRIMARY KEY, + foreign_artist_id TEXT, + artist_name TEXT, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS lidarr_albums ( + id TEXT PRIMARY KEY, + artist_id TEXT, + foreign_album_id TEXT, + album_name TEXT, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS lidarr_tracks ( + id TEXT PRIMARY KEY, + album_id TEXT, + artist_id TEXT, + foreign_track_id TEXT, + track_name TEXT, + track_number INTEGER, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS lidarr_sync_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_weekly_flow_jobs_status ON weekly_flow_jobs(status); CREATE INDEX IF NOT EXISTS idx_weekly_flow_jobs_playlist_type ON weekly_flow_jobs(playlist_type); CREATE INDEX IF NOT EXISTS idx_images_cache_cache_age ON images_cache(cache_age); + CREATE INDEX IF NOT EXISTS idx_lidarr_artists_foreign_id ON lidarr_artists(foreign_artist_id); + CREATE INDEX IF NOT EXISTS idx_lidarr_artists_name ON lidarr_artists(artist_name); + CREATE INDEX IF NOT EXISTS idx_lidarr_albums_artist_id ON lidarr_albums(artist_id); + CREATE INDEX IF NOT EXISTS idx_lidarr_albums_foreign_id ON lidarr_albums(foreign_album_id); + CREATE INDEX IF NOT EXISTS idx_lidarr_albums_name ON lidarr_albums(album_name); + CREATE INDEX IF NOT EXISTS idx_lidarr_tracks_album_id ON lidarr_tracks(album_id); + CREATE INDEX IF NOT EXISTS idx_lidarr_tracks_foreign_id ON lidarr_tracks(foreign_track_id); `); export const dbHelpers = { diff --git a/backend/package-lock.json b/backend/package-lock.json index 9a6ac02..a987b7c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@microsoft/signalr": "^8.0.7", "axios": "^1.7.9", "bcrypt": "^5.1.1", "better-sqlite3": "^12.6.2", @@ -60,6 +61,40 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@microsoft/signalr": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz", + "integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@microsoft/signalr/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -112,6 +147,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -764,6 +811,24 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -843,6 +908,16 @@ "express": ">= 4.11" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-type": { "version": "21.3.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", @@ -1902,6 +1977,18 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -1919,6 +2006,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -1934,6 +2030,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2000,6 +2102,12 @@ "node": ">=8.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -2105,6 +2213,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2470,6 +2584,21 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -2529,6 +2658,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2538,6 +2676,16 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index d63d426..0ebe25a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "axios": "^1.7.9", + "@microsoft/signalr": "^8.0.7", "bcrypt": "^5.1.1", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", diff --git a/backend/routes/artists/handlers/details.js b/backend/routes/artists/handlers/details.js index c466207..520ddb9 100644 --- a/backend/routes/artists/handlers/details.js +++ b/backend/routes/artists/handlers/details.js @@ -122,8 +122,6 @@ export default function registerDetails(router) { console.log(`[Artists Route] Fetching artist details for MBID: ${mbid}`); - const { lidarrClient } = - await import("../../../services/lidarrClient.js"); const { libraryManager } = await import("../../../services/libraryManager.js"); @@ -135,23 +133,18 @@ export default function registerDetails(router) { let lidarrArtist = null; let lidarrAlbums = []; - if (lidarrClient.isConfigured()) { - try { - lidarrArtist = await lidarrClient.getArtistByMbid(mbid); - if (lidarrArtist) { - console.log( - `[Artists Route] Found artist in Lidarr: ${lidarrArtist.artistName}`, - ); - const libraryArtist = await libraryManager.getArtist(mbid); - if (libraryArtist) { - lidarrAlbums = await libraryManager.getAlbums(libraryArtist.id); - } - } - } catch (error) { - console.warn( - `[Artists Route] Failed to fetch from Lidarr: ${error.message}`, + try { + lidarrArtist = await libraryManager.getArtist(mbid); + if (lidarrArtist) { + console.log( + `[Artists Route] Found artist in Lidarr: ${lidarrArtist.artistName}`, ); + lidarrAlbums = await libraryManager.getAlbums(lidarrArtist.id); } + } catch (error) { + console.warn( + `[Artists Route] Failed to fetch from Lidarr: ${error.message}`, + ); } if (lidarrArtist) { diff --git a/backend/routes/artists/handlers/search.js b/backend/routes/artists/handlers/search.js index a91c673..3c4d5f0 100644 --- a/backend/routes/artists/handlers/search.js +++ b/backend/routes/artists/handlers/search.js @@ -3,6 +3,7 @@ import { lastfmRequest, musicbrainzRequest, } from "../../../services/apiClients.js"; +import { libraryManager } from "../../../services/libraryManager.js"; import { imagePrefetchService } from "../../../services/imagePrefetchService.js"; import { dbOps } from "../../../config/db-helpers.js"; import { cacheMiddleware } from "../../../middleware/cache.js"; @@ -18,6 +19,12 @@ const handleSearch = async (req, res) => { const limitInt = parseInt(limit) || 24; const offsetInt = parseInt(offset) || 0; + // Get local library artists for "Already in Library" checks + const localArtists = await libraryManager.getAllArtists(); + const localArtistMbids = new Set( + localArtists.map((a) => a.mbid || a.foreignArtistId).filter((id) => id), + ); + if (getLastfmApiKey()) { try { const page = Math.floor(offsetInt / limitInt) + 1; @@ -60,6 +67,7 @@ const handleSearch = async (req, res) => { image: img, imageUrl: img, listeners: a.listeners, + inLibrary: localArtistMbids.has(a.mbid), }; const cachedImage = cachedImages[a.mbid]; @@ -122,6 +130,7 @@ const handleSearch = async (req, res) => { image: imageUrl, imageUrl, listeners: null, + inLibrary: localArtistMbids.has(a.id), }; }); diff --git a/backend/routes/artists/handlers/stream.js b/backend/routes/artists/handlers/stream.js index 3002784..7e2d84e 100644 --- a/backend/routes/artists/handlers/stream.js +++ b/backend/routes/artists/handlers/stream.js @@ -58,129 +58,121 @@ export default function registerStream(router) { const deezerArtistId = override?.deezerArtistId || null; try { - const { lidarrClient } = - await import("../../../services/lidarrClient.js"); const { libraryManager } = await import("../../../services/libraryManager.js"); let lidarrArtist = null; let lidarrAlbums = []; - if (lidarrClient.isConfigured()) { - try { - lidarrArtist = await lidarrClient.getArtistByMbid(mbid); - if (lidarrArtist) { - console.log( - `[Artists Stream] Found artist in Lidarr: ${lidarrArtist.artistName}`, - ); - const libraryArtist = await libraryManager.getArtist(mbid); - if (libraryArtist) { - lidarrAlbums = await libraryManager.getAlbums(libraryArtist.id); - } + try { + lidarrArtist = await libraryManager.getArtist(mbid); + if (lidarrArtist) { + console.log( + `[Artists Stream] Found artist in Lidarr: ${lidarrArtist.artistName}`, + ); + lidarrAlbums = await libraryManager.getAlbums(lidarrArtist.id); - const artistMbid = - override?.musicbrainzId || lidarrArtist.foreignArtistId || mbid; - let releaseGroups = []; - try { - releaseGroups = - await musicbrainzGetArtistReleaseGroups(artistMbid); - await enrichReleaseGroupsWithDeezer( + const artistMbid = + override?.musicbrainzId || lidarrArtist.foreignArtistId || mbid; + let releaseGroups = []; + try { + releaseGroups = await musicbrainzGetArtistReleaseGroups(artistMbid); + await enrichReleaseGroupsWithDeezer( + releaseGroups, + lidarrArtist.artistName, + deezerArtistId, + ); + if (getLastfmApiKey()) { + await enrichReleaseGroupsWithLastfm( releaseGroups, lidarrArtist.artistName, - deezerArtistId, + artistMbid, ); - if (getLastfmApiKey()) { - await enrichReleaseGroupsWithLastfm( - releaseGroups, - lidarrArtist.artistName, - artistMbid, - ); - } - } catch (e) { - releaseGroups = lidarrAlbums.map((album) => ({ - id: album.mbid, - title: album.albumName, - "first-release-date": album.releaseDate || null, - "primary-type": "Album", - "secondary-types": [], - })); } - const mbidToType = new Map( - releaseGroups.map((rg) => [rg.id, rg["primary-type"]]), - ); - - const [bio, tagsData] = await Promise.all([ - getArtistBio( - lidarrArtist.artistName, - artistMbid, - deezerArtistId, - ), - getLastfmApiKey() - ? lastfmRequest("artist.getTopTags", { mbid: artistMbid }) - : null, - ]); - const tags = tagsData?.toptags?.tag - ? (Array.isArray(tagsData.toptags.tag) - ? tagsData.toptags.tag - : [tagsData.toptags.tag] - ).map((t) => ({ name: t.name, count: t.count || 0 })) - : []; - artistData = { - id: artistMbid, - name: lidarrArtist.artistName, - "sort-name": lidarrArtist.artistName, - disambiguation: "", - "type-id": null, - type: null, - country: null, - "life-span": { - begin: null, - end: null, - ended: false, - }, - tags, - genres: [], - "release-groups": releaseGroups, - relations: [], - "release-group-count": releaseGroups.length, - "release-count": releaseGroups.length, - _lidarrData: { - id: lidarrArtist.id, - monitored: lidarrArtist.monitored, - statistics: lidarrArtist.statistics, - }, - ...(bio ? { bio } : {}), - }; - - sendSSE(res, "artist", artistData); - - const libArtist = libraryManager.mapLidarrArtist(lidarrArtist); - sendSSE(res, "library", { - exists: true, - artist: { - ...libArtist, - foreignArtistId: libArtist.foreignArtistId || libArtist.mbid, - added: libArtist.addedAt, - }, - albums: lidarrAlbums.map((a) => ({ - ...a, - foreignAlbumId: a.foreignAlbumId || a.mbid, - title: a.albumName, - albumType: - mbidToType.get(a.mbid || a.foreignAlbumId) || "Album", - statistics: a.statistics || { - trackCount: 0, - sizeOnDisk: 0, - percentOfTracks: 0, - }, - })), - }); + } catch (e) { + releaseGroups = lidarrAlbums.map((album) => ({ + id: album.mbid, + title: album.albumName, + "first-release-date": album.releaseDate || null, + "primary-type": "Album", + "secondary-types": [], + })); } - } catch (error) { - console.warn( - `[Artists Stream] Failed to fetch from Lidarr: ${error.message}`, + const mbidToType = new Map( + releaseGroups.map((rg) => [rg.id, rg["primary-type"]]), ); + + const [bio, tagsData] = await Promise.all([ + getArtistBio( + lidarrArtist.artistName, + artistMbid, + deezerArtistId, + ), + getLastfmApiKey() + ? lastfmRequest("artist.getTopTags", { mbid: artistMbid }) + : null, + ]); + const tags = tagsData?.toptags?.tag + ? (Array.isArray(tagsData.toptags.tag) + ? tagsData.toptags.tag + : [tagsData.toptags.tag] + ).map((t) => ({ name: t.name, count: t.count || 0 })) + : []; + artistData = { + id: artistMbid, + name: lidarrArtist.artistName, + "sort-name": lidarrArtist.artistName, + disambiguation: "", + "type-id": null, + type: null, + country: null, + "life-span": { + begin: null, + end: null, + ended: false, + }, + tags, + genres: [], + "release-groups": releaseGroups, + relations: [], + "release-group-count": releaseGroups.length, + "release-count": releaseGroups.length, + _lidarrData: { + id: lidarrArtist.id, + monitored: lidarrArtist.monitored, + statistics: lidarrArtist.statistics, + }, + ...(bio ? { bio } : {}), + }; + + sendSSE(res, "artist", artistData); + + const libArtist = lidarrArtist; + sendSSE(res, "library", { + exists: true, + artist: { + ...libArtist, + foreignArtistId: libArtist.foreignArtistId || libArtist.mbid, + added: libArtist.addedAt, + }, + albums: lidarrAlbums.map((a) => ({ + ...a, + foreignAlbumId: a.foreignAlbumId || a.mbid, + title: a.albumName, + albumType: + mbidToType.get(a.mbid || a.foreignAlbumId) || "Album", + statistics: a.statistics || { + trackCount: 0, + sizeOnDisk: 0, + percentOfTracks: 0, + }, + })), + }); } + } catch (error) { + console.warn( + `[Artists Stream] Failed to fetch from Lidarr: ${error.message}`, + ); } if (!artistData) { diff --git a/backend/routes/discovery.js b/backend/routes/discovery.js index 4fc4460..8bcbd64 100644 --- a/backend/routes/discovery.js +++ b/backend/routes/discovery.js @@ -182,7 +182,7 @@ router.get("/", async (req, res) => { const existingArtistIds = new Set(libraryArtists.map((a) => a.mbid)); recommendations = recommendations.filter( - (artist) => !existingArtistIds.has(artist.id) + (artist) => !existingArtistIds.has(artist.id), ); globalTop = globalTop.filter((artist) => !existingArtistIds.has(artist.id)); @@ -323,6 +323,11 @@ router.get("/by-tag", async (req, res) => { scope === "all" || includeLibraryFlag ? "all" : "recommended"; const cacheKey = `tag:${tag.toLowerCase()}:${limitInt}:${page}:${scopeValue}`; + const localArtists = await libraryManager.getAllArtists(); + const localArtistMbids = new Set( + localArtists.map((a) => a.mbid || a.foreignArtistId).filter((id) => id), + ); + let recommendations = []; if (scopeValue === "all") { if (getLastfmApiKey()) { @@ -373,6 +378,7 @@ router.get("/by-tag", async (req, res) => { type: "Artist", tags: [tag], image: imageUrl, + inLibrary: localArtistMbids.has(artist.mbid), }; }) .filter((a) => a.id); @@ -384,11 +390,18 @@ router.get("/by-tag", async (req, res) => { } else { const discoveryCache = getDiscoveryCache(); const tagLower = String(tag).trim().toLowerCase(); - const matches = (discoveryCache.recommendations || []).filter((artist) => { - const tags = Array.isArray(artist.tags) ? artist.tags : []; - return tags.some((t) => String(t).toLowerCase() === tagLower); - }); - recommendations = matches.slice(offsetInt, offsetInt + limitInt); + const matches = (discoveryCache.recommendations || []).filter( + (artist) => { + const tags = Array.isArray(artist.tags) ? artist.tags : []; + return tags.some((t) => String(t).toLowerCase() === tagLower); + }, + ); + recommendations = matches + .slice(offsetInt, offsetInt + limitInt) + .map((a) => ({ + ...a, + inLibrary: localArtistMbids.has(a.id || a.mbid), + })); return res.json({ recommendations, tag, @@ -472,7 +485,7 @@ router.delete("/preferences/exclude-genre/:genre", (req, res) => { const { genre } = req.params; discoveryPreferences.excludedGenres = discoveryPreferences.excludedGenres.filter( - (g) => g !== genre.toLowerCase() + (g) => g !== genre.toLowerCase(), ); res.json({ @@ -517,7 +530,7 @@ router.delete("/preferences/exclude-artist/:artistId", (req, res) => { const { artistId } = req.params; discoveryPreferences.excludedArtists = discoveryPreferences.excludedArtists.filter( - (a) => a.artistId !== artistId + (a) => a.artistId !== artistId, ); res.json({ @@ -542,13 +555,13 @@ router.get("/filtered", async (req, res) => { const existingArtistIds = new Set(libraryArtists.map((a) => a.mbid)); recommendations = recommendations.filter( - (artist) => !existingArtistIds.has(artist.id) + (artist) => !existingArtistIds.has(artist.id), ); globalTop = globalTop.filter((artist) => !existingArtistIds.has(artist.id)); if (discoveryPreferences.excludedGenres.length > 0) { const excludedGenresLower = discoveryPreferences.excludedGenres.map((g) => - g.toLowerCase() + g.toLowerCase(), ); recommendations = recommendations.filter((artist) => { @@ -564,10 +577,10 @@ router.get("/filtered", async (req, res) => { if (discoveryPreferences.excludedArtists.length > 0) { const excludedIds = new Set( - discoveryPreferences.excludedArtists.map((a) => a.artistId) + discoveryPreferences.excludedArtists.map((a) => a.artistId), ); recommendations = recommendations.filter( - (artist) => !excludedIds.has(artist.id) + (artist) => !excludedIds.has(artist.id), ); globalTop = globalTop.filter((artist) => !excludedIds.has(artist.id)); } @@ -575,7 +588,7 @@ router.get("/filtered", async (req, res) => { if (discoveryPreferences.maxRecommendations > 0) { recommendations = recommendations.slice( 0, - discoveryPreferences.maxRecommendations + discoveryPreferences.maxRecommendations, ); } diff --git a/backend/routes/library/handlers/downloads.js b/backend/routes/library/handlers/downloads.js index d086746..fa9858d 100644 --- a/backend/routes/library/handlers/downloads.js +++ b/backend/routes/library/handlers/downloads.js @@ -171,12 +171,15 @@ export default function registerDownloads(router) { router.get("/downloads", async (req, res) => { try { + const { libraryManager } = + await import("../../../services/libraryManager.js"); const { lidarrClient } = await import("../../../services/lidarrClient.js"); - if (!lidarrClient.isConfigured()) { + + if (!lidarrClient || !lidarrClient.isConfigured()) { return res.json([]); } - const queue = await lidarrClient.getQueue(); + const queue = await libraryManager.getQueue().catch(() => []); const queueItems = Array.isArray(queue) ? queue : queue.records || []; res.json( queueItems.map((item) => ({ @@ -217,12 +220,14 @@ export default function registerDownloads(router) { const { lidarrClient } = await import("../../../services/lidarrClient.js"); + const { libraryManager } = + await import("../../../services/libraryManager.js"); if (lidarrClient.isConfigured()) { try { const [queue, history, commands] = await Promise.all([ - lidarrClient.getQueue(), - lidarrClient.getHistory(1, 200), + libraryManager.getQueue().catch(() => []), + libraryManager.getHistory().catch(() => ({ records: [] })), lidarrClient.request("/command").catch(() => []), ]); const queueItems = Array.isArray(queue) ? queue : queue.records || []; @@ -404,14 +409,16 @@ export default function registerDownloads(router) { try { const { lidarrClient } = await import("../../../services/lidarrClient.js"); + const { libraryManager } = + await import("../../../services/libraryManager.js"); const allStatuses = {}; if (lidarrClient.isConfigured()) { try { const [queue, history, albums, commands] = await Promise.all([ - lidarrClient.getQueue(), - lidarrClient.getHistory(1, 200), - lidarrClient.request("/album"), + libraryManager.getQueue().catch(() => []), + libraryManager.getHistory().catch(() => ({ records: [] })), + libraryManager.getAllAlbums().catch(() => []), lidarrClient.request("/command").catch(() => []), ]); diff --git a/backend/routes/requests.js b/backend/routes/requests.js index 760be33..efd66e5 100644 --- a/backend/routes/requests.js +++ b/backend/routes/requests.js @@ -18,16 +18,17 @@ const toIso = (value) => { router.get("/", noCache, async (req, res) => { try { const { lidarrClient } = await import("../services/lidarrClient.js"); + const { libraryManager } = await import("../services/libraryManager.js"); if (!lidarrClient?.isConfigured()) { return res.json([]); } const [queue, history, artists, albums] = await Promise.all([ - lidarrClient.getQueue().catch(() => []), - lidarrClient.getHistory(1, 200).catch(() => ({ records: [] })), - lidarrClient.request("/artist").catch(() => []), - lidarrClient.request("/album").catch(() => []), + libraryManager.getQueue().catch(() => []), + libraryManager.getHistory().catch(() => ({ records: [] })), + libraryManager.getAllArtists().catch(() => []), + libraryManager.getAllAlbums().catch(() => []), ]); const artistById = new Map( @@ -70,50 +71,57 @@ router.get("/", noCache, async (req, res) => { const albumId = item?.albumId ?? item?.album?.id; if (albumId == null) continue; - const artistId = item?.artistId ?? item?.artist?.id ?? item?.album?.artistId; + const artistId = + item?.artistId ?? item?.artist?.id ?? item?.album?.artistId; const artistInfo = artistId != null ? artistById.get(artistId) : null; const albumName = item?.album?.title || item?.title || "Album"; const artistName = item?.artist?.artistName || artistInfo?.artistName || "Artist"; - + let artistMbid = null; - + if (artistId && artistById.has(artistId)) { artistMbid = artistById.get(artistId).foreignArtistId || null; } - + if (!artistMbid) { artistMbid = item?.artist?.foreignArtistId || null; } - + if (!artistMbid && artistInfo) { artistMbid = artistInfo.foreignArtistId || null; } - + if (!artistMbid && artistId) { try { - const { libraryManager } = await import("../services/libraryManager.js"); const libraryArtist = await libraryManager.getArtistById(artistId); if (libraryArtist) { - artistMbid = libraryArtist.foreignArtistId || libraryArtist.mbid || null; + artistMbid = + libraryArtist.foreignArtistId || libraryArtist.mbid || null; } } catch {} } const queueStatus = String(item.status || "").toLowerCase(); const title = String(item.title || "").toLowerCase(); - const trackedDownloadState = String(item.trackedDownloadState || "").toLowerCase(); - const trackedDownloadStatus = String(item.trackedDownloadStatus || "").toLowerCase(); + const trackedDownloadState = String( + item.trackedDownloadState || "", + ).toLowerCase(); + const trackedDownloadStatus = String( + item.trackedDownloadStatus || "", + ).toLowerCase(); const errorMessage = String(item.errorMessage || "").toLowerCase(); - const statusMessages = Array.isArray(item.statusMessages) - ? item.statusMessages.map(m => String(m || "").toLowerCase()).join(" ") + const statusMessages = Array.isArray(item.statusMessages) + ? item.statusMessages + .map((m) => String(m || "").toLowerCase()) + .join(" ") : ""; - + const isFailed = trackedDownloadState === "importfailed" || trackedDownloadState === "importFailed" || - queueStatus.includes("fail") || + queueStatus.includes("fail") || queueStatus.includes("import fail") || title.includes("import fail") || title.includes("downloaded - import fail") || @@ -124,7 +132,7 @@ router.get("/", noCache, async (req, res) => { errorMessage.includes("retrying") || statusMessages.includes("fail") || statusMessages.includes("unmatched"); - + const status = isFailed ? "failed" : "processing"; requestsByAlbumId.set(String(albumId), { @@ -164,44 +172,48 @@ router.get("/", noCache, async (req, res) => { const albumName = record?.album?.title || record?.sourceTitle || "Album"; const artistName = record?.artist?.artistName || artistInfo?.artistName || "Artist"; - + let artistMbid = null; - + if (artistId && artistById.has(artistId)) { artistMbid = artistById.get(artistId).foreignArtistId || null; } - + if (!artistMbid) { artistMbid = record?.artist?.foreignArtistId || null; } - + if (!artistMbid && artistInfo) { artistMbid = artistInfo.foreignArtistId || null; } - + if (!artistMbid && artistId) { try { - const { libraryManager } = await import("../services/libraryManager.js"); + const { libraryManager } = + await import("../services/libraryManager.js"); const libraryArtist = await libraryManager.getArtistById(artistId); if (libraryArtist) { - artistMbid = libraryArtist.foreignArtistId || libraryArtist.mbid || null; + artistMbid = + libraryArtist.foreignArtistId || libraryArtist.mbid || null; } } catch {} } const eventType = String(record?.eventType || "").toLowerCase(); const data = record?.data || {}; - const statusMessages = Array.isArray(data?.statusMessages) - ? data.statusMessages.map(m => String(m || "").toLowerCase()).join(" ") + const statusMessages = Array.isArray(data?.statusMessages) + ? data.statusMessages + .map((m) => String(m || "").toLowerCase()) + .join(" ") : String(data?.statusMessages?.[0] || "").toLowerCase(); const errorMessage = String(data?.errorMessage || "").toLowerCase(); const sourceTitle = String(record?.sourceTitle || "").toLowerCase(); const dataString = JSON.stringify(data).toLowerCase(); - + const isFailedImport = eventType === "albumimportincomplete" || eventType.includes("incomplete") || - statusMessages.includes("fail") || + statusMessages.includes("fail") || statusMessages.includes("error") || statusMessages.includes("import fail") || statusMessages.includes("incomplete") || @@ -209,17 +221,20 @@ router.get("/", noCache, async (req, res) => { errorMessage.includes("error") || sourceTitle.includes("import fail") || dataString.includes("import fail"); - - const isSuccessfulImport = eventType.includes("import") && !isFailedImport && eventType !== "albumimportincomplete"; + + const isSuccessfulImport = + eventType.includes("import") && + !isFailedImport && + eventType !== "albumimportincomplete"; const lidarrAlbum = albumById.get(albumId); const isCompleteInLibrary = isAlbumAvailable(lidarrAlbum); const status = isCompleteInLibrary ? "available" : isSuccessfulImport - ? "available" - : isFailedImport - ? "failed" - : "processing"; + ? "available" + : isFailedImport + ? "failed" + : "processing"; requestsByAlbumId.set(String(albumId), { id: `lidarr-history-${record.id ?? albumId}`, @@ -269,8 +284,9 @@ router.delete("/album/:albumId", async (req, res) => { try { const { lidarrClient } = await import("../services/lidarrClient.js"); + const { libraryManager } = await import("../services/libraryManager.js"); if (lidarrClient?.isConfigured()) { - const queue = await lidarrClient.getQueue().catch(() => []); + const queue = await libraryManager.getQueue().catch(() => []); const queueItems = Array.isArray(queue) ? queue : queue?.records || []; const targetAlbumId = parseInt(albumId, 10); @@ -300,21 +316,22 @@ router.delete("/:mbid", async (req, res) => { try { const { lidarrClient } = await import("../services/lidarrClient.js"); + const { libraryManager } = await import("../services/libraryManager.js"); if (!lidarrClient?.isConfigured()) { return res.json({ success: true }); } - const artist = await lidarrClient.getArtistByMbid(mbid).catch(() => null); + const artist = await libraryManager.getArtist(mbid).catch(() => null); if (!artist?.id) { return res.json({ success: true }); } - const queue = await lidarrClient.getQueue().catch(() => []); + const queue = await libraryManager.getQueue().catch(() => []); const queueItems = Array.isArray(queue) ? queue : queue?.records || []; for (const item of queueItems) { const itemArtistId = item?.artist?.id ?? item?.album?.artistId; - if (itemArtistId === artist.id && item?.id != null) { + if (String(itemArtistId) === String(artist.id) && item?.id != null) { await lidarrClient .request(`/queue/${item.id}`, "DELETE") .catch(() => null); diff --git a/backend/server.js b/backend/server.js index 5f636de..5c3af51 100644 --- a/backend/server.js +++ b/backend/server.js @@ -12,7 +12,7 @@ process.on("uncaughtException", (err) => { if (err.code === "ERR_STREAM_DESTROYED") { console.warn( "[Process] Caught stream destroyed error (safe to ignore):", - err.message + err.message, ); return; } @@ -22,7 +22,7 @@ process.on("uncaughtException", (err) => { process.on("unhandledRejection", (reason, promise) => { if (reason?.code === "ERR_STREAM_DESTROYED") { console.warn( - "[Process] Caught stream destroyed rejection (safe to ignore)" + "[Process] Caught stream destroyed rejection (safe to ignore)", ); return; } @@ -30,10 +30,9 @@ process.on("unhandledRejection", (reason, promise) => { }); import { createAuthMiddleware } from "./middleware/auth.js"; -import { - updateDiscoveryCache, - getDiscoveryCache, -} from "./services/discoveryService.js"; +import { updateDiscoveryCache } from "./services/discoveryService.js"; +import { libraryManager } from "./services/libraryManager.js"; +import { lidarrSignalRService } from "./services/lidarrClient.js"; import { websocketService } from "./services/websocketService.js"; import settingsRouter from "./routes/settings.js"; @@ -51,6 +50,7 @@ const __dirname = dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; +const DAY_MS = 24 * 60 * 60 * 1000; const trustProxyValue = process.env.TRUST_PROXY === undefined @@ -87,22 +87,64 @@ app.use("/api/requests", requestsRouter); app.use("/api/health", healthRouter); app.use("/api/weekly-flow", weeklyFlowRouter); -setInterval(updateDiscoveryCache, 24 * 60 * 60 * 1000); +setInterval(updateDiscoveryCache, DAY_MS); + +setInterval(() => { + libraryManager + .fullSyncFromLidarr() + .then((result) => { + if (result?.success) { + console.log( + `[LibrarySync] Full sync complete (${result.artists} artists, ${result.albums} albums)`, + ); + } else if (result?.error && result.error !== "Lidarr is not configured") { + console.warn("[LibrarySync] Full sync failed:", result.error); + } + }) + .catch((error) => { + console.warn("[LibrarySync] Full sync failed:", error?.message || error); + }); +}, DAY_MS); setTimeout(async () => { const { dbOps } = await import("./config/db-helpers.js"); const discovery = dbOps.getDiscoveryCache(); const lastUpdated = discovery?.lastUpdated; - const twentyFourHoursAgo = Date.now() - 24 * 60 * 60 * 1000; + const twentyFourHoursAgo = Date.now() - DAY_MS; if (!lastUpdated || new Date(lastUpdated).getTime() < twentyFourHoursAgo) { updateDiscoveryCache(); } else { console.log( - `Discovery cache is fresh (last updated ${lastUpdated}). Skipping initial update.` + `Discovery cache is fresh (last updated ${lastUpdated}). Skipping initial update.`, ); } }, 5000); +setTimeout(async () => { + const lastSyncAt = libraryManager.getLastFullSyncAt(); + + libraryManager.getAllArtists().catch(() => {}); + libraryManager.getAllAlbums().catch(() => {}); + + if (!lastSyncAt || Date.now() - lastSyncAt > DAY_MS) { + try { + const result = await libraryManager.fullSyncFromLidarr(); + if (result?.success) { + console.log( + `[LibrarySync] Initial full sync complete (${result.artists} artists, ${result.albums} albums)`, + ); + } else if (result?.error && result.error !== "Lidarr is not configured") { + console.warn("[LibrarySync] Initial full sync failed:", result.error); + } + } catch (error) { + console.warn( + "[LibrarySync] Initial full sync failed:", + error?.message || error, + ); + } + } +}, 8000); + const httpServer = createServer(app); websocketService.initialize(httpServer); @@ -110,4 +152,6 @@ websocketService.initialize(httpServer); httpServer.listen(PORT, async () => { console.log(`Server running on port ${PORT}`); console.log(`WebSocket available at ws://localhost:${PORT}/ws`); + lidarrSignalRService.start(); + libraryManager.startActivityPolling(); }); diff --git a/backend/services/libraryManager.js b/backend/services/libraryManager.js index e532d05..e1c6a46 100644 --- a/backend/services/libraryManager.js +++ b/backend/services/libraryManager.js @@ -1,19 +1,232 @@ import fs from "fs/promises"; import path from "path"; import { dbOps } from "../config/db-helpers.js"; -import { dbHelpers } from "../config/db-sqlite.js"; +import { dbHelpers, db } from "../config/db-sqlite.js"; import { musicbrainzRequest } from "./apiClients.js"; const LIDARR_RETRY_MS = 60000; const TRACKS_CACHE_TTL_MS = 120000; const TRACKS_CACHE_MAX = 300; +const ACTIVITY_POLL_INTERVAL_MS = 15000; let lidarrClient = null; let _cachedArtists = []; +let _cachedAlbums = []; +let _cachedQueue = null; +let _cachedHistory = null; let _lastLidarrFailureAt = 0; let _retryTimeoutId = null; +let _activityPollIntervalId = null; const _tracksCache = new Map(); +const selectAllArtistsStmt = db.prepare( + "SELECT data FROM lidarr_artists ORDER BY artist_name COLLATE NOCASE", +); +const selectArtistByMbidStmt = db.prepare( + "SELECT data FROM lidarr_artists WHERE foreign_artist_id = ? LIMIT 1", +); +const selectArtistByIdStmt = db.prepare( + "SELECT data FROM lidarr_artists WHERE id = ? LIMIT 1", +); +const upsertArtistStmt = db.prepare(` + INSERT INTO lidarr_artists (id, foreign_artist_id, artist_name, data, updated_at) + VALUES (@id, @foreignArtistId, @artistName, @data, @updatedAt) + ON CONFLICT(id) DO UPDATE SET + foreign_artist_id = excluded.foreign_artist_id, + artist_name = excluded.artist_name, + data = excluded.data, + updated_at = excluded.updated_at +`); +const deleteArtistByIdStmt = db.prepare( + "DELETE FROM lidarr_artists WHERE id = ?", +); +const deleteArtistByMbidStmt = db.prepare( + "DELETE FROM lidarr_artists WHERE foreign_artist_id = ?", +); +const deleteAllArtistsStmt = db.prepare("DELETE FROM lidarr_artists"); + +const selectAllAlbumsStmt = db.prepare( + "SELECT data FROM lidarr_albums ORDER BY album_name COLLATE NOCASE", +); +const selectAlbumsByArtistIdStmt = db.prepare( + "SELECT data FROM lidarr_albums WHERE artist_id = ? ORDER BY album_name COLLATE NOCASE", +); +const selectAlbumByIdStmt = db.prepare( + "SELECT data FROM lidarr_albums WHERE id = ? LIMIT 1", +); +const selectAlbumByMbidStmt = db.prepare( + "SELECT data FROM lidarr_albums WHERE foreign_album_id = ? LIMIT 1", +); +const upsertAlbumStmt = db.prepare(` + INSERT INTO lidarr_albums (id, artist_id, foreign_album_id, album_name, data, updated_at) + VALUES (@id, @artistId, @foreignAlbumId, @albumName, @data, @updatedAt) + ON CONFLICT(id) DO UPDATE SET + artist_id = excluded.artist_id, + foreign_album_id = excluded.foreign_album_id, + album_name = excluded.album_name, + data = excluded.data, + updated_at = excluded.updated_at +`); +const deleteAlbumByIdStmt = db.prepare( + "DELETE FROM lidarr_albums WHERE id = ?", +); +const deleteAlbumsByArtistIdStmt = db.prepare( + "DELETE FROM lidarr_albums WHERE artist_id = ?", +); +const deleteAllAlbumsStmt = db.prepare("DELETE FROM lidarr_albums"); + +const selectTracksByAlbumIdStmt = db.prepare( + "SELECT data FROM lidarr_tracks WHERE album_id = ? ORDER BY track_number ASC, track_name COLLATE NOCASE", +); +const upsertTrackStmt = db.prepare(` + INSERT INTO lidarr_tracks (id, album_id, artist_id, foreign_track_id, track_name, track_number, data, updated_at) + VALUES (@id, @albumId, @artistId, @foreignTrackId, @trackName, @trackNumber, @data, @updatedAt) + ON CONFLICT(id) DO UPDATE SET + album_id = excluded.album_id, + artist_id = excluded.artist_id, + foreign_track_id = excluded.foreign_track_id, + track_name = excluded.track_name, + track_number = excluded.track_number, + data = excluded.data, + updated_at = excluded.updated_at +`); +const deleteTracksByAlbumIdStmt = db.prepare( + "DELETE FROM lidarr_tracks WHERE album_id = ?", +); +const deleteTracksByArtistIdStmt = db.prepare( + "DELETE FROM lidarr_tracks WHERE artist_id = ?", +); +const deleteTrackByIdStmt = db.prepare( + "DELETE FROM lidarr_tracks WHERE id = ?", +); + +const selectSyncMetaStmt = db.prepare( + "SELECT value FROM lidarr_sync_meta WHERE key = ?", +); +const upsertSyncMetaStmt = db.prepare(` + INSERT INTO lidarr_sync_meta (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value +`); + +function safeStringify(data) { + try { + return JSON.stringify(data ?? {}); + } catch { + return "{}"; + } +} + +function parseRowData(row) { + if (!row) return null; + return dbHelpers.parseJSON(row.data) || null; +} + +function loadCachedArtists() { + const rows = selectAllArtistsStmt.all(); + return rows.map(parseRowData).filter(Boolean); +} + +function loadCachedAlbums() { + const rows = selectAllAlbumsStmt.all(); + return rows.map(parseRowData).filter(Boolean); +} + +function loadCachedArtistByMbid(mbid) { + return parseRowData(selectArtistByMbidStmt.get(mbid)); +} + +function loadCachedArtistById(id) { + return parseRowData(selectArtistByIdStmt.get(id)); +} + +function loadCachedAlbumsByArtistId(artistId) { + const rows = selectAlbumsByArtistIdStmt.all(String(artistId)); + return rows.map(parseRowData).filter(Boolean); +} + +function loadCachedAlbumById(id) { + return parseRowData(selectAlbumByIdStmt.get(String(id))); +} + +function loadCachedAlbumByMbid(mbid) { + return parseRowData(selectAlbumByMbidStmt.get(mbid)); +} + +function loadCachedTracksByAlbumId(albumId) { + const rows = selectTracksByAlbumIdStmt.all(String(albumId)); + return rows.map(parseRowData).filter(Boolean); +} + +function upsertArtistCache(artist) { + if (!artist) return; + _cachedArtists = []; + const payload = { + id: String(artist.id ?? ""), + foreignArtistId: artist.foreignArtistId || artist.mbid || null, + artistName: artist.artistName || artist.name || "", + data: safeStringify(artist), + updatedAt: Date.now(), + }; + if (!payload.id) return; + upsertArtistStmt.run(payload); +} + +function upsertAlbumCache(album) { + if (!album) return; + _cachedAlbums = []; + const payload = { + id: String(album.id ?? ""), + artistId: String(album.artistId ?? ""), + foreignAlbumId: album.foreignAlbumId || album.mbid || null, + albumName: album.albumName || album.title || "", + data: safeStringify(album), + updatedAt: Date.now(), + }; + if (!payload.id) return; + upsertAlbumStmt.run(payload); +} + +function upsertTrackCache(track) { + if (!track) return; + _tracksCache.delete(String(track.albumId)); + const payload = { + id: String(track.id ?? ""), + albumId: String(track.albumId ?? ""), + artistId: String(track.artistId ?? ""), + foreignTrackId: track.foreignTrackId || track.mbid || null, + trackName: track.trackName || track.title || "", + trackNumber: track.trackNumber || 0, + data: safeStringify(track), + updatedAt: Date.now(), + }; + if (!payload.id) return; + upsertTrackStmt.run(payload); +} + +const replaceAllArtistsTx = db.transaction((artists) => { + deleteAllArtistsStmt.run(); + for (const artist of artists) { + upsertArtistCache(artist); + } + _cachedArtists = artists; +}); + +const replaceAllAlbumsTx = db.transaction((albums) => { + deleteAllAlbumsStmt.run(); + for (const album of albums) { + upsertAlbumCache(album); + } + _cachedAlbums = albums; +}); + +const replaceTracksByAlbumTx = db.transaction((albumId, tracks) => { + deleteTracksByAlbumIdStmt.run(String(albumId)); + for (const track of tracks) { + upsertTrackCache(track); + } +}); + async function getLidarrClient() { if (!lidarrClient) { try { @@ -49,7 +262,9 @@ export class LibraryManager { try { const existing = await lidarr.getArtistByMbid(mbid); if (existing) { - return this.mapLidarrArtist(existing); + const mapped = this.mapLidarrArtist(existing); + upsertArtistCache(mapped); + return mapped; } const lidarrSettings = getSettings(); const lidarrArtist = await lidarr.addArtist(mbid, artistName, { @@ -60,7 +275,9 @@ export class LibraryManager { lidarrSettings.integrations?.lidarr?.metadataProfileId, }); console.log(`[LibraryManager] Added artist "${artistName}" to Lidarr`); - return this.mapLidarrArtist(lidarrArtist); + const mapped = this.mapLidarrArtist(lidarrArtist); + upsertArtistCache(mapped); + return mapped; } catch (error) { console.error( `[LibraryManager] Failed to add artist to Lidarr: ${error.message}`, @@ -155,13 +372,15 @@ export class LibraryManager { async getArtist(mbid) { const lidarr = await getLidarrClient(); - if (!lidarr || !lidarr.isConfigured()) { - return null; - } + const cached = loadCachedArtistByMbid(mbid); + if (cached) return cached; + if (!lidarr || !lidarr.isConfigured()) return null; try { const lidarrArtist = await lidarr.getArtistByMbid(mbid); if (!lidarrArtist) return null; - return this.mapLidarrArtist(lidarrArtist); + const mapped = this.mapLidarrArtist(lidarrArtist); + upsertArtistCache(mapped); + return mapped; } catch { return null; } @@ -169,12 +388,14 @@ export class LibraryManager { async getArtistById(id) { const lidarr = await getLidarrClient(); - if (!lidarr || !lidarr.isConfigured()) { - return null; - } + const cached = loadCachedArtistById(id); + if (cached) return cached; + if (!lidarr || !lidarr.isConfigured()) return null; try { const lidarrArtist = await lidarr.getArtist(id); - return this.mapLidarrArtist(lidarrArtist); + const mapped = this.mapLidarrArtist(lidarrArtist); + upsertArtistCache(mapped); + return mapped; } catch (error) { return null; } @@ -182,10 +403,15 @@ export class LibraryManager { async getAllArtists() { try { + if (_cachedArtists.length > 0) return _cachedArtists; + const lidarr = await getLidarrClient(); - if (!lidarr || !lidarr.isConfigured()) { - return _cachedArtists; + const cachedFromDb = loadCachedArtists(); + if (cachedFromDb.length > 0) { + _cachedArtists = cachedFromDb; + return cachedFromDb; } + if (!lidarr || !lidarr.isConfigured()) return _cachedArtists; if ( _lastLidarrFailureAt && Date.now() - _lastLidarrFailureAt < LIDARR_RETRY_MS @@ -200,6 +426,7 @@ export class LibraryManager { return _cachedArtists; } _cachedArtists = lidarrArtists.map((a) => this.mapLidarrArtist(a)); + replaceAllArtistsTx(_cachedArtists); return _cachedArtists; } catch (error) { const wasHealthy = _lastLidarrFailureAt === 0; @@ -220,6 +447,30 @@ export class LibraryManager { } } + async getAllAlbums() { + try { + if (_cachedAlbums.length > 0) return _cachedAlbums; + + const cached = loadCachedAlbums(); + if (cached.length > 0) { + _cachedAlbums = cached; + return cached; + } + + // If cache is empty, trigger a full sync to populate it + const result = await this.fullSyncFromLidarr(); + if (result.success) { + const fresh = loadCachedAlbums(); + _cachedAlbums = fresh; + return fresh; + } + return []; + } catch (err) { + console.warn("[LibraryManager] Failed to get all albums:", err.message); + return []; + } + } + mapLidarrArtist(lidarrArtist) { const artistPath = lidarrArtist.path ?? null; const monitorOption = @@ -288,9 +539,12 @@ export class LibraryManager { }) .catch(() => {}); } + upsertArtistCache(mapped); return mapped; } - return this.mapLidarrArtist(lidarrArtist); + const mapped = this.mapLidarrArtist(lidarrArtist); + upsertArtistCache(mapped); + return mapped; } catch (error) { console.error( `[LibraryManager] Failed to update artist in Lidarr: ${error.message}`, @@ -312,6 +566,10 @@ export class LibraryManager { console.log( `[LibraryManager] Deleted artist "${lidarrArtist.artistName}" from Lidarr`, ); + deleteArtistByIdStmt.run(String(lidarrArtist.id)); + deleteArtistByMbidStmt.run(mbid); + deleteAlbumsByArtistIdStmt.run(String(lidarrArtist.id)); + deleteTracksByArtistIdStmt.run(String(lidarrArtist.id)); return { success: true }; } catch (error) { console.error( @@ -335,7 +593,9 @@ export class LibraryManager { } const existing = await lidarr.getAlbumByMbid(releaseGroupMbid); if (existing) { - return this.mapLidarrAlbum(existing, lidarrArtist); + const mapped = this.mapLidarrAlbum(existing, lidarrArtist); + upsertAlbumCache(mapped); + return mapped; } const settings = getSettings(); const searchOnAdd = settings.integrations?.lidarr?.searchOnAdd ?? false; @@ -373,7 +633,9 @@ export class LibraryManager { } } const updatedArtist = await lidarr.getArtist(artistId); - return this.mapLidarrAlbum(lidarrAlbum, updatedArtist); + const mapped = this.mapLidarrAlbum(lidarrAlbum, updatedArtist); + upsertAlbumCache(mapped); + return mapped; } catch (error) { console.error( `[LibraryManager] Failed to add album to Lidarr: ${error.message}`, @@ -384,9 +646,9 @@ export class LibraryManager { async getAlbums(artistId) { const lidarr = await getLidarrClient(); - if (!lidarr || !lidarr.isConfigured()) { - return []; - } + const cached = loadCachedAlbumsByArtistId(artistId); + if (cached.length > 0) return cached; + if (!lidarr || !lidarr.isConfigured()) return []; try { const lidarrArtist = await lidarr.getArtist(artistId); if (!lidarrArtist) { @@ -398,7 +660,13 @@ export class LibraryManager { const artistAlbums = Array.isArray(allAlbums) ? allAlbums.filter((a) => a.artistId === parseInt(artistId)) : []; - return artistAlbums.map((a) => this.mapLidarrAlbum(a, lidarrArtist)); + const mapped = artistAlbums.map((a) => + this.mapLidarrAlbum(a, lidarrArtist), + ); + for (const album of mapped) { + upsertAlbumCache(album); + } + return mapped; } catch (error) { console.error( `[LibraryManager] Failed to fetch albums from Lidarr: ${error.message}`, @@ -409,9 +677,9 @@ export class LibraryManager { async getAlbumById(id) { const lidarr = await getLidarrClient(); - if (!lidarr || !lidarr.isConfigured()) { - return null; - } + const cached = loadCachedAlbumById(id); + if (cached) return cached; + if (!lidarr || !lidarr.isConfigured()) return null; if (!id || id === "undefined" || id === "null") { return null; } @@ -421,7 +689,9 @@ export class LibraryManager { return null; } const lidarrArtist = await lidarr.getArtist(lidarrAlbum.artistId); - return this.mapLidarrAlbum(lidarrAlbum, lidarrArtist); + const mapped = this.mapLidarrAlbum(lidarrAlbum, lidarrArtist); + upsertAlbumCache(mapped); + return mapped; } catch (error) { if (error.response?.status === 404 || error.message?.includes("404")) { return null; @@ -430,6 +700,23 @@ export class LibraryManager { } } + async getAlbumByMbid(mbid) { + const lidarr = await getLidarrClient(); + const cached = loadCachedAlbumByMbid(mbid); + if (cached) return cached; + if (!lidarr || !lidarr.isConfigured()) return null; + try { + const lidarrAlbum = await lidarr.getAlbumByMbid(mbid); + if (!lidarrAlbum) return null; + const lidarrArtist = await lidarr.getArtist(lidarrAlbum.artistId); + const mapped = this.mapLidarrAlbum(lidarrAlbum, lidarrArtist); + upsertAlbumCache(mapped); + return mapped; + } catch { + return null; + } + } + mapLidarrAlbum(lidarrAlbum, lidarrArtist) { const albumPath = lidarrAlbum.path ?? @@ -491,7 +778,9 @@ export class LibraryManager { } const updated = await lidarr.getAlbum(id); const lidarrArtist = await lidarr.getArtist(updated.artistId); - return this.mapLidarrAlbum(updated, lidarrArtist); + const mapped = this.mapLidarrAlbum(updated, lidarrArtist); + upsertAlbumCache(mapped); + return mapped; } catch (error) { const msg = error.message || ""; const isTransient = @@ -521,6 +810,8 @@ export class LibraryManager { } try { await lidarr.deleteAlbum(id, deleteFiles); + deleteAlbumByIdStmt.run(String(id)); + deleteTracksByAlbumIdStmt.run(String(id)); return { success: true }; } catch (error) { console.error( @@ -567,6 +858,14 @@ export class LibraryManager { if (cached && cached.expires > Date.now()) { return cached.tracks; } + const cachedFromDb = loadCachedTracksByAlbumId(albumId); + if (cachedFromDb.length > 0) { + _tracksCache.set(key, { + tracks: cachedFromDb, + expires: Date.now() + TRACKS_CACHE_TTL_MS, + }); + return cachedFromDb; + } const lidarr = await getLidarrClient(); if (!lidarr || !lidarr.isConfigured()) { @@ -649,6 +948,7 @@ export class LibraryManager { const firstKey = _tracksCache.keys().next().value; if (firstKey !== undefined) _tracksCache.delete(firstKey); } + replaceTracksByAlbumTx(albumId, result); _tracksCache.set(key, { tracks: result, expires: Date.now() + TRACKS_CACHE_TTL_MS, @@ -722,12 +1022,268 @@ export class LibraryManager { const tracks = await this.getTracks(lidarrAlbum.id.toString()); const track = tracks.find((t) => t.id === id); if (!track) return null; - return { ...track, ...updates }; + const updated = { ...track, ...updates }; + upsertTrackCache(updated); + return updated; } catch { return null; } } + removeArtistCacheById(id) { + if (!id) return; + deleteArtistByIdStmt.run(String(id)); + deleteAlbumsByArtistIdStmt.run(String(id)); + deleteTracksByArtistIdStmt.run(String(id)); + } + + removeArtistCacheByMbid(mbid) { + if (!mbid) return; + const cached = loadCachedArtistByMbid(mbid); + deleteArtistByMbidStmt.run(String(mbid)); + if (cached?.id) { + deleteAlbumsByArtistIdStmt.run(String(cached.id)); + deleteTracksByArtistIdStmt.run(String(cached.id)); + } + } + + removeAlbumCacheById(id) { + if (!id) return; + deleteAlbumByIdStmt.run(String(id)); + deleteTracksByAlbumIdStmt.run(String(id)); + } + + removeAlbumCacheByMbid(mbid) { + if (!mbid) return; + const cached = loadCachedAlbumByMbid(mbid); + if (cached?.id) { + deleteAlbumByIdStmt.run(String(cached.id)); + deleteTracksByAlbumIdStmt.run(String(cached.id)); + } + } + + getLastFullSyncAt() { + const row = selectSyncMetaStmt.get("last_full_sync_at"); + if (!row?.value) return null; + const parsed = Number(row.value); + return Number.isNaN(parsed) ? null : parsed; + } + + setLastFullSyncAt(timestamp) { + if (!timestamp) return; + upsertSyncMetaStmt.run("last_full_sync_at", String(timestamp)); + } + + async fullSyncFromLidarr() { + const lidarr = await getLidarrClient(); + if (!lidarr || !lidarr.isConfigured()) { + return { success: false, error: "Lidarr is not configured" }; + } + try { + const [artists, albums] = await Promise.all([ + lidarr.getAllArtists(), + lidarr.getAllAlbums(), + ]); + + if (Array.isArray(artists)) { + _cachedArtists = artists.map((a) => this.mapLidarrArtist(a)); + replaceAllArtistsTx(_cachedArtists); + } + if (Array.isArray(albums)) { + // Map albums with artist info if possible, but mapLidarrAlbum handles missing artist + // We need to match albums to artists to get artistName + const artistMap = new Map(_cachedArtists.map((a) => [a.id, a])); + const mappedAlbums = albums.map((album) => { + const artist = artistMap.get(String(album.artistId)); + return this.mapLidarrAlbum(album, artist); + }); + replaceAllAlbumsTx(mappedAlbums); + } + + this.setLastFullSyncAt(Date.now()); + return { + success: true, + artists: artists.length, + albums: albums.length, + }; + } catch (error) { + console.error(`[LibraryManager] Full sync failed: ${error.message}`); + return { success: false, error: error.message }; + } + } + + async refreshActivity() { + const lidarr = await getLidarrClient(); + if (!lidarr || !lidarr.isConfigured()) return; + try { + const [queue, history] = await Promise.all([ + lidarr.getQueue().catch(() => []), + lidarr.getHistory(1, 200).catch(() => ({ records: [] })), + ]); + + _cachedQueue = Array.isArray(queue) ? queue : queue?.records || []; + _cachedHistory = history || { records: [] }; + } catch (error) { + // Silent fail for background polling + } + } + + startActivityPolling() { + if (_activityPollIntervalId) return; + this.refreshActivity(); // Initial fetch + _activityPollIntervalId = setInterval(() => { + this.refreshActivity(); + }, ACTIVITY_POLL_INTERVAL_MS); + console.log("[LibraryManager] Started background activity polling"); + } + + stopActivityPolling() { + if (_activityPollIntervalId) { + clearInterval(_activityPollIntervalId); + _activityPollIntervalId = null; + } + } + + async getQueue() { + if (_cachedQueue === null) { + await this.refreshActivity(); + } + return _cachedQueue || []; + } + + async getHistory() { + if (_cachedHistory === null) { + await this.refreshActivity(); + } + return _cachedHistory || { records: [] }; + } + + async syncArtistFromLidarr(idOrMbid) { + const lidarr = await getLidarrClient(); + if (!lidarr || !lidarr.isConfigured()) return null; + const value = String(idOrMbid || "").trim(); + if (!value) return null; + try { + const isMbid = value.includes("-") && value.length >= 32; + const artist = isMbid + ? await lidarr.getArtistByMbid(value) + : await lidarr.getArtist(value); + if (!artist) return null; + const mapped = this.mapLidarrArtist(artist); + upsertArtistCache(mapped); + return mapped; + } catch { + return null; + } + } + + async syncAlbumFromLidarr(albumId) { + const lidarr = await getLidarrClient(); + if (!lidarr || !lidarr.isConfigured()) return null; + const value = String(albumId || "").trim(); + if (!value) return null; + try { + const album = await lidarr.getAlbum(value); + if (!album) return null; + const artist = await lidarr.getArtist(album.artistId); + const mapped = this.mapLidarrAlbum(album, artist); + upsertAlbumCache(mapped); + return mapped; + } catch { + return null; + } + } + + async syncAlbumTracksFromLidarr(albumId) { + const lidarr = await getLidarrClient(); + if (!lidarr || !lidarr.isConfigured()) return []; + const value = String(albumId || "").trim(); + if (!value) return []; + try { + const lidarrAlbum = await lidarr.getAlbum(value); + if (!lidarrAlbum) return []; + const rawPercent = lidarrAlbum.statistics?.percentOfTracks || 0; + const albumSizeOnDisk = lidarrAlbum.statistics?.sizeOnDisk || 0; + let normalizedPercent = rawPercent; + + if (rawPercent > 1 && rawPercent <= 100) { + normalizedPercent = rawPercent; + } else if (rawPercent <= 1 && rawPercent >= 0) { + normalizedPercent = Math.round(rawPercent * 100); + } else if (rawPercent > 100) { + normalizedPercent = Math.min(100, Math.round(rawPercent / 10)); + } + + const isAlbumComplete = normalizedPercent >= 100 || albumSizeOnDisk > 0; + let result = []; + + if ( + lidarrAlbum.tracks && + Array.isArray(lidarrAlbum.tracks) && + lidarrAlbum.tracks.length > 0 + ) { + result = lidarrAlbum.tracks.map((t, index) => + this.mapLidarrTrack(t, lidarrAlbum, index + 1, isAlbumComplete), + ); + } else if ( + lidarrAlbum.albumReleases && + lidarrAlbum.albumReleases.length > 0 + ) { + for (const release of lidarrAlbum.albumReleases) { + if ( + release.tracks && + Array.isArray(release.tracks) && + release.tracks.length > 0 + ) { + result = release.tracks.map((t, index) => + this.mapLidarrTrack(t, lidarrAlbum, index + 1, isAlbumComplete), + ); + break; + } + } + } else if ( + lidarrAlbum.media && + Array.isArray(lidarrAlbum.media) && + lidarrAlbum.media.length > 0 + ) { + const allTracks = []; + for (const medium of lidarrAlbum.media) { + if (medium.tracks && Array.isArray(medium.tracks)) { + allTracks.push(...medium.tracks); + } + } + if (allTracks.length > 0) { + result = allTracks.map((t, index) => + this.mapLidarrTrack(t, lidarrAlbum, index + 1, isAlbumComplete), + ); + } + } + + if (result.length === 0) { + const lidarrTracks = await lidarr.getTracksByAlbumId(value); + if (lidarrTracks && lidarrTracks.length > 0) { + result = lidarrTracks.map((t, index) => + this.mapLidarrTrack(t, lidarrAlbum, index + 1, isAlbumComplete), + ); + } + } + + replaceTracksByAlbumTx(value, result); + const key = String(value); + if (_tracksCache.size >= TRACKS_CACHE_MAX) { + const firstKey = _tracksCache.keys().next().value; + if (firstKey !== undefined) _tracksCache.delete(firstKey); + } + _tracksCache.set(key, { + tracks: result, + expires: Date.now() + TRACKS_CACHE_TTL_MS, + }); + return result; + } catch { + return []; + } + } + async scanLibrary(discover = false) { const { fileScanner } = await import("./fileScanner.js"); return await fileScanner.scanLibrary(discover); diff --git a/backend/services/lidarrClient.js b/backend/services/lidarrClient.js index 9e0e54d..da7464d 100644 --- a/backend/services/lidarrClient.js +++ b/backend/services/lidarrClient.js @@ -1,14 +1,26 @@ import axios from "axios"; import http from "http"; import https from "https"; +import { + HubConnectionBuilder, + HttpTransportType, + LogLevel, +} from "@microsoft/signalr"; +import WebSocket from "ws"; import { dbOps } from "../config/db-helpers.js"; +if (!globalThis.WebSocket) { + globalThis.WebSocket = WebSocket; +} + const CIRCUIT_COOLDOWN_MS = 60000; const CIRCUIT_FAILURE_THRESHOLD = 3; const LIDARR_MAX_CONCURRENT = 12; const LIDARR_LIST_CACHE_MS = 30000; const LIDARR_RETRY_ATTEMPTS = 2; const LIDARR_RETRY_DELAY_MS = 800; +const SIGNALR_RECONNECT_MS = 15000; +const SIGNALR_CONFIG_CHECK_MS = 60000; export class LidarrClient { constructor() { @@ -803,3 +815,284 @@ export class LidarrClient { } export const lidarrClient = new LidarrClient(); + +class LidarrSignalRService { + constructor() { + this.connection = null; + this.started = false; + this.lastConfig = null; + this.checkTimer = null; + this.reconnectTimer = null; + } + + start() { + if (this.started) return; + this.started = true; + this.ensureConnection(); + this.checkTimer = setInterval( + () => this.ensureConnection(), + SIGNALR_CONFIG_CHECK_MS, + ); + } + + async stop() { + this.started = false; + if (this.checkTimer) { + clearInterval(this.checkTimer); + this.checkTimer = null; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + await this.stopConnection(); + } + + getSignalRUrl(config) { + const base = String(config.url || "").replace(/\/+$/, ""); + const apiKey = String(config.apiKey || ""); + if (!apiKey) return `${base}/signalr`; + return `${base}/signalr?apikey=${encodeURIComponent(apiKey)}`; + } + + getConfigSignature(config) { + return `${config.url}|${config.apiKey}`; + } + + async ensureConnection() { + if (!this.started) return; + lidarrClient.updateConfig(); + const config = lidarrClient.getConfig(); + if (!config.apiKey) { + await this.stopConnection(); + this.lastConfig = null; + return; + } + if (process.env.LIDARR_SIGNALR_DISABLED === "true") { + await this.stopConnection(); + this.lastConfig = null; + return; + } + const signature = this.getConfigSignature(config); + if (this.connection && this.lastConfig === signature) { + return; + } + await this.stopConnection(); + await this.connect(config); + } + + async connect(config) { + const url = this.getSignalRUrl(config); + const connection = new HubConnectionBuilder() + .withUrl(url, { + headers: { + "X-Api-Key": config.apiKey, + }, + transport: HttpTransportType.WebSockets, + skipNegotiation: true, + }) + .withAutomaticReconnect([0, 2000, 5000, 15000]) + .configureLogging(LogLevel.Warning) + .build(); + + const handler = (payload) => { + this.handlePayload(payload); + }; + + connection.on("receiveNotification", handler); + connection.on("ReceiveNotification", handler); + connection.on("receiveMessage", handler); + connection.on("ReceiveMessage", handler); + connection.on("receiveEvent", handler); + connection.on("ReceiveEvent", handler); + connection.on("event", handler); + connection.on("Event", handler); + connection.on("notification", handler); + connection.on("Notification", handler); + + connection.onclose(() => { + this.connection = null; + if (this.started) { + this.scheduleReconnect(); + } + }); + + try { + await connection.start(); + this.connection = connection; + this.lastConfig = this.getConfigSignature(config); + console.log("[Lidarr SignalR] Connected"); + } catch (error) { + console.warn( + "[Lidarr SignalR] Connection failed:", + error?.message || error, + ); + this.scheduleReconnect(); + } + } + + async stopConnection() { + if (!this.connection) return; + try { + await this.connection.stop(); + } catch {} + this.connection = null; + } + + scheduleReconnect() { + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.ensureConnection(); + }, SIGNALR_RECONNECT_MS); + } + + async handlePayload(payload) { + const items = Array.isArray(payload) ? payload : [payload]; + for (const item of items) { + await this.processEvent(item); + } + } + + parseEvent(payload) { + const base = payload?.resource || payload?.data || payload?.item || payload; + const resourceType = String( + payload?.resourceType || + base?.resourceType || + payload?.type || + base?.type || + "", + ).toLowerCase(); + const eventType = String( + payload?.eventType || + payload?.name || + payload?.event || + payload?.type || + base?.eventType || + base?.event || + base?.type || + "", + ).toLowerCase(); + const artistId = + base?.artistId ?? base?.artist?.id ?? payload?.artistId ?? null; + const artistMbid = + base?.foreignArtistId || + base?.artist?.foreignArtistId || + base?.artist?.mbid || + payload?.artistMbid || + payload?.foreignArtistId || + null; + const albumId = + base?.albumId ?? + base?.album?.id ?? + payload?.albumId ?? + (resourceType === "album" ? base?.id : null); + const albumMbid = + base?.foreignAlbumId || + base?.album?.foreignAlbumId || + payload?.albumMbid || + payload?.foreignAlbumId || + null; + return { resourceType, eventType, artistId, artistMbid, albumId, albumMbid }; + } + + async processEvent(payload) { + const parsed = this.parseEvent(payload); + const resourceType = parsed.resourceType; + const eventType = parsed.eventType; + let artistId = parsed.artistId; + let artistMbid = parsed.artistMbid; + let albumId = parsed.albumId; + let albumMbid = parsed.albumMbid; + + if (!resourceType && !eventType && !artistId && !albumId && !albumMbid) { + return; + } + + const isDelete = + eventType.includes("deleted") || + eventType.includes("removed") || + eventType.includes("delete"); + const relatesToAlbum = + resourceType.includes("album") || + eventType.includes("album") || + albumId != null || + albumMbid != null; + const relatesToArtist = + resourceType.includes("artist") || + eventType.includes("artist") || + artistId != null || + artistMbid != null; + const relatesToTracks = + resourceType.includes("track") || + eventType.includes("track") || + eventType.includes("import"); + + const { libraryManager } = await import("./libraryManager.js"); + const { websocketService } = await import("./websocketService.js"); + + if (isDelete) { + if (relatesToAlbum) { + if (albumId != null) { + libraryManager.removeAlbumCacheById(albumId); + } else if (albumMbid) { + libraryManager.removeAlbumCacheByMbid(albumMbid); + } + } + if (relatesToArtist) { + if (artistId != null) { + libraryManager.removeArtistCacheById(artistId); + } else if (artistMbid) { + libraryManager.removeArtistCacheByMbid(artistMbid); + } + } + websocketService.emitLibraryUpdate("lidarr_signalr", { + action: "delete", + resourceType, + eventType, + artistId, + artistMbid, + albumId, + albumMbid, + }); + return; + } + + if (relatesToAlbum || relatesToTracks) { + if (!albumId && albumMbid) { + const cached = await libraryManager.getAlbumByMbid(albumMbid); + albumId = cached?.id || null; + if (!artistId && cached?.artistId) artistId = cached.artistId; + } + if (albumId) { + await libraryManager.syncAlbumFromLidarr(albumId); + await libraryManager.syncAlbumTracksFromLidarr(albumId); + websocketService.emitLibraryUpdate("lidarr_signalr", { + action: "album_update", + resourceType, + eventType, + artistId, + artistMbid, + albumId, + albumMbid, + }); + return; + } + } + + if (relatesToArtist && (artistId || artistMbid)) { + const updated = await libraryManager.syncArtistFromLidarr( + artistId || artistMbid, + ); + websocketService.emitLibraryUpdate("lidarr_signalr", { + action: "artist_update", + resourceType, + eventType, + artistId: updated?.id || artistId, + artistMbid: updated?.foreignArtistId || artistMbid, + }); + } + } +} + +export const lidarrSignalRService = new LidarrSignalRService(); diff --git a/frontend/src/pages/SearchResultsPage.jsx b/frontend/src/pages/SearchResultsPage.jsx index e866254..0d996ef 100644 --- a/frontend/src/pages/SearchResultsPage.jsx +++ b/frontend/src/pages/SearchResultsPage.jsx @@ -6,7 +6,6 @@ import { searchArtistsByTag, getDiscovery, checkHealth, - lookupArtistsInLibraryBatch, } from "../utils/api"; import ArtistImage from "../components/ArtistImage"; import PillToggle from "../components/PillToggle"; @@ -27,7 +26,6 @@ function SearchResultsPage() { const [hasMore, setHasMore] = useState(false); const [searchTotalCount, setSearchTotalCount] = useState(0); const [lastfmConfigured, setLastfmConfigured] = useState(null); - const [libraryLookup, setLibraryLookup] = useState({}); const navigate = useNavigate(); const trimmedQuery = useMemo(() => query.trim(), [query]); @@ -80,7 +78,6 @@ function SearchResultsPage() { useEffect(() => { const performSearch = async () => { - setLibraryLookup({}); if (type === "recommended" || type === "trending") { setLoading(true); setError(null); @@ -172,39 +169,6 @@ function SearchResultsPage() { performSearch(); }, [query, type, dedupe, trimmedQuery, isTagSearch, tagScope, getArtistId]); - useEffect(() => { - let cancelled = false; - const ids = results.map((artist) => getArtistId(artist)).filter(Boolean); - if (ids.length === 0) { - setLibraryLookup({}); - return () => { - cancelled = true; - }; - } - const missing = ids.filter((id) => libraryLookup[id] === undefined); - if (missing.length === 0) return () => { - cancelled = true; - }; - - const fetchLookup = async () => { - try { - const lookup = await lookupArtistsInLibraryBatch(missing); - if (!cancelled && lookup) { - setLibraryLookup((prev) => ({ ...prev, ...lookup })); - } - } catch { - if (!cancelled) { - setLibraryLookup((prev) => ({ ...prev })); - } - } - }; - - fetchLookup(); - return () => { - cancelled = true; - }; - }, [results, libraryLookup, getArtistId]); - const loadMore = useCallback(async () => { if (type === "recommended" || type === "trending") { const next = visibleCount + PAGE_SIZE; @@ -487,7 +451,7 @@ function SearchResultsPage() { > {artist.name} - {libraryLookup[artistId] && ( + {artist.inLibrary && ( )} diff --git a/package-lock.json b/package-lock.json index 99b91c6..e217da2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aurral", - "version": "1.14.0", + "version": "1.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aurral", - "version": "1.14.0", + "version": "1.15.0", "license": "MIT", "dependencies": { "axios": "^1.7.9", From 7a1fd7e03518847fd574c5554271c7607852b8b9 Mon Sep 17 00:00:00 2001 From: lklynet Date: Wed, 25 Feb 2026 13:16:19 -0500 Subject: [PATCH 02/11] feat: integrate SignalR for real-time updates and improve polling efficiency - Add SignalR client dependency and integrate with Lidarr for real-time notifications - Replace API polling with WebSocket-driven updates for queue, library, and download status - Implement caching and signature-based change detection to reduce unnecessary updates - Extend library manager with command caching and SignalR event processing - Remove client-side polling in favor of server-pushed WebSocket events - Increase polling intervals for background tasks to reduce server load --- backend/package-lock.json | 192 +++++++++++++++++++ backend/package.json | 3 +- backend/routes/library/handlers/downloads.js | 4 +- backend/routes/library/handlers/misc.js | 10 +- backend/services/libraryManager.js | 142 +++++++++++++- backend/services/lidarrClient.js | 138 +++++++++++-- frontend/src/pages/DiscoverPage.jsx | 32 ---- frontend/src/pages/RequestsPage.jsx | 99 +++++++++- server.js | 5 + 9 files changed, 551 insertions(+), 74 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index a987b7c..49661ec 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,7 @@ "lowdb": "^7.0.1", "music-metadata": "^11.10.6", "node-cache": "^5.1.2", + "signalr-client": "^0.0.20", "slsk-client": "^1.1.0", "uuid": "^11.0.0", "ws": "^8.18.0" @@ -455,6 +456,19 @@ "ieee754": "^1.1.13" } }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -619,6 +633,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -796,12 +823,67 @@ "node": ">= 0.4" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -811,6 +893,16 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -908,6 +1000,15 @@ "express": ">= 4.11" } }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/fetch-cookie": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", @@ -1443,6 +1544,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/lowdb": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", @@ -1711,6 +1818,12 @@ "node": ">= 0.6" } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -1761,6 +1874,17 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -2303,6 +2427,19 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/signalr-client": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/signalr-client/-/signalr-client-0.0.20.tgz", + "integrity": "sha512-4gscZW3jcrbwXb2dZJL0N40qHQyK08ejH6JYE35nNm+aM2eUu56vKtcEq44zTGIdwoRGJ/SO4TO3nwfZ0LFj3A==", + "deprecated": "This is no longer active", + "license": "MIT", + "dependencies": { + "websocket": "^1.0.28" + }, + "engines": { + "node": "*" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2617,6 +2754,12 @@ "node": "*" } }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2639,6 +2782,15 @@ "node": ">= 0.6" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -2686,6 +2838,19 @@ "requires-port": "^1.0.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2729,6 +2894,23 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -2781,6 +2963,16 @@ } } }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 0ebe25a..ce754fa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,8 +21,8 @@ ] }, "dependencies": { - "axios": "^1.7.9", "@microsoft/signalr": "^8.0.7", + "axios": "^1.7.9", "bcrypt": "^5.1.1", "better-sqlite3": "^12.6.2", "bottleneck": "^2.19.5", @@ -35,6 +35,7 @@ "lowdb": "^7.0.1", "music-metadata": "^11.10.6", "node-cache": "^5.1.2", + "signalr-client": "^0.0.20", "slsk-client": "^1.1.0", "uuid": "^11.0.0", "ws": "^8.18.0" diff --git a/backend/routes/library/handlers/downloads.js b/backend/routes/library/handlers/downloads.js index 394f043..958d2d4 100644 --- a/backend/routes/library/handlers/downloads.js +++ b/backend/routes/library/handlers/downloads.js @@ -230,7 +230,7 @@ export default function registerDownloads(router) { const [queue, history, commands] = await Promise.all([ libraryManager.getQueue().catch(() => []), libraryManager.getHistory().catch(() => ({ records: [] })), - lidarrClient.request("/command").catch(() => []), + libraryManager.getCommands().catch(() => []), ]); const queueItems = Array.isArray(queue) ? queue : queue.records || []; const historyItems = Array.isArray(history) @@ -455,7 +455,7 @@ export default function registerDownloads(router) { libraryManager.getQueue().catch(() => []), libraryManager.getHistory().catch(() => ({ records: [] })), libraryManager.getAllAlbums().catch(() => []), - lidarrClient.request("/command").catch(() => []), + libraryManager.getCommands().catch(() => []), ]); const queueItems = Array.isArray(queue) ? queue : queue.records || []; diff --git a/backend/routes/library/handlers/misc.js b/backend/routes/library/handlers/misc.js index 2397025..7df0c7b 100644 --- a/backend/routes/library/handlers/misc.js +++ b/backend/routes/library/handlers/misc.js @@ -120,15 +120,9 @@ export default function registerMisc(router) { router.get("/recent-releases", async (req, res) => { try { - const { lidarrClient } = - await import("../../../services/lidarrClient.js"); - if (!lidarrClient.isConfigured()) { - return res.json([]); - } - const [artists, albums] = await Promise.all([ - lidarrClient.request("/artist"), - lidarrClient.request("/album"), + libraryManager.getAllArtists(), + libraryManager.getAllAlbums(), ]); if (!Array.isArray(albums) || albums.length === 0) { diff --git a/backend/services/libraryManager.js b/backend/services/libraryManager.js index 92e761d..1d32b38 100644 --- a/backend/services/libraryManager.js +++ b/backend/services/libraryManager.js @@ -6,20 +6,28 @@ import { musicbrainzRequest, musicbrainzGetArtistReleaseGroups, } from "./apiClients.js"; +import { websocketService } from "./websocketService.js"; const LIDARR_RETRY_MS = 60000; const TRACKS_CACHE_TTL_MS = 120000; const TRACKS_CACHE_MAX = 300; -const ACTIVITY_POLL_INTERVAL_MS = 15000; +const ACTIVITY_POLL_INTERVAL_MS = 60000; +const COMMANDS_CACHE_MS = 60000; +const SIGNALR_ACTIVITY_POLL_MS = 300000; let lidarrClient = null; let _cachedArtists = []; let _cachedAlbums = []; let _cachedQueue = null; let _cachedHistory = null; +let _cachedCommands = null; +let _lastCommandsAt = 0; let _lastLidarrFailureAt = 0; let _retryTimeoutId = null; let _activityPollIntervalId = null; +let _lastQueueSignature = null; +let _lastHistorySignature = null; +let _lastSignalrActivityAt = 0; const _tracksCache = new Map(); const selectAllArtistsStmt = db.prepare( @@ -1178,18 +1186,79 @@ export class LibraryManager { lidarr.getHistory(1, 200).catch(() => ({ records: [] })), ]); - _cachedQueue = Array.isArray(queue) ? queue : queue?.records || []; - _cachedHistory = history || { records: [] }; - } catch (error) { - // Silent fail for background polling + const nextQueue = Array.isArray(queue) ? queue : queue?.records || []; + const nextHistory = history || { records: [] }; + const historyRecords = Array.isArray(nextHistory) + ? nextHistory + : nextHistory?.records || []; + + const queueSignature = nextQueue + .map((item) => + [ + item?.id ?? item?.downloadId ?? "", + item?.status ?? "", + item?.trackedDownloadState ?? "", + item?.trackedDownloadStatus ?? "", + item?.size ?? "", + item?.sizeleft ?? "", + ].join("|"), + ) + .sort() + .join("||"); + const historySignature = historyRecords + .map((item) => + [ + item?.id ?? "", + item?.eventType ?? "", + item?.date ?? item?.eventDate ?? "", + ].join("|"), + ) + .sort() + .join("||"); + + _cachedQueue = nextQueue; + _cachedHistory = nextHistory; + + if ( + queueSignature !== _lastQueueSignature || + historySignature !== _lastHistorySignature + ) { + _lastQueueSignature = queueSignature; + _lastHistorySignature = historySignature; + websocketService.emitQueueUpdate({ + queueCount: nextQueue.length, + historyCount: historyRecords.length, + }); + } + } catch (error) {} + } + + async refreshActivityFromSignalR() { + _lastSignalrActivityAt = Date.now(); + await this.refreshActivity(); + } + + async refreshActivityIfNeeded() { + let signalrConnected = false; + try { + const { lidarrSignalRService } = await import("./lidarrClient.js"); + signalrConnected = !!lidarrSignalRService?.isConnected?.(); + } catch {} + if (signalrConnected) { + const now = Date.now(); + if (_lastSignalrActivityAt && now - _lastSignalrActivityAt < SIGNALR_ACTIVITY_POLL_MS) { + return; + } + _lastSignalrActivityAt = now; } + await this.refreshActivity(); } startActivityPolling() { if (_activityPollIntervalId) return; this.refreshActivity(); // Initial fetch _activityPollIntervalId = setInterval(() => { - this.refreshActivity(); + this.refreshActivityIfNeeded(); }, ACTIVITY_POLL_INTERVAL_MS); console.log("[LibraryManager] Started background activity polling"); } @@ -1215,6 +1284,67 @@ export class LibraryManager { return _cachedHistory || { records: [] }; } + async refreshCommands(force = false) { + const lidarr = await getLidarrClient(); + if (!lidarr || !lidarr.isConfigured()) return; + const now = Date.now(); + if ( + !force && + _cachedCommands && + now - _lastCommandsAt < COMMANDS_CACHE_MS + ) { + return; + } + try { + const commands = await lidarr.request("/command").catch(() => []); + _cachedCommands = Array.isArray(commands) + ? commands + : commands?.records || []; + _lastCommandsAt = now; + } catch (error) {} + } + + updateCommandCacheFromSignalR(payload) { + if (!payload) return; + const command = + payload?.resource || payload?.data || payload?.item || payload; + if (!command) return; + const commandId = command?.id ?? command?.commandId ?? null; + if (_cachedCommands === null) { + _cachedCommands = []; + } + if (commandId != null) { + const index = _cachedCommands.findIndex( + (item) => + item?.id === commandId || + item?.commandId === commandId || + item?.id === String(commandId), + ); + if (index >= 0) { + _cachedCommands[index] = { + ..._cachedCommands[index], + ...command, + }; + } else { + _cachedCommands.unshift(command); + } + } else { + _cachedCommands.unshift(command); + } + _lastCommandsAt = Date.now(); + } + + async getCommands({ force = false } = {}) { + if (_cachedCommands === null) { + await this.refreshCommands(true); + } else if (force) { + await this.refreshCommands(true); + } else { + await this.refreshCommands(false); + } + return _cachedCommands || []; + } + async syncArtistFromLidarr(idOrMbid) { const lidarr = await getLidarrClient(); if (!lidarr || !lidarr.isConfigured()) return null; diff --git a/backend/services/lidarrClient.js b/backend/services/lidarrClient.js index da7464d..61fcc1f 100644 --- a/backend/services/lidarrClient.js +++ b/backend/services/lidarrClient.js @@ -3,6 +3,7 @@ import http from "http"; import https from "https"; import { HubConnectionBuilder, + HubConnectionState, HttpTransportType, LogLevel, } from "@microsoft/signalr"; @@ -222,15 +223,16 @@ export class LidarrClient { }, timeout: this.config.timeoutMs, httpAgent: this._httpAgent, - httpsAgent: isHttps && this.config.insecure - ? new https.Agent({ - rejectUnauthorized: false, - keepAlive: true, - maxSockets: LIDARR_MAX_CONCURRENT, - maxFreeSockets: 2, - timeout: 60000, - }) - : this._httpsAgent, + httpsAgent: + isHttps && this.config.insecure + ? new https.Agent({ + rejectUnauthorized: false, + keepAlive: true, + maxSockets: LIDARR_MAX_CONCURRENT, + maxFreeSockets: 2, + timeout: 60000, + }) + : this._httpsAgent, validateStatus: function (status) { return status < 500; }, @@ -823,6 +825,7 @@ class LidarrSignalRService { this.lastConfig = null; this.checkTimer = null; this.reconnectTimer = null; + this.signalrDisabledSignature = null; } start() { @@ -848,11 +851,18 @@ class LidarrSignalRService { await this.stopConnection(); } + isConnected() { + return ( + this.connection && + this.connection.state === HubConnectionState.Connected + ); + } + getSignalRUrl(config) { const base = String(config.url || "").replace(/\/+$/, ""); const apiKey = String(config.apiKey || ""); - if (!apiKey) return `${base}/signalr`; - return `${base}/signalr?apikey=${encodeURIComponent(apiKey)}`; + if (!apiKey) return `${base}/signalr/messages`; + return `${base}/signalr/messages?apikey=${encodeURIComponent(apiKey)}`; } getConfigSignature(config) { @@ -874,6 +884,11 @@ class LidarrSignalRService { return; } const signature = this.getConfigSignature(config); + if (this.signalrDisabledSignature === signature) { + await this.stopConnection(); + this.lastConfig = null; + return; + } if (this.connection && this.lastConfig === signature) { return; } @@ -888,8 +903,11 @@ class LidarrSignalRService { headers: { "X-Api-Key": config.apiKey, }, - transport: HttpTransportType.WebSockets, - skipNegotiation: true, + accessTokenFactory: () => config.apiKey, + transport: + HttpTransportType.WebSockets | + HttpTransportType.ServerSentEvents | + HttpTransportType.LongPolling, }) .withAutomaticReconnect([0, 2000, 5000, 15000]) .configureLogging(LogLevel.Warning) @@ -899,10 +917,10 @@ class LidarrSignalRService { this.handlePayload(payload); }; - connection.on("receiveNotification", handler); - connection.on("ReceiveNotification", handler); connection.on("receiveMessage", handler); connection.on("ReceiveMessage", handler); + connection.on("receiveNotification", handler); + connection.on("ReceiveNotification", handler); connection.on("receiveEvent", handler); connection.on("ReceiveEvent", handler); connection.on("event", handler); @@ -922,7 +940,16 @@ class LidarrSignalRService { this.connection = connection; this.lastConfig = this.getConfigSignature(config); console.log("[Lidarr SignalR] Connected"); + try { + const { libraryManager } = await import("./libraryManager.js"); + await libraryManager.refreshCommands(true); + await libraryManager.refreshActivityFromSignalR(); + } catch {} } catch (error) { + const message = String(error?.message || error || ""); + if (message.includes("405") || message.includes("Method Not Allowed")) { + this.signalrDisabledSignature = this.getConfigSignature(config); + } console.warn( "[Lidarr SignalR] Connection failed:", error?.message || error, @@ -950,62 +977,115 @@ class LidarrSignalRService { async handlePayload(payload) { const items = Array.isArray(payload) ? payload : [payload]; for (const item of items) { + if (item?.name && item?.body !== undefined) { + if (Array.isArray(item.body)) { + for (const bodyItem of item.body) { + await this.processEvent({ + __messageName: item.name, + __messageBody: bodyItem, + }); + } + } else { + await this.processEvent({ + __messageName: item.name, + __messageBody: item.body, + }); + } + continue; + } await this.processEvent(item); } } parseEvent(payload) { - const base = payload?.resource || payload?.data || payload?.item || payload; + const basePayload = payload?.__messageBody ?? payload; + const base = + basePayload?.resource || + basePayload?.data || + basePayload?.item || + basePayload; const resourceType = String( payload?.resourceType || + payload?.__messageName || base?.resourceType || + basePayload?.resourceType || payload?.type || + basePayload?.type || base?.type || "", ).toLowerCase(); const eventType = String( payload?.eventType || + payload?.__messageName || payload?.name || payload?.event || payload?.type || + basePayload?.eventType || + basePayload?.event || + basePayload?.type || base?.eventType || base?.event || base?.type || "", ).toLowerCase(); const artistId = - base?.artistId ?? base?.artist?.id ?? payload?.artistId ?? null; + base?.artistId ?? + base?.artist?.id ?? + basePayload?.artistId ?? + payload?.artistId ?? + null; const artistMbid = base?.foreignArtistId || base?.artist?.foreignArtistId || base?.artist?.mbid || + basePayload?.foreignArtistId || + basePayload?.artistMbid || payload?.artistMbid || payload?.foreignArtistId || null; const albumId = base?.albumId ?? base?.album?.id ?? + basePayload?.albumId ?? payload?.albumId ?? (resourceType === "album" ? base?.id : null); const albumMbid = base?.foreignAlbumId || base?.album?.foreignAlbumId || + basePayload?.foreignAlbumId || + basePayload?.albumMbid || payload?.albumMbid || payload?.foreignAlbumId || null; - return { resourceType, eventType, artistId, artistMbid, albumId, albumMbid }; + return { + resourceType, + eventType, + messageName: String(payload?.__messageName || "").toLowerCase(), + artistId, + artistMbid, + albumId, + albumMbid, + }; } async processEvent(payload) { const parsed = this.parseEvent(payload); const resourceType = parsed.resourceType; const eventType = parsed.eventType; + const messageName = parsed.messageName; let artistId = parsed.artistId; let artistMbid = parsed.artistMbid; let albumId = parsed.albumId; let albumMbid = parsed.albumMbid; - if (!resourceType && !eventType && !artistId && !albumId && !albumMbid) { + if ( + !resourceType && + !eventType && + !messageName && + !artistId && + !albumId && + !albumMbid + ) { return; } @@ -1027,10 +1107,22 @@ class LidarrSignalRService { resourceType.includes("track") || eventType.includes("track") || eventType.includes("import"); + const relatesToQueue = + resourceType.includes("queue") || + eventType.includes("queue") || + messageName.includes("queue") || + messageName.includes("wanted") || + messageName.includes("command"); + const relatesToCommand = + resourceType.includes("command") || messageName.includes("command"); const { libraryManager } = await import("./libraryManager.js"); const { websocketService } = await import("./websocketService.js"); + if (relatesToCommand) { + libraryManager.updateCommandCacheFromSignalR(payload?.__messageBody ?? payload); + } + if (isDelete) { if (relatesToAlbum) { if (albumId != null) { @@ -1050,6 +1142,7 @@ class LidarrSignalRService { action: "delete", resourceType, eventType, + messageName, artistId, artistMbid, albumId, @@ -1071,12 +1164,12 @@ class LidarrSignalRService { action: "album_update", resourceType, eventType, + messageName, artistId, artistMbid, albumId, albumMbid, }); - return; } } @@ -1088,10 +1181,15 @@ class LidarrSignalRService { action: "artist_update", resourceType, eventType, + messageName, artistId: updated?.id || artistId, artistMbid: updated?.foreignArtistId || artistMbid, }); } + + if (relatesToQueue || relatesToAlbum || relatesToTracks) { + await libraryManager.refreshActivityFromSignalR(); + } } } diff --git a/frontend/src/pages/DiscoverPage.jsx b/frontend/src/pages/DiscoverPage.jsx index 50ee8b8..867eef9 100644 --- a/frontend/src/pages/DiscoverPage.jsx +++ b/frontend/src/pages/DiscoverPage.jsx @@ -14,7 +14,6 @@ import { import { getDiscovery, getRecentlyAdded, - getAllDownloadStatus, getRecentReleases, getReleaseGroupCover, getArtistCover, @@ -274,7 +273,6 @@ function DiscoverPage() { const [draggingId, setDraggingId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const [error, setError] = useState(null); - const downloadStatusesRef = useRef({}); const requestedReleaseCoversRef = useRef(new Set()); const requestedArtistCoversRef = useRef(new Set()); const navigate = useNavigate(); @@ -324,36 +322,6 @@ function DiscoverPage() { .then(setRecentReleases) .catch(() => {}); - const pollDownloadStatus = async () => { - try { - const statuses = await getAllDownloadStatus(); - const prev = downloadStatusesRef.current; - const prevKeys = Object.keys(prev).sort().join(","); - const newKeys = Object.keys(statuses).sort().join(","); - - if (prevKeys !== newKeys) { - downloadStatusesRef.current = statuses; - return; - } - - let hasChanges = false; - for (const key in statuses) { - if (prev[key] !== statuses[key]) { - hasChanges = true; - break; - } - } - - if (hasChanges) { - downloadStatusesRef.current = statuses; - } - } catch {} - }; - - pollDownloadStatus(); - const interval = setInterval(pollDownloadStatus, 15000); - - return () => clearInterval(interval); }, []); useEffect(() => { diff --git a/frontend/src/pages/RequestsPage.jsx b/frontend/src/pages/RequestsPage.jsx index a28b675..5e37e17 100644 --- a/frontend/src/pages/RequestsPage.jsx +++ b/frontend/src/pages/RequestsPage.jsx @@ -17,6 +17,7 @@ import { } from "../utils/api"; import ArtistImage from "../components/ArtistImage"; import { useToast } from "../contexts/ToastContext"; +import { useWebSocketChannel } from "../hooks/useWebSocket"; function RequestsPage() { const [requests, setRequests] = useState([]); @@ -27,6 +28,10 @@ function RequestsPage() { const navigate = useNavigate(); const { showError, showSuccess } = useToast(); const activeAlbumIdsRef = useRef([]); + const requestsRef = useRef([]); + const downloadStatusesRef = useRef({}); + const refreshTimerRef = useRef(null); + const cacheKeyRef = useRef("aurral:requestsCache:v1"); const activeAlbumIds = useMemo(() => { return requests @@ -46,6 +51,38 @@ function RequestsPage() { return [...activeAlbumIds].sort().join(","); }, [activeAlbumIds]); + const writeCache = useCallback((nextRequests, nextStatuses) => { + try { + localStorage.setItem( + cacheKeyRef.current, + JSON.stringify({ + requests: Array.isArray(nextRequests) ? nextRequests : [], + downloadStatuses: + nextStatuses && typeof nextStatuses === "object" + ? nextStatuses + : {}, + updatedAt: Date.now(), + }), + ); + } catch {} + }, []); + + const loadCache = useCallback(() => { + try { + const raw = localStorage.getItem(cacheKeyRef.current); + if (!raw) return; + const cached = JSON.parse(raw); + if (Array.isArray(cached?.requests)) { + setRequests(cached.requests); + setLoading(false); + } + if (cached?.downloadStatuses && typeof cached.downloadStatuses === "object") { + setDownloadStatuses(cached.downloadStatuses); + downloadStatusesRef.current = cached.downloadStatuses; + } + } catch {} + }, []); + const fetchRequests = useCallback(async ({ silent = false } = {}) => { if (!silent) { setLoading(true); @@ -54,6 +91,7 @@ function RequestsPage() { try { const data = await getRequests(); setRequests(data); + writeCache(data, downloadStatusesRef.current); setError(null); } catch { setError("Failed to load requests history."); @@ -62,7 +100,7 @@ function RequestsPage() { setLoading(false); } } - }, []); + }, [writeCache]); const fetchActiveDownloadStatus = useCallback(async (albumIds) => { const ids = Array.isArray(albumIds) @@ -70,18 +108,69 @@ function RequestsPage() { : activeAlbumIdsRef.current; if (!ids.length) { setDownloadStatuses({}); + downloadStatusesRef.current = {}; + writeCache(requestsRef.current, {}); return; } try { const statuses = await getDownloadStatus(ids); - setDownloadStatuses(statuses || {}); + const nextStatuses = statuses || {}; + setDownloadStatuses(nextStatuses); + downloadStatusesRef.current = nextStatuses; + writeCache(requestsRef.current, nextStatuses); } catch {} - }, []); + }, [writeCache]); + + const scheduleRefresh = useCallback( + (refreshStatuses = false) => { + if (refreshTimerRef.current) return; + refreshTimerRef.current = setTimeout(() => { + refreshTimerRef.current = null; + fetchRequests({ silent: true }); + if (refreshStatuses) { + fetchActiveDownloadStatus(); + } + }, 500); + }, + [fetchRequests, fetchActiveDownloadStatus], + ); useEffect(() => { activeAlbumIdsRef.current = activeAlbumIds; }, [activeAlbumIds]); + useEffect(() => { + requestsRef.current = requests; + }, [requests]); + + useEffect(() => { + loadCache(); + return () => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + }, [loadCache]); + + useWebSocketChannel("queue", (msg) => { + if (msg.type === "queue_update") { + scheduleRefresh(true); + } + }); + + useWebSocketChannel("library", (msg) => { + if (msg.type === "library_update") { + scheduleRefresh(true); + } + }); + + useWebSocketChannel("downloads", (msg) => { + if (msg.type && msg.type.startsWith("download_")) { + scheduleRefresh(true); + } + }); + useEffect(() => { fetchRequests(); @@ -124,7 +213,7 @@ function RequestsPage() { }; pollDownloadStatus(); - const interval = setInterval(pollDownloadStatus, 15000); + const interval = setInterval(pollDownloadStatus, 60000); return () => { cancelled = true; clearInterval(interval); @@ -137,7 +226,7 @@ function RequestsPage() { request.inQueue || (request.status && request.status !== "available" && request.status !== "failed"), ); - const intervalMs = hasActive ? 15000 : 60000; + const intervalMs = hasActive ? 60000 : 180000; const interval = setInterval(() => { fetchRequests({ silent: true }); }, intervalMs); diff --git a/server.js b/server.js index e7a5c48..9c12f0d 100644 --- a/server.js +++ b/server.js @@ -15,6 +15,8 @@ import { getDiscoveryCache, } from "./backend/services/discoveryService.js"; import { websocketService } from "./backend/services/websocketService.js"; +import { lidarrSignalRService } from "./backend/services/lidarrClient.js"; +import { libraryManager } from "./backend/services/libraryManager.js"; import settingsRouter from "./backend/routes/settings.js"; import onboardingRouter from "./backend/routes/onboarding.js"; @@ -184,6 +186,9 @@ websocketService.initialize(httpServer); httpServer.listen(PORT, "0.0.0.0", async () => { console.log(`Server running on port ${PORT}`); + console.log(`WebSocket available at ws://localhost:${PORT}/ws`); + lidarrSignalRService.start(); + libraryManager.startActivityPolling(); }); httpServer.on("error", (error) => { From e41ca7757c35ea3ddbc6ca3b07fcd085bc6b2ef4 Mon Sep 17 00:00:00 2001 From: lklynet Date: Wed, 25 Feb 2026 14:02:10 -0500 Subject: [PATCH 03/11] feat(artist-details): add library status indicator to similar artists Add a cache for library lookup results to avoid redundant network requests. The similar artists section now displays a checkmark icon for artists already in the user's library. This provides immediate visual feedback and improves the user experience by reducing perceived latency. --- .../components/ArtistDetailsSimilar.jsx | 106 +++++++++++++----- frontend/src/utils/api.js | 24 +++- 2 files changed, 99 insertions(+), 31 deletions(-) diff --git a/frontend/src/pages/ArtistDetails/components/ArtistDetailsSimilar.jsx b/frontend/src/pages/ArtistDetails/components/ArtistDetailsSimilar.jsx index 375fdda..531654f 100644 --- a/frontend/src/pages/ArtistDetails/components/ArtistDetailsSimilar.jsx +++ b/frontend/src/pages/ArtistDetails/components/ArtistDetailsSimilar.jsx @@ -1,6 +1,14 @@ +import { useEffect, useMemo, useState } from "react"; import PropTypes from "prop-types"; -import { Loader, ChevronLeft, ChevronRight } from "lucide-react"; +import { Loader, ChevronLeft, ChevronRight, CheckCircle2 } from "lucide-react"; import ArtistImage from "../../../components/ArtistImage"; +import { + lookupArtistsInLibraryBatch, + readLibraryLookupCache, +} from "../../../utils/api"; + +const getArtistId = (artist) => + artist?.id || artist?.mbid || artist?.foreignArtistId; export function ArtistDetailsSimilar({ loadingSimilar, @@ -8,6 +16,36 @@ export function ArtistDetailsSimilar({ similarArtistsScrollRef, onArtistClick, }) { + const [libraryLookup, setLibraryLookup] = useState({}); + const artistIds = useMemo( + () => similarArtists.map(getArtistId).filter(Boolean), + [similarArtists], + ); + + useEffect(() => { + const cached = readLibraryLookupCache(artistIds); + setLibraryLookup(cached); + const missing = artistIds.filter((id) => cached[id] === undefined); + if (missing.length === 0) return; + let cancelled = false; + const fetchLookup = async () => { + try { + const lookup = await lookupArtistsInLibraryBatch(missing); + if (!cancelled && lookup) { + setLibraryLookup((prev) => ({ ...prev, ...lookup })); + } + } catch { + if (!cancelled) { + setLibraryLookup((prev) => ({ ...prev })); + } + } + }; + fetchLookup(); + return () => { + cancelled = true; + }; + }, [artistIds]); + if (!loadingSimilar && similarArtists.length === 0) return null; return ( @@ -59,40 +97,48 @@ export function ArtistDetailsSimilar({ msOverflowStyle: "none", }} > - {similarArtists.map((similar) => ( -
onArtistClick(similar.id, similar.name)} - > + {similarArtists.map((similar) => { + const artistId = getArtistId(similar); + return (
onArtistClick(similar.id, similar.name)} > - +
+ -
+
- {similar.match && ( -
- {similar.match}% Match -
- )} + {similar.match && ( +
+ {similar.match}% Match +
+ )} +
+
+

+ {similar.name} +

+ {artistId && libraryLookup[artistId] && ( + + )} +
-

- {similar.name} -

-
- ))} + ); + })}