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({ Loading... - ) : existsInLibrary ? ( + ) : existsInLibrary && !addingToLibrary ? ( <>
diff --git a/server.js b/server.js index e7a5c48..9c12f0d 100644 --- a/server.js +++ b/server.js @@ -15,6 +15,8 @@ import { getDiscoveryCache, } from "./backend/services/discoveryService.js"; import { websocketService } from "./backend/services/websocketService.js"; +import { lidarrSignalRService } from "./backend/services/lidarrClient.js"; +import { libraryManager } from "./backend/services/libraryManager.js"; import settingsRouter from "./backend/routes/settings.js"; import onboardingRouter from "./backend/routes/onboarding.js"; @@ -184,6 +186,9 @@ websocketService.initialize(httpServer); httpServer.listen(PORT, "0.0.0.0", async () => { console.log(`Server running on port ${PORT}`); + console.log(`WebSocket available at ws://localhost:${PORT}/ws`); + lidarrSignalRService.start(); + libraryManager.startActivityPolling(); }); httpServer.on("error", (error) => {