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..49661ec 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",
@@ -22,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"
@@ -60,6 +62,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 +148,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",
@@ -408,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",
@@ -572,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",
@@ -749,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",
@@ -764,6 +893,34 @@
"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",
+ "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 +1000,25 @@
"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",
+ "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",
@@ -1368,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",
@@ -1636,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",
@@ -1686,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",
@@ -1902,6 +2101,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 +2130,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 +2154,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 +2226,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 +2337,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",
@@ -2189,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",
@@ -2470,6 +2721,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",
@@ -2488,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",
@@ -2510,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",
@@ -2529,6 +2810,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 +2828,29 @@
"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/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",
@@ -2581,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",
@@ -2633,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 d63d426..ce754fa 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -21,6 +21,7 @@
]
},
"dependencies": {
+ "@microsoft/signalr": "^8.0.7",
"axios": "^1.7.9",
"bcrypt": "^5.1.1",
"better-sqlite3": "^12.6.2",
@@ -34,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/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/health.js b/backend/routes/health.js
index 50f79a7..ce90c8b 100644
--- a/backend/routes/health.js
+++ b/backend/routes/health.js
@@ -8,7 +8,14 @@ import {
isProxyAuthEnabled,
} from "../middleware/auth.js";
import { getDiscoveryCache } from "../services/discoveryService.js";
-import { getCachedArtistCount } from "../services/libraryManager.js";
+import {
+ getCachedArtistCount,
+ getCachedAlbumCount,
+ getCachedTrackCount,
+ getTracksCacheSize,
+ getLastSignalrActivityAt,
+ getLastFullSyncAt,
+} from "../services/libraryManager.js";
import { lidarrClient } from "../services/lidarrClient.js";
import { dbOps } from "../config/db-helpers.js";
import { userOps } from "../config/db-helpers.js";
@@ -45,6 +52,11 @@ router.get("/", noCache, async (req, res) => {
const discoveryCache = getDiscoveryCache();
const wsStats = websocketService.getStats();
const artistCount = getCachedArtistCount();
+ const albumCount = getCachedAlbumCount();
+ const trackCount = getCachedTrackCount();
+ const tracksCacheSize = getTracksCacheSize();
+ const lastFullSyncAt = getLastFullSyncAt();
+ const lastSignalrActivityAt = getLastSignalrActivityAt();
const currentUser = resolveRequestUser(req);
const payload = {
@@ -58,6 +70,12 @@ router.get("/", noCache, async (req, res) => {
),
library: {
artistCount: typeof artistCount === "number" ? artistCount : 0,
+ albumCount: typeof albumCount === "number" ? albumCount : 0,
+ trackCount: typeof trackCount === "number" ? trackCount : 0,
+ tracksCacheSize:
+ typeof tracksCacheSize === "number" ? tracksCacheSize : 0,
+ lastFullSyncAt,
+ lastSignalrActivityAt,
lastScan: null,
},
discovery: {
diff --git a/backend/routes/library/handlers/albums.js b/backend/routes/library/handlers/albums.js
index 9a1fa28..3f45bda 100644
--- a/backend/routes/library/handlers/albums.js
+++ b/backend/routes/library/handlers/albums.js
@@ -1,7 +1,8 @@
-import { libraryManager } from "../../../services/libraryManager.js";
+import { libraryManager, getCacheStats } from "../../../services/libraryManager.js";
import { playlistManager } from "../../../services/weeklyFlowPlaylistManager.js";
import { dbOps } from "../../../config/db-helpers.js";
import { cacheMiddleware } from "../../../middleware/cache.js";
+import { websocketService } from "../../../services/websocketService.js";
import {
requireAuth,
requirePermission,
@@ -90,6 +91,12 @@ export default function registerAlbums(router) {
title: album.albumName,
albumType: "Album",
};
+ websocketService.emitLibraryUpdate("album_add", {
+ albumId: album.id,
+ albumMbid: album.mbid,
+ albumName: album.albumName,
+ artistId: album.artistId,
+ });
res.status(201).json(formatted);
} catch (error) {
res.status(500).json({
@@ -123,6 +130,13 @@ export default function registerAlbums(router) {
if (album?.error) {
return res.status(503).json({ error: album.error });
}
+ websocketService.emitLibraryUpdate("album_update", {
+ albumId: album.id,
+ albumMbid: album.mbid,
+ albumName: album.albumName,
+ artistId: album.artistId,
+ cache: getCacheStats(),
+ });
res.json(album);
} catch (error) {
res.status(500).json({
@@ -140,6 +154,7 @@ export default function registerAlbums(router) {
try {
const { id } = req.params;
const { deleteFiles = false } = req.query;
+ const existingAlbum = await libraryManager.getAlbumById(id);
const result = await libraryManager.deleteAlbum(
id,
deleteFiles === "true"
@@ -149,6 +164,13 @@ export default function registerAlbums(router) {
.status(503)
.json({ error: result?.error || "Failed to delete album" });
}
+ websocketService.emitLibraryUpdate("album_delete", {
+ albumId: existingAlbum?.id || id,
+ albumMbid: existingAlbum?.mbid,
+ albumName: existingAlbum?.albumName,
+ artistId: existingAlbum?.artistId,
+ cache: getCacheStats(),
+ });
res.json({ success: true, message: "Album deleted successfully" });
} catch (error) {
res.status(500).json({
diff --git a/backend/routes/library/handlers/artists.js b/backend/routes/library/handlers/artists.js
index 7cce55f..f65af86 100644
--- a/backend/routes/library/handlers/artists.js
+++ b/backend/routes/library/handlers/artists.js
@@ -1,8 +1,12 @@
import { UUID_REGEX } from "../../../config/constants.js";
-import { libraryManager } from "../../../services/libraryManager.js";
+import {
+ libraryManager,
+ getCacheStats,
+} from "../../../services/libraryManager.js";
import { musicbrainzGetArtistReleaseGroups } from "../../../services/apiClients.js";
import { dbOps } from "../../../config/db-helpers.js";
-import { cacheMiddleware } from "../../../middleware/cache.js";
+import { cacheMiddleware, noCache } from "../../../middleware/cache.js";
+import { websocketService } from "../../../services/websocketService.js";
import {
requireAuth,
requirePermission,
@@ -154,14 +158,16 @@ const monitorArtistAlbums = async (artist, albums, lidarrClient) => {
};
export default function registerArtists(router) {
- router.get("/artists", cacheMiddleware(120), async (req, res) => {
+ router.get("/artists", noCache, async (req, res) => {
try {
const artists = await libraryManager.getAllArtists();
- const formatted = artists.map((artist) => ({
- ...artist,
- foreignArtistId: artist.foreignArtistId || artist.mbid,
- added: artist.addedAt,
- }));
+ const formatted = artists
+ .map((artist) => ({
+ ...artist,
+ foreignArtistId: artist.foreignArtistId || artist.mbid,
+ added: artist.addedAt,
+ }))
+ .filter((artist) => artist.foreignArtistId);
res.json(formatted);
} catch (error) {
res.status(500).json({
@@ -255,6 +261,13 @@ export default function registerArtists(router) {
}
await monitorArtistAlbums(artist, albums, lidarrClient);
}
+ const artistMbid = artist.foreignArtistId || artist.mbid || mbid;
+ websocketService.emitLibraryUpdate("artist_add", {
+ artistId: artist.id,
+ artistMbid,
+ artistName: artist.artistName,
+ cache: getCacheStats(),
+ });
})();
} catch (error) {
res.status(500).json({
@@ -295,6 +308,13 @@ export default function registerArtists(router) {
await monitorArtistAlbums(artist, albums, lidarrClient);
}
}
+ const artistMbid = artist.foreignArtistId || artist.mbid || mbid;
+ websocketService.emitLibraryUpdate("artist_update", {
+ artistId: artist.id,
+ artistMbid,
+ artistName: artist.artistName,
+ cache: getCacheStats(),
+ });
res.json(artist);
} catch (error) {
res.status(500).json({
@@ -318,6 +338,7 @@ export default function registerArtists(router) {
return res.status(400).json({ error: "Invalid MBID format" });
}
+ const existingArtist = await libraryManager.getArtist(mbid);
const result = await libraryManager.deleteArtist(
mbid,
deleteFiles === "true",
@@ -326,6 +347,12 @@ export default function registerArtists(router) {
const message = result?.error || "Failed to delete artist";
return res.status(503).json({ error: message, message });
}
+ websocketService.emitLibraryUpdate("artist_delete", {
+ artistId: existingArtist?.id,
+ artistMbid: existingArtist?.foreignArtistId || existingArtist?.mbid || mbid,
+ artistName: existingArtist?.artistName,
+ cache: getCacheStats(),
+ });
res.json({ success: true, message: "Artist deleted successfully" });
} catch (error) {
res.status(500).json({
@@ -377,6 +404,12 @@ export default function registerArtists(router) {
}
}
+ websocketService.emitLibraryUpdate("artist_refresh", {
+ artistId: artist.id,
+ artistMbid: artist.foreignArtistId || artist.mbid || mbid,
+ artistName: artist.artistName,
+ cache: getCacheStats(),
+ });
res.json({
success: true,
message: "Artist refreshed successfully",
diff --git a/backend/routes/library/handlers/downloads.js b/backend/routes/library/handlers/downloads.js
index f425aa8..958d2d4 100644
--- a/backend/routes/library/handlers/downloads.js
+++ b/backend/routes/library/handlers/downloads.js
@@ -173,12 +173,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) => ({
@@ -219,13 +222,15 @@ 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),
- lidarrClient.request("/command").catch(() => []),
+ libraryManager.getQueue().catch(() => []),
+ libraryManager.getHistory().catch(() => ({ records: [] })),
+ libraryManager.getCommands().catch(() => []),
]);
const queueItems = Array.isArray(queue) ? queue : queue.records || [];
const historyItems = Array.isArray(history)
@@ -440,15 +445,17 @@ 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"),
- lidarrClient.request("/command").catch(() => []),
+ libraryManager.getQueue().catch(() => []),
+ libraryManager.getHistory().catch(() => ({ records: [] })),
+ libraryManager.getAllAlbums().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/routes/requests.js b/backend/routes/requests.js
index 4eee6e9..16c4c93 100644
--- a/backend/routes/requests.js
+++ b/backend/routes/requests.js
@@ -22,6 +22,7 @@ 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([]);
@@ -33,8 +34,8 @@ router.get("/", noCache, async (req, res) => {
}
const [queue, history] = await Promise.all([
- lidarrClient.getQueue().catch(() => []),
- lidarrClient.getHistory(1, 200).catch(() => ({ records: [] })),
+ libraryManager.getQueue().catch(() => []),
+ libraryManager.getHistory().catch(() => ({ records: [] })),
]);
const requestsByAlbumId = new Map();
@@ -53,24 +54,27 @@ router.get("/", noCache, async (req, res) => {
const albumName = item?.album?.title || item?.title || "Album";
const artistName = item?.artist?.artistName || "Artist";
-
- let artistMbid = null;
-
- artistMbid = item?.artist?.foreignArtistId || null;
+ let artistMbid = item?.artist?.foreignArtistId || null;
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") ||
@@ -81,7 +85,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), {
@@ -130,15 +134,14 @@ router.get("/", noCache, async (req, res) => {
const albumName = record?.album?.title || record?.sourceTitle || "Album";
const artistName = record?.artist?.artistName || "Artist";
-
- let artistMbid = null;
-
- artistMbid = record?.artist?.foreignArtistId || null;
+ let artistMbid = record?.artist?.foreignArtistId || null;
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();
@@ -156,11 +159,11 @@ router.get("/", noCache, async (req, res) => {
errorMessage.includes("error") ||
sourceTitle.includes("fail") ||
dataString.includes("fail");
-
+
const isFailedImport =
eventType === "albumimportincomplete" ||
eventType.includes("incomplete") ||
- statusMessages.includes("fail") ||
+ statusMessages.includes("fail") ||
statusMessages.includes("error") ||
statusMessages.includes("import fail") ||
statusMessages.includes("incomplete") ||
@@ -168,8 +171,11 @@ 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 isStaleGrabbed =
isGrabbed && !hasQueue && Date.now() - recordTime > STALE_GRABBED_MS;
const status = hasQueue
@@ -242,7 +248,7 @@ router.get("/", noCache, async (req, res) => {
if (missingAlbumIds.size > 0) {
const albumIds = Array.from(missingAlbumIds);
const albums = await Promise.all(
- albumIds.map((id) => lidarrClient.getAlbum(id).catch(() => null)),
+ albumIds.map((id) => libraryManager.getAlbumById(id).catch(() => null)),
);
for (let i = 0; i < albumIds.length; i++) {
if (albums[i]) {
@@ -257,7 +263,7 @@ router.get("/", noCache, async (req, res) => {
if (missingArtistIds.size > 0) {
const artistIds = Array.from(missingArtistIds);
const artists = await Promise.all(
- artistIds.map((id) => lidarrClient.getArtist(id).catch(() => null)),
+ artistIds.map((id) => libraryManager.getArtistById(id).catch(() => null)),
);
for (let i = 0; i < artistIds.length; i++) {
if (artists[i]) {
@@ -269,30 +275,42 @@ router.get("/", noCache, async (req, res) => {
if (albumDetailsById.size > 0 || artistDetailsById.size > 0) {
sorted = sorted.map((request) => {
const enriched = { ...request };
- if (enriched.albumId && albumDetailsById.has(String(enriched.albumId))) {
+ if (
+ enriched.albumId &&
+ albumDetailsById.has(String(enriched.albumId))
+ ) {
const album = albumDetailsById.get(String(enriched.albumId));
if (album) {
- if (!enriched.albumMbid && album.foreignAlbumId) {
- enriched.albumMbid = album.foreignAlbumId;
+ const albumMbid = album.foreignAlbumId || album.mbid;
+ const albumTitle = album.title || album.albumName;
+ if (!enriched.albumMbid && albumMbid) {
+ enriched.albumMbid = albumMbid;
}
- if (isPlaceholder(enriched.albumName, "Album") && album.title) {
- enriched.albumName = album.title;
- enriched.name = album.title;
+ if (isPlaceholder(enriched.albumName, "Album") && albumTitle) {
+ enriched.albumName = albumTitle;
+ enriched.name = albumTitle;
}
if (!enriched.artistId && album.artistId != null) {
enriched.artistId = String(album.artistId);
}
}
}
- if (enriched.artistId && artistDetailsById.has(String(enriched.artistId))) {
+ if (
+ enriched.artistId &&
+ artistDetailsById.has(String(enriched.artistId))
+ ) {
const artist = artistDetailsById.get(String(enriched.artistId));
if (artist) {
- if (isPlaceholder(enriched.artistName, "Artist") && artist.artistName) {
+ if (
+ isPlaceholder(enriched.artistName, "Artist") &&
+ artist.artistName
+ ) {
enriched.artistName = artist.artistName;
}
- if (!enriched.artistMbid && artist.foreignArtistId) {
- enriched.artistMbid = artist.foreignArtistId;
- enriched.mbid = artist.foreignArtistId;
+ const artistMbid = artist.foreignArtistId || artist.mbid;
+ if (!enriched.artistMbid && artistMbid) {
+ enriched.artistMbid = artistMbid;
+ enriched.mbid = artistMbid;
}
}
}
@@ -316,8 +334,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);
@@ -347,21 +366,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..f5759f4 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1,113 +1,2 @@
-import "./loadEnv.js";
-
-import express from "express";
-import cors from "cors";
-import rateLimit from "express-rate-limit";
-import helmet from "helmet";
-import { fileURLToPath } from "url";
-import { dirname, join } from "path";
-import { createServer } from "http";
-
-process.on("uncaughtException", (err) => {
- if (err.code === "ERR_STREAM_DESTROYED") {
- console.warn(
- "[Process] Caught stream destroyed error (safe to ignore):",
- err.message
- );
- return;
- }
- console.error("[Process] Uncaught Exception:", err);
-});
-
-process.on("unhandledRejection", (reason, promise) => {
- if (reason?.code === "ERR_STREAM_DESTROYED") {
- console.warn(
- "[Process] Caught stream destroyed rejection (safe to ignore)"
- );
- return;
- }
- console.error("[Process] Unhandled Rejection:", reason);
-});
-
-import { createAuthMiddleware } from "./middleware/auth.js";
-import {
- updateDiscoveryCache,
- getDiscoveryCache,
-} from "./services/discoveryService.js";
-import { websocketService } from "./services/websocketService.js";
-
-import settingsRouter from "./routes/settings.js";
-import onboardingRouter from "./routes/onboarding.js";
-import usersRouter from "./routes/users.js";
-import artistsRouter from "./routes/artists.js";
-import libraryRouter from "./routes/library.js";
-import discoveryRouter from "./routes/discovery.js";
-import requestsRouter from "./routes/requests.js";
-import healthRouter from "./routes/health.js";
-import weeklyFlowRouter from "./routes/weeklyFlow.js";
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
-const app = express();
-const PORT = process.env.PORT || 3001;
-
-const trustProxyValue =
- process.env.TRUST_PROXY === undefined
- ? 1
- : process.env.TRUST_PROXY === "true"
- ? true
- : process.env.TRUST_PROXY === "false"
- ? false
- : Number.isNaN(Number(process.env.TRUST_PROXY))
- ? process.env.TRUST_PROXY
- : Number(process.env.TRUST_PROXY);
-app.set("trust proxy", trustProxyValue);
-
-app.use(cors());
-app.use(helmet());
-app.use(express.json());
-
-app.use(createAuthMiddleware());
-
-const limiter = rateLimit({
- windowMs: 15 * 60 * 1000,
- max: 5000,
-});
-app.use("/api/", limiter);
-
-app.use("/api/settings", settingsRouter);
-app.use("/api/onboarding", onboardingRouter);
-app.use("/api/users", usersRouter);
-app.use("/api/search", artistsRouter);
-app.use("/api/artists", artistsRouter);
-app.use("/api/library", libraryRouter);
-app.use("/api/discover", discoveryRouter);
-app.use("/api/requests", requestsRouter);
-app.use("/api/health", healthRouter);
-app.use("/api/weekly-flow", weeklyFlowRouter);
-
-setInterval(updateDiscoveryCache, 24 * 60 * 60 * 1000);
-
-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;
- if (!lastUpdated || new Date(lastUpdated).getTime() < twentyFourHoursAgo) {
- updateDiscoveryCache();
- } else {
- console.log(
- `Discovery cache is fresh (last updated ${lastUpdated}). Skipping initial update.`
- );
- }
-}, 5000);
-
-const httpServer = createServer(app);
-
-websocketService.initialize(httpServer);
-
-httpServer.listen(PORT, async () => {
- console.log(`Server running on port ${PORT}`);
- console.log(`WebSocket available at ws://localhost:${PORT}/ws`);
-});
+import "../loadEnv.js";
+import "../server.js";
diff --git a/backend/services/libraryManager.js b/backend/services/libraryManager.js
index 0d4427d..0f3306d 100644
--- a/backend/services/libraryManager.js
+++ b/backend/services/libraryManager.js
@@ -1,22 +1,293 @@
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,
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 = 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(
+ "SELECT data FROM lidarr_artists ORDER BY artist_name COLLATE NOCASE",
+);
+const countArtistsStmt = db.prepare(
+ "SELECT COUNT(1) as count FROM lidarr_artists",
+);
+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 countAlbumsStmt = db.prepare(
+ "SELECT COUNT(1) as count FROM lidarr_albums",
+);
+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 countTracksStmt = db.prepare("SELECT COUNT(1) as count FROM lidarr_tracks");
+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 selectPreferredArtist(existing, candidate) {
+ if (!existing) return candidate;
+ if (!candidate) return existing;
+ const existingAdded =
+ Date.parse(existing.addedAt || existing.added || "") || 0;
+ const candidateAdded =
+ Date.parse(candidate.addedAt || candidate.added || "") || 0;
+ if (existingAdded !== candidateAdded) {
+ return candidateAdded > existingAdded ? candidate : existing;
+ }
+ const existingMonitored = existing.monitored ? 1 : 0;
+ const candidateMonitored = candidate.monitored ? 1 : 0;
+ if (existingMonitored !== candidateMonitored) {
+ return candidateMonitored > existingMonitored ? candidate : existing;
+ }
+ const existingStats =
+ (existing.statistics?.albumCount || 0) +
+ (existing.statistics?.trackCount || 0);
+ const candidateStats =
+ (candidate.statistics?.albumCount || 0) +
+ (candidate.statistics?.trackCount || 0);
+ if (existingStats !== candidateStats) {
+ return candidateStats > existingStats ? candidate : existing;
+ }
+ const existingId = Number.parseInt(existing.id, 10);
+ const candidateId = Number.parseInt(candidate.id, 10);
+ if (Number.isFinite(existingId) && Number.isFinite(candidateId)) {
+ return candidateId > existingId ? candidate : existing;
+ }
+ return existing;
+}
+
+function dedupeArtists(artists) {
+ const byKey = new Map();
+ for (const artist of artists) {
+ const key = artist?.foreignArtistId || artist?.mbid || artist?.id;
+ if (!key) continue;
+ const current = byKey.get(String(key)) || null;
+ byKey.set(String(key), selectPreferredArtist(current, artist));
+ }
+ return Array.from(byKey.values());
+}
+
+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 {
@@ -36,7 +307,54 @@ function scheduleLidarrRetry(instance) {
}
export function getCachedArtistCount() {
- return Array.isArray(_cachedArtists) ? _cachedArtists.length : 0;
+ if (Array.isArray(_cachedArtists) && _cachedArtists.length > 0) {
+ return _cachedArtists.length;
+ }
+ const row = countArtistsStmt.get();
+ return typeof row?.count === "number" ? row.count : 0;
+}
+
+export function getCachedAlbumCount() {
+ if (Array.isArray(_cachedAlbums) && _cachedAlbums.length > 0) {
+ return _cachedAlbums.length;
+ }
+ const row = countAlbumsStmt.get();
+ return typeof row?.count === "number" ? row.count : 0;
+}
+
+export function getCachedTrackCount() {
+ const row = countTracksStmt.get();
+ return typeof row?.count === "number" ? row.count : 0;
+}
+
+export function getTracksCacheSize() {
+ return _tracksCache.size;
+}
+
+export function getLastSignalrActivityAt() {
+ return _lastSignalrActivityAt || null;
+}
+
+function readLastFullSyncAt() {
+ const row = selectSyncMetaStmt.get("last_full_sync_at");
+ if (!row?.value) return null;
+ const parsed = Number(row.value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function getLastFullSyncAt() {
+ return readLastFullSyncAt();
+}
+
+export function getCacheStats() {
+ return {
+ artistCount: getCachedArtistCount(),
+ albumCount: getCachedAlbumCount(),
+ trackCount: getCachedTrackCount(),
+ tracksCacheSize: getTracksCacheSize(),
+ lastFullSyncAt: getLastFullSyncAt(),
+ lastSignalrActivityAt: getLastSignalrActivityAt(),
+ };
}
function getSettings() {
@@ -52,7 +370,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, {
@@ -63,7 +383,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}`,
@@ -90,8 +412,7 @@ export class LibraryManager {
const profiles = await lidarr.getMetadataProfiles();
const profile = Array.isArray(profiles)
? profiles.find(
- (item) =>
- String(item?.id) === String(metadataProfileId),
+ (item) => String(item?.id) === String(metadataProfileId),
)
: null;
if (profile?.primaryAlbumTypes) {
@@ -213,13 +534,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;
}
@@ -227,12 +550,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;
}
@@ -240,10 +565,26 @@ export class LibraryManager {
async getAllArtists() {
try {
- const lidarr = await getLidarrClient();
- if (!lidarr || !lidarr.isConfigured()) {
+ if (_cachedArtists.length > 0) {
+ const deduped = dedupeArtists(_cachedArtists);
+ if (deduped.length !== _cachedArtists.length) {
+ replaceAllArtistsTx(deduped);
+ _cachedArtists = deduped;
+ }
return _cachedArtists;
}
+
+ const lidarr = await getLidarrClient();
+ const cachedFromDb = loadCachedArtists();
+ if (cachedFromDb.length > 0) {
+ const deduped = dedupeArtists(cachedFromDb);
+ _cachedArtists = deduped;
+ if (deduped.length !== cachedFromDb.length) {
+ replaceAllArtistsTx(deduped);
+ }
+ return deduped;
+ }
+ if (!lidarr || !lidarr.isConfigured()) return _cachedArtists;
if (
_lastLidarrFailureAt &&
Date.now() - _lastLidarrFailureAt < LIDARR_RETRY_MS
@@ -258,6 +599,7 @@ export class LibraryManager {
return _cachedArtists;
}
_cachedArtists = lidarrArtists.map((a) => this.mapLidarrArtist(a));
+ replaceAllArtistsTx(_cachedArtists);
return _cachedArtists;
} catch (error) {
const wasHealthy = _lastLidarrFailureAt === 0;
@@ -278,6 +620,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 =
@@ -346,9 +712,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}`,
@@ -370,6 +739,8 @@ export class LibraryManager {
console.log(
`[LibraryManager] Deleted artist "${lidarrArtist.artistName}" from Lidarr`,
);
+ this.removeArtistCacheByMbid(mbid);
+ this.removeArtistCacheById(lidarrArtist.id);
return { success: true };
} catch (error) {
console.error(
@@ -393,7 +764,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;
@@ -431,7 +804,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}`,
@@ -442,9 +817,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) {
@@ -456,8 +831,47 @@ 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) {
+ const is404 =
+ error?.response?.status === 404 ||
+ String(error?.message || "").includes("404") ||
+ String(error?.message || "").toLowerCase().includes("not found");
+ if (is404) {
+ const cachedArtist = loadCachedArtistById(artistId);
+ const mbid =
+ cachedArtist?.foreignArtistId || cachedArtist?.mbid || null;
+ if (mbid) {
+ try {
+ const resolved = await lidarr.getArtistByMbid(mbid);
+ if (resolved?.id != null) {
+ const mappedResolved = this.mapLidarrArtist(resolved);
+ upsertArtistCache(mappedResolved);
+ const newArtistId = String(resolved.id);
+ const allAlbums = await lidarr.request(
+ `/album?artistId=${encodeURIComponent(newArtistId)}`,
+ );
+ const artistAlbums = Array.isArray(allAlbums)
+ ? allAlbums.filter((a) => a.artistId === parseInt(newArtistId))
+ : [];
+ const mapped = artistAlbums.map((a) =>
+ this.mapLidarrAlbum(a, resolved),
+ );
+ for (const album of mapped) {
+ upsertAlbumCache(album);
+ }
+ return mapped;
+ }
+ } catch {}
+ }
+ this.removeArtistCacheById(artistId);
+ }
console.error(
`[LibraryManager] Failed to fetch albums from Lidarr: ${error.message}`,
);
@@ -467,9 +881,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;
}
@@ -479,7 +893,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;
@@ -488,6 +904,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 ??
@@ -549,7 +982,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 =
@@ -579,6 +1014,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(
@@ -625,6 +1062,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()) {
@@ -707,6 +1152,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,
@@ -780,12 +1226,405 @@ 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;
+ const normalized = String(id);
+ deleteArtistByIdStmt.run(normalized);
+ deleteAlbumsByArtistIdStmt.run(normalized);
+ deleteTracksByArtistIdStmt.run(normalized);
+ if (Array.isArray(_cachedArtists) && _cachedArtists.length > 0) {
+ _cachedArtists = _cachedArtists.filter(
+ (artist) => String(artist?.id) !== normalized,
+ );
+ }
+ if (Array.isArray(_cachedAlbums) && _cachedAlbums.length > 0) {
+ _cachedAlbums = _cachedAlbums.filter(
+ (album) => String(album?.artistId) !== normalized,
+ );
+ }
+ }
+
+ removeArtistCacheByMbid(mbid) {
+ if (!mbid) return;
+ const cached = loadCachedArtistByMbid(mbid);
+ const normalized = String(mbid);
+ deleteArtistByMbidStmt.run(normalized);
+ if (cached?.id) {
+ deleteAlbumsByArtistIdStmt.run(String(cached.id));
+ deleteTracksByArtistIdStmt.run(String(cached.id));
+ }
+ if (Array.isArray(_cachedArtists) && _cachedArtists.length > 0) {
+ _cachedArtists = _cachedArtists.filter((artist) => {
+ const artistMbid = artist?.foreignArtistId || artist?.mbid;
+ return String(artistMbid || "") !== normalized;
+ });
+ }
+ }
+
+ 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() {
+ return readLastFullSyncAt();
+ }
+
+ 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: [] })),
+ ]);
+
+ 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.refreshActivityIfNeeded();
+ }, 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 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;
+ 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..e115fe1 100644
--- a/backend/services/lidarrClient.js
+++ b/backend/services/lidarrClient.js
@@ -1,14 +1,27 @@
import axios from "axios";
import http from "http";
import https from "https";
+import {
+ HubConnectionBuilder,
+ HubConnectionState,
+ 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() {
@@ -210,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;
},
@@ -284,18 +298,6 @@ export class LidarrClient {
const statusText = error.response.statusText;
const responseData = error.response.data;
- const isAlbum404 = status === 404 && endpoint.includes("/album/");
- if (!isAlbum404) {
- console.error(`Lidarr API error (${status}):`, {
- url: `${this.config.url}${this.apiPath}${endpoint}`,
- method: method,
- status: status,
- statusText: statusText,
- responseData: responseData,
- responseHeaders: error.response.headers,
- });
- }
-
let errorMsg = statusText || "Unknown error";
let errorDetails = "";
@@ -317,6 +319,26 @@ export class LidarrClient {
const responseText =
typeof responseData === "string" ? responseData : errorMsg;
const responseTextLower = responseText?.toLowerCase?.();
+ const isAlbum404 = status === 404 && endpoint.includes("/album/");
+ const isArtist404 = status === 404 && endpoint.startsWith("/artist/");
+ const isMissingArtist =
+ isArtist404 &&
+ responseTextLower &&
+ responseTextLower.includes("artist with id") &&
+ responseTextLower.includes("does not exist");
+ const suppress404Log = isAlbum404 || isMissingArtist;
+
+ if (!suppress404Log) {
+ console.error(`Lidarr API error (${status}):`, {
+ url: `${this.config.url}${this.apiPath}${endpoint}`,
+ method: method,
+ status: status,
+ statusText: statusText,
+ responseData: responseData,
+ responseHeaders: error.response.headers,
+ });
+ }
+
const isLidarrSkyhookRefused =
status >= 500 &&
responseTextLower &&
@@ -346,6 +368,9 @@ export class LidarrClient {
if (isAlbumEndpoint) {
return null;
}
+ if (isMissingArtist) {
+ return null;
+ }
throw new Error(
`Lidarr endpoint not found: ${endpoint}. Check if Lidarr is running and the API version is correct.`,
);
@@ -803,3 +828,383 @@ 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;
+ this.signalrDisabledSignature = 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();
+ }
+
+ 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/messages`;
+ return `${base}/signalr/messages?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.signalrDisabledSignature === signature) {
+ await this.stopConnection();
+ this.lastConfig = null;
+ return;
+ }
+ 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,
+ },
+ accessTokenFactory: () => config.apiKey,
+ transport:
+ HttpTransportType.WebSockets |
+ HttpTransportType.ServerSentEvents |
+ HttpTransportType.LongPolling,
+ })
+ .withAutomaticReconnect([0, 2000, 5000, 15000])
+ .configureLogging(LogLevel.Warning)
+ .build();
+
+ const handler = (payload) => {
+ this.handlePayload(payload);
+ };
+
+ 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);
+ 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");
+ 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,
+ );
+ 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) {
+ 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 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 ??
+ 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,
+ 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 &&
+ !messageName &&
+ !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 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 { websocketService } = await import("./websocketService.js");
+ const { libraryManager, getCacheStats } = await import("./libraryManager.js");
+
+ if (relatesToCommand) {
+ libraryManager.updateCommandCacheFromSignalR(payload?.__messageBody ?? payload);
+ }
+
+ 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,
+ messageName,
+ artistId,
+ artistMbid,
+ albumId,
+ albumMbid,
+ cache: getCacheStats(),
+ });
+ 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,
+ messageName,
+ artistId,
+ artistMbid,
+ albumId,
+ albumMbid,
+ cache: getCacheStats(),
+ });
+ }
+ }
+
+ if (relatesToArtist && (artistId || artistMbid)) {
+ const updated = await libraryManager.syncArtistFromLidarr(
+ artistId || artistMbid,
+ );
+ websocketService.emitLibraryUpdate("lidarr_signalr", {
+ action: "artist_update",
+ resourceType,
+ eventType,
+ messageName,
+ artistId: updated?.id || artistId,
+ artistMbid: updated?.foreignArtistId || artistMbid,
+ cache: getCacheStats(),
+ });
+ }
+
+ if (relatesToQueue || relatesToAlbum || relatesToTracks) {
+ await libraryManager.refreshActivityFromSignalR();
+ }
+ }
+}
+
+export const lidarrSignalRService = new LidarrSignalRService();
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b86c694..e074eaf 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8762,8 +8762,8 @@
},
"node_modules/yocto-queue": {
"version": "0.1.0",
- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "resolved": "https://codeload.github.com/sindresorhus/yocto-queue/tar.gz/refs/tags/v0.1.0",
+ "integrity": "sha512-SRoq3OoAjvqS5GAtfPr0GttjBY1DTGYHt5D13yI/WWb9Im8lfxpaYmK2AIP0iLAJtWtcJG/YNtyAeuJ118gIog==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/frontend/package.json b/frontend/package.json
index c53ef80..ff7b776 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -31,5 +31,8 @@
"tailwindcss": "^3.4.17",
"vite": "^6.0.5",
"vite-plugin-pwa": "^0.21.1"
+ },
+ "overrides": {
+ "yocto-queue": "https://codeload.github.com/sindresorhus/yocto-queue/tar.gz/refs/tags/v0.1.0"
}
}
diff --git a/frontend/src/pages/ArtistDetails/components/ArtistDetailsHero.jsx b/frontend/src/pages/ArtistDetails/components/ArtistDetailsHero.jsx
index 0c8a664..7f698ab 100644
--- a/frontend/src/pages/ArtistDetails/components/ArtistDetailsHero.jsx
+++ b/frontend/src/pages/ArtistDetails/components/ArtistDetailsHero.jsx
@@ -190,7 +190,7 @@ export function ArtistDetailsHero({