mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 21:10:12 +02:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19673d3c2 | |||
| 3efeaa7359 | |||
| 63be8c8fb8 | |||
| 975c31635a | |||
| 9b5bce34a0 | |||
| bb27758310 | |||
| 2d7c52a6b6 | |||
| cbb15ac7ee | |||
| b2db2b27da | |||
| 3dfeed1432 | |||
| 2101f1e9a7 | |||
| 8a0a8e4d54 | |||
| a9ca3f9083 | |||
| 6d5e10a31c | |||
| 5b616d5928 | |||
| 62670964c0 | |||
| 314bd766df | |||
| bdd023fde3 | |||
| 40aabd2217 | |||
| b9d5447b4f | |||
| 68a1cb9aaa | |||
| bf3024939a | |||
| df9464f762 | |||
| 17cf624f6a | |||
| 8f042ad448 | |||
| 1cbd61888f | |||
| 2ce49fc54e | |||
| bec328f1f4 | |||
| ea8c63b71b | |||
| 52049ce163 | |||
| 70c62c8b52 | |||
| fa79b4cbe0 | |||
| 438085633b | |||
| fe043d1823 | |||
| 9bd12df8f6 | |||
| 637d420e1c | |||
| c593b7bc46 | |||
| 5e90139b17 | |||
| ed86d8ffd2 | |||
| bcaaaac586 |
Generated
+139
-119
@@ -16,16 +16,18 @@
|
||||
"@ag-grid-community/react": "^28.2.1",
|
||||
"@ag-grid-community/styles": "^28.2.1",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@mantine/core": "^6.0.0",
|
||||
"@mantine/dates": "^6.0.0",
|
||||
"@mantine/dropzone": "^6.0.0",
|
||||
"@mantine/form": "^6.0.0",
|
||||
"@mantine/hooks": "^6.0.0",
|
||||
"@mantine/modals": "^6.0.0",
|
||||
"@mantine/notifications": "^6.0.0",
|
||||
"@mantine/utils": "^6.0.0",
|
||||
"@mantine/core": "^6.0.8",
|
||||
"@mantine/dates": "^6.0.8",
|
||||
"@mantine/dropzone": "^6.0.8",
|
||||
"@mantine/form": "^6.0.8",
|
||||
"@mantine/hooks": "^6.0.8",
|
||||
"@mantine/modals": "^6.0.8",
|
||||
"@mantine/notifications": "^6.0.8",
|
||||
"@mantine/utils": "^6.0.8",
|
||||
"@tanstack/react-query": "^4.24.4",
|
||||
"@tanstack/react-query-devtools": "^4.24.4",
|
||||
"@ts-rest/core": "^3.19.2",
|
||||
"axios": "^1.3.6",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
@@ -39,7 +41,6 @@
|
||||
"i18next": "^21.6.16",
|
||||
"immer": "^9.0.15",
|
||||
"is-electron": "^2.2.1",
|
||||
"ky": "^0.33.0",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -2214,56 +2215,56 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-6.0.0.tgz",
|
||||
"integrity": "sha512-ik2NUAAn9fYcqmOAluGtI9R73ijrr450dZDA+MezKq/dvpUU/Fhl9yXnGoCxxZ5XF6y4i6q07318rdrVturc9w==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-6.0.8.tgz",
|
||||
"integrity": "sha512-jRVRgcZwKH2FKBUBch+NyFAJgEjU8DOzN5oqBIxR6xqQ2+ESV8dmSWLNxQGvFve7PR32DYuiXEDqzQ5YnwtDzQ==",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.19.1",
|
||||
"@mantine/styles": "6.0.0",
|
||||
"@mantine/utils": "6.0.0",
|
||||
"@mantine/styles": "6.0.8",
|
||||
"@mantine/utils": "6.0.8",
|
||||
"@radix-ui/react-scroll-area": "1.0.2",
|
||||
"react-remove-scroll": "^2.5.5",
|
||||
"react-textarea-autosize": "8.3.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/hooks": "6.0.0",
|
||||
"@mantine/hooks": "6.0.8",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/dates": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-6.0.0.tgz",
|
||||
"integrity": "sha512-eR4zwRXLeBGnjNlJGp2x/CnMgpHsUIX+CG3gQdU8eCp1qzZ+6L4RIVfha45mhbHXRFGig1U0EdCdRVljMKfdnw==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-6.0.8.tgz",
|
||||
"integrity": "sha512-hWMtkKyg5X8xfvEyFJrXHVJu4Vmq7gWnICUxtyP6xD1KUAP4tZdIIq7fQw0bCow+O99YFzZwv0j7wwGzdTqVlg==",
|
||||
"dependencies": {
|
||||
"@mantine/utils": "6.0.0"
|
||||
"@mantine/utils": "6.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "6.0.0",
|
||||
"@mantine/hooks": "6.0.0",
|
||||
"@mantine/core": "6.0.8",
|
||||
"@mantine/hooks": "6.0.8",
|
||||
"dayjs": ">=1.0.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/dropzone": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-6.0.0.tgz",
|
||||
"integrity": "sha512-ql5bmyHEhIGZ17L2W5oL/ZtBlOe7KkrgMecRf6np1d0D+hUYg71XDfiEytZsTLkifufTvZyl8Wx9TViOxuS9iQ==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-6.0.8.tgz",
|
||||
"integrity": "sha512-onl4DVmLqZ9rjaGJiH899A/V4SUxw/wlbLTCFiQGJ6cxYyo2gZZHmXRVLskFX5N9sVBzMBckQyikDIn16mqz/g==",
|
||||
"dependencies": {
|
||||
"@mantine/utils": "6.0.0",
|
||||
"@mantine/utils": "6.0.8",
|
||||
"react-dropzone": "14.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "6.0.0",
|
||||
"@mantine/hooks": "6.0.0",
|
||||
"@mantine/core": "6.0.8",
|
||||
"@mantine/hooks": "6.0.8",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/form": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-6.0.0.tgz",
|
||||
"integrity": "sha512-RQIvQtMqB5rOUzC2LhaQnwii3BNv6J8Btaju/Twqql6mcwnKq8ce+Edw51TuLJ6krAD/iVz6O/Q/HW/0MEUw1A==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-6.0.8.tgz",
|
||||
"integrity": "sha512-0Z+4g5JGNGrEnCrrsetc4OaL8Kg5+GUVFnxi6oEHZdD3LJqknK6/OOLLCHXsNuiL0TWSeXiuYT1UihciRCm0Kg==",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"klona": "^2.0.5"
|
||||
@@ -2273,46 +2274,46 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/hooks": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-6.0.0.tgz",
|
||||
"integrity": "sha512-boszkajLaA4qvd/ebDhqZBbMuUXlvJv8EM0jTaXz09IaGPachBKG5WKpXEcwWh2qmrUQL6pyhIbLMgPnvwS0QQ==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-6.0.8.tgz",
|
||||
"integrity": "sha512-9N4/v1Pt9EWY22MkyZfdMvoQuvLGKui7RqsS29b23Gz1xXR/DrkIAblvLWWu/xE/xYhIpdYfufpC1mF8Y3RfNg==",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/modals": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-6.0.0.tgz",
|
||||
"integrity": "sha512-Z8JBFZdvrciQYdt5n1EzlXi+Q9PfCYpWJQufT2Frcm44+1fTJU2iJWsm7nRTmfuC2T1HxpdlV8hXIE5ovKr29g==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-6.0.8.tgz",
|
||||
"integrity": "sha512-myALL7sKaqmCAXb8uESLni4BOxT8/I1OKIkv2VllVaFdqUPWDsPUEnk+Azhq/A6KpL37lignJQuTNrkN5ONAnw==",
|
||||
"dependencies": {
|
||||
"@mantine/utils": "6.0.0"
|
||||
"@mantine/utils": "6.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "6.0.0",
|
||||
"@mantine/hooks": "6.0.0",
|
||||
"@mantine/core": "6.0.8",
|
||||
"@mantine/hooks": "6.0.8",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/notifications": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-6.0.0.tgz",
|
||||
"integrity": "sha512-Y/hWrOyjR8lfj+oiz0CVP0cG8jCvKAEHYHJNdG13x2fxtNZrkd8FMqH2DyhcVkhByHVMWeCIZUZ7Y+W3NukAjA==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-6.0.8.tgz",
|
||||
"integrity": "sha512-FFB2g3oVrthwRyL2Zwd1IqqxRkIR35olA/D5mX1WcSq32N0+zN3Sa9iSHeGXfyAMpBl5S8ZcHGoCcxSnMzULDg==",
|
||||
"dependencies": {
|
||||
"@mantine/utils": "6.0.0",
|
||||
"@mantine/utils": "6.0.8",
|
||||
"react-transition-group": "4.4.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "6.0.0",
|
||||
"@mantine/hooks": "6.0.0",
|
||||
"@mantine/core": "6.0.8",
|
||||
"@mantine/hooks": "6.0.8",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/styles": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/styles/-/styles-6.0.0.tgz",
|
||||
"integrity": "sha512-TyqFvdKIhbhnGYBDEJ9QIPit4NzyzQ3ivDfdzeqzd/cJBxFPhxB0sEFU8RppXpXBUlbhLFhulYFEVl2pP6zaeg==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/styles/-/styles-6.0.8.tgz",
|
||||
"integrity": "sha512-f5hsU1O+7wxSD9sTU85rKQ3fUmUDup/N+wkdOWmIA8EtNMopmY6wbfs4bh8T8LvyPzMFS7/MzwqXq3nRD9Hfyw==",
|
||||
"dependencies": {
|
||||
"clsx": "1.1.1",
|
||||
"csstype": "3.0.9"
|
||||
@@ -2329,9 +2330,9 @@
|
||||
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
|
||||
},
|
||||
"node_modules/@mantine/utils": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.0.tgz",
|
||||
"integrity": "sha512-1AalSgzINKP4uv1DBTkJe/jh6yGwC2xaCQE4Atlr2bSHiLezYFMy/deGQ8XLFFv2AL0sjvewLW4ernlFujGMZg==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.8.tgz",
|
||||
"integrity": "sha512-G7omM5XjnGzXLiFU/r2yOJrixDFnY2pUkqD38cV9e/Qa6KFEErp4mNb+rhkSHafCnvfwBiNcdryZwEhJTtuC+Q==",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
@@ -2922,6 +2923,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-rest/core": {
|
||||
"version": "3.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@ts-rest/core/-/core-3.19.2.tgz",
|
||||
"integrity": "sha512-ZLKVgJTHIByNYbE06yylKoqGII3XELTMzio1UfToR2abVTt7zZUw+KjNZnXC8XsIFhf/EdlsjcHRcgrvbevlzw==",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz",
|
||||
@@ -4738,8 +4752,7 @@
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
|
||||
"dev": true
|
||||
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
|
||||
},
|
||||
"node_modules/at-least-node": {
|
||||
"version": "1.0.0",
|
||||
@@ -4841,6 +4854,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.6.tgz",
|
||||
"integrity": "sha512-PEcdkk7JcdPiMDkvM4K6ZBRYq9keuVJsToxm2zQIM70Qqo2WHTdJZMXcG9X+RmRp2VPNUQC8W1RAGbgt6b1yMg==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
|
||||
@@ -6192,7 +6215,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
@@ -7223,7 +7245,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -10314,7 +10335,6 @@
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
|
||||
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -10343,7 +10363,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
@@ -13532,17 +13551,6 @@
|
||||
"integrity": "sha512-b0/9J1O9Jcyik1GC6KC42hJ41jKwdO/Mq8Mdo5sYN+IuRTXs2YFHZC3kZSx6ueusqa95x3wLYe/ytKjbAfGixA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ky": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/ky/-/ky-0.33.0.tgz",
|
||||
"integrity": "sha512-peKzuOlN/q3Q3jOgi4t0cp6DOgif5rVnmiSIsjsmkiOcdnSjkrKSUqQmRWYCTqjUtR9b3xQQr8aj7KwSW1r49A==",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/ky?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/language-subtag-registry": {
|
||||
"version": "0.3.21",
|
||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz",
|
||||
@@ -14411,7 +14419,6 @@
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -14420,7 +14427,6 @@
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -16869,6 +16875,11 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
@@ -24955,71 +24966,71 @@
|
||||
}
|
||||
},
|
||||
"@mantine/core": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-6.0.0.tgz",
|
||||
"integrity": "sha512-ik2NUAAn9fYcqmOAluGtI9R73ijrr450dZDA+MezKq/dvpUU/Fhl9yXnGoCxxZ5XF6y4i6q07318rdrVturc9w==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-6.0.8.tgz",
|
||||
"integrity": "sha512-jRVRgcZwKH2FKBUBch+NyFAJgEjU8DOzN5oqBIxR6xqQ2+ESV8dmSWLNxQGvFve7PR32DYuiXEDqzQ5YnwtDzQ==",
|
||||
"requires": {
|
||||
"@floating-ui/react": "^0.19.1",
|
||||
"@mantine/styles": "6.0.0",
|
||||
"@mantine/utils": "6.0.0",
|
||||
"@mantine/styles": "6.0.8",
|
||||
"@mantine/utils": "6.0.8",
|
||||
"@radix-ui/react-scroll-area": "1.0.2",
|
||||
"react-remove-scroll": "^2.5.5",
|
||||
"react-textarea-autosize": "8.3.4"
|
||||
}
|
||||
},
|
||||
"@mantine/dates": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-6.0.0.tgz",
|
||||
"integrity": "sha512-eR4zwRXLeBGnjNlJGp2x/CnMgpHsUIX+CG3gQdU8eCp1qzZ+6L4RIVfha45mhbHXRFGig1U0EdCdRVljMKfdnw==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-6.0.8.tgz",
|
||||
"integrity": "sha512-hWMtkKyg5X8xfvEyFJrXHVJu4Vmq7gWnICUxtyP6xD1KUAP4tZdIIq7fQw0bCow+O99YFzZwv0j7wwGzdTqVlg==",
|
||||
"requires": {
|
||||
"@mantine/utils": "6.0.0"
|
||||
"@mantine/utils": "6.0.8"
|
||||
}
|
||||
},
|
||||
"@mantine/dropzone": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-6.0.0.tgz",
|
||||
"integrity": "sha512-ql5bmyHEhIGZ17L2W5oL/ZtBlOe7KkrgMecRf6np1d0D+hUYg71XDfiEytZsTLkifufTvZyl8Wx9TViOxuS9iQ==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-6.0.8.tgz",
|
||||
"integrity": "sha512-onl4DVmLqZ9rjaGJiH899A/V4SUxw/wlbLTCFiQGJ6cxYyo2gZZHmXRVLskFX5N9sVBzMBckQyikDIn16mqz/g==",
|
||||
"requires": {
|
||||
"@mantine/utils": "6.0.0",
|
||||
"@mantine/utils": "6.0.8",
|
||||
"react-dropzone": "14.2.3"
|
||||
}
|
||||
},
|
||||
"@mantine/form": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-6.0.0.tgz",
|
||||
"integrity": "sha512-RQIvQtMqB5rOUzC2LhaQnwii3BNv6J8Btaju/Twqql6mcwnKq8ce+Edw51TuLJ6krAD/iVz6O/Q/HW/0MEUw1A==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-6.0.8.tgz",
|
||||
"integrity": "sha512-0Z+4g5JGNGrEnCrrsetc4OaL8Kg5+GUVFnxi6oEHZdD3LJqknK6/OOLLCHXsNuiL0TWSeXiuYT1UihciRCm0Kg==",
|
||||
"requires": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"klona": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"@mantine/hooks": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-6.0.0.tgz",
|
||||
"integrity": "sha512-boszkajLaA4qvd/ebDhqZBbMuUXlvJv8EM0jTaXz09IaGPachBKG5WKpXEcwWh2qmrUQL6pyhIbLMgPnvwS0QQ==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-6.0.8.tgz",
|
||||
"integrity": "sha512-9N4/v1Pt9EWY22MkyZfdMvoQuvLGKui7RqsS29b23Gz1xXR/DrkIAblvLWWu/xE/xYhIpdYfufpC1mF8Y3RfNg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@mantine/modals": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-6.0.0.tgz",
|
||||
"integrity": "sha512-Z8JBFZdvrciQYdt5n1EzlXi+Q9PfCYpWJQufT2Frcm44+1fTJU2iJWsm7nRTmfuC2T1HxpdlV8hXIE5ovKr29g==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/modals/-/modals-6.0.8.tgz",
|
||||
"integrity": "sha512-myALL7sKaqmCAXb8uESLni4BOxT8/I1OKIkv2VllVaFdqUPWDsPUEnk+Azhq/A6KpL37lignJQuTNrkN5ONAnw==",
|
||||
"requires": {
|
||||
"@mantine/utils": "6.0.0"
|
||||
"@mantine/utils": "6.0.8"
|
||||
}
|
||||
},
|
||||
"@mantine/notifications": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-6.0.0.tgz",
|
||||
"integrity": "sha512-Y/hWrOyjR8lfj+oiz0CVP0cG8jCvKAEHYHJNdG13x2fxtNZrkd8FMqH2DyhcVkhByHVMWeCIZUZ7Y+W3NukAjA==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-6.0.8.tgz",
|
||||
"integrity": "sha512-FFB2g3oVrthwRyL2Zwd1IqqxRkIR35olA/D5mX1WcSq32N0+zN3Sa9iSHeGXfyAMpBl5S8ZcHGoCcxSnMzULDg==",
|
||||
"requires": {
|
||||
"@mantine/utils": "6.0.0",
|
||||
"@mantine/utils": "6.0.8",
|
||||
"react-transition-group": "4.4.2"
|
||||
}
|
||||
},
|
||||
"@mantine/styles": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/styles/-/styles-6.0.0.tgz",
|
||||
"integrity": "sha512-TyqFvdKIhbhnGYBDEJ9QIPit4NzyzQ3ivDfdzeqzd/cJBxFPhxB0sEFU8RppXpXBUlbhLFhulYFEVl2pP6zaeg==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/styles/-/styles-6.0.8.tgz",
|
||||
"integrity": "sha512-f5hsU1O+7wxSD9sTU85rKQ3fUmUDup/N+wkdOWmIA8EtNMopmY6wbfs4bh8T8LvyPzMFS7/MzwqXq3nRD9Hfyw==",
|
||||
"requires": {
|
||||
"clsx": "1.1.1",
|
||||
"csstype": "3.0.9"
|
||||
@@ -25033,9 +25044,9 @@
|
||||
}
|
||||
},
|
||||
"@mantine/utils": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.0.tgz",
|
||||
"integrity": "sha512-1AalSgzINKP4uv1DBTkJe/jh6yGwC2xaCQE4Atlr2bSHiLezYFMy/deGQ8XLFFv2AL0sjvewLW4ernlFujGMZg==",
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.8.tgz",
|
||||
"integrity": "sha512-G7omM5XjnGzXLiFU/r2yOJrixDFnY2pUkqD38cV9e/Qa6KFEErp4mNb+rhkSHafCnvfwBiNcdryZwEhJTtuC+Q==",
|
||||
"requires": {}
|
||||
},
|
||||
"@mdn/browser-compat-data": {
|
||||
@@ -25462,6 +25473,12 @@
|
||||
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
|
||||
"dev": true
|
||||
},
|
||||
"@ts-rest/core": {
|
||||
"version": "3.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@ts-rest/core/-/core-3.19.2.tgz",
|
||||
"integrity": "sha512-ZLKVgJTHIByNYbE06yylKoqGII3XELTMzio1UfToR2abVTt7zZUw+KjNZnXC8XsIFhf/EdlsjcHRcgrvbevlzw==",
|
||||
"requires": {}
|
||||
},
|
||||
"@tsconfig/node10": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz",
|
||||
@@ -26965,8 +26982,7 @@
|
||||
"asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
|
||||
"dev": true
|
||||
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
|
||||
},
|
||||
"at-least-node": {
|
||||
"version": "1.0.0",
|
||||
@@ -27035,6 +27051,16 @@
|
||||
"integrity": "sha512-lCZN5XRuOnpG4bpMq8v0khrWtUOn+i8lZSb6wHZH56ZfbIEv6XwJV84AAueh9/zi7qPVJ/E4yz6fmsiyOmXR4w==",
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.6.tgz",
|
||||
"integrity": "sha512-PEcdkk7JcdPiMDkvM4K6ZBRYq9keuVJsToxm2zQIM70Qqo2WHTdJZMXcG9X+RmRp2VPNUQC8W1RAGbgt6b1yMg==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"axobject-query": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
|
||||
@@ -28074,7 +28100,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
}
|
||||
@@ -28830,8 +28855,7 @@
|
||||
"delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
|
||||
"dev": true
|
||||
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
|
||||
},
|
||||
"delegates": {
|
||||
"version": "1.0.0",
|
||||
@@ -31182,8 +31206,7 @@
|
||||
"follow-redirects": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
|
||||
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
|
||||
},
|
||||
"for-in": {
|
||||
"version": "1.0.2",
|
||||
@@ -31195,7 +31218,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
@@ -33606,11 +33628,6 @@
|
||||
"integrity": "sha512-b0/9J1O9Jcyik1GC6KC42hJ41jKwdO/Mq8Mdo5sYN+IuRTXs2YFHZC3kZSx6ueusqa95x3wLYe/ytKjbAfGixA==",
|
||||
"dev": true
|
||||
},
|
||||
"ky": {
|
||||
"version": "0.33.0",
|
||||
"resolved": "https://registry.npmjs.org/ky/-/ky-0.33.0.tgz",
|
||||
"integrity": "sha512-peKzuOlN/q3Q3jOgi4t0cp6DOgif5rVnmiSIsjsmkiOcdnSjkrKSUqQmRWYCTqjUtR9b3xQQr8aj7KwSW1r49A=="
|
||||
},
|
||||
"language-subtag-registry": {
|
||||
"version": "0.3.21",
|
||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz",
|
||||
@@ -34260,14 +34277,12 @@
|
||||
"mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mime-db": "1.52.0"
|
||||
}
|
||||
@@ -36068,6 +36083,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
|
||||
+10
-9
@@ -254,16 +254,18 @@
|
||||
"@ag-grid-community/react": "^28.2.1",
|
||||
"@ag-grid-community/styles": "^28.2.1",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@mantine/core": "^6.0.0",
|
||||
"@mantine/dates": "^6.0.0",
|
||||
"@mantine/dropzone": "^6.0.0",
|
||||
"@mantine/form": "^6.0.0",
|
||||
"@mantine/hooks": "^6.0.0",
|
||||
"@mantine/modals": "^6.0.0",
|
||||
"@mantine/notifications": "^6.0.0",
|
||||
"@mantine/utils": "^6.0.0",
|
||||
"@mantine/core": "^6.0.8",
|
||||
"@mantine/dates": "^6.0.8",
|
||||
"@mantine/dropzone": "^6.0.8",
|
||||
"@mantine/form": "^6.0.8",
|
||||
"@mantine/hooks": "^6.0.8",
|
||||
"@mantine/modals": "^6.0.8",
|
||||
"@mantine/notifications": "^6.0.8",
|
||||
"@mantine/utils": "^6.0.8",
|
||||
"@tanstack/react-query": "^4.24.4",
|
||||
"@tanstack/react-query-devtools": "^4.24.4",
|
||||
"@ts-rest/core": "^3.19.2",
|
||||
"axios": "^1.3.6",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
@@ -277,7 +279,6 @@
|
||||
"i18next": "^21.6.16",
|
||||
"immer": "^9.0.15",
|
||||
"is-electron": "^2.2.1",
|
||||
"ky": "^0.33.0",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
|
||||
+260
-143
@@ -1,86 +1,91 @@
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { navidromeApi } from '/@/renderer/api/navidrome.api';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import type {
|
||||
AlbumDetailArgs,
|
||||
RawAlbumDetailResponse,
|
||||
RawAlbumListResponse,
|
||||
AlbumListArgs,
|
||||
SongListArgs,
|
||||
RawSongListResponse,
|
||||
SongDetailArgs,
|
||||
RawSongDetailResponse,
|
||||
AlbumArtistDetailArgs,
|
||||
RawAlbumArtistDetailResponse,
|
||||
AlbumArtistListArgs,
|
||||
RawAlbumArtistListResponse,
|
||||
RatingArgs,
|
||||
RawRatingResponse,
|
||||
RawFavoriteResponse,
|
||||
SetRatingArgs,
|
||||
GenreListArgs,
|
||||
RawGenreListResponse,
|
||||
CreatePlaylistArgs,
|
||||
RawCreatePlaylistResponse,
|
||||
DeletePlaylistArgs,
|
||||
RawDeletePlaylistResponse,
|
||||
PlaylistDetailArgs,
|
||||
RawPlaylistDetailResponse,
|
||||
PlaylistListArgs,
|
||||
RawPlaylistListResponse,
|
||||
MusicFolderListArgs,
|
||||
RawMusicFolderListResponse,
|
||||
PlaylistSongListArgs,
|
||||
ArtistListArgs,
|
||||
RawArtistListResponse,
|
||||
UpdatePlaylistArgs,
|
||||
RawUpdatePlaylistResponse,
|
||||
UserListArgs,
|
||||
RawUserListResponse,
|
||||
FavoriteArgs,
|
||||
TopSongListArgs,
|
||||
RawTopSongListResponse,
|
||||
AddToPlaylistArgs,
|
||||
RawAddToPlaylistResponse,
|
||||
AddToPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
RawRemoveFromPlaylistResponse,
|
||||
RemoveFromPlaylistResponse,
|
||||
ScrobbleArgs,
|
||||
RawScrobbleResponse,
|
||||
ScrobbleResponse,
|
||||
AlbumArtistDetailResponse,
|
||||
FavoriteResponse,
|
||||
CreatePlaylistResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumListResponse,
|
||||
ArtistListResponse,
|
||||
GenreListResponse,
|
||||
MusicFolderListResponse,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistListResponse,
|
||||
RatingResponse,
|
||||
SongDetailResponse,
|
||||
SongListResponse,
|
||||
TopSongListResponse,
|
||||
UpdatePlaylistResponse,
|
||||
UserListResponse,
|
||||
AuthenticationResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
import { DeletePlaylistResponse } from './types';
|
||||
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||
|
||||
export type ControllerEndpoint = Partial<{
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||
authenticate: (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
) => Promise<AuthenticationResponse>;
|
||||
clearPlaylist: () => void;
|
||||
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
|
||||
deleteFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
|
||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<RawDeletePlaylistResponse>;
|
||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<RawAlbumArtistDetailResponse>;
|
||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<RawAlbumArtistListResponse>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
|
||||
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
|
||||
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||
getArtistDetail: () => void;
|
||||
getArtistInfo: (args: any) => void;
|
||||
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
|
||||
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||
getFavoritesList: () => void;
|
||||
getFolderItemList: () => void;
|
||||
getFolderList: () => void;
|
||||
getFolderSongs: () => void;
|
||||
getGenreList: (args: GenreListArgs) => Promise<RawGenreListResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<RawMusicFolderListResponse>;
|
||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<RawPlaylistDetailResponse>;
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<RawPlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<RawScrobbleResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
||||
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
}>;
|
||||
|
||||
type ApiController = {
|
||||
@@ -91,48 +96,17 @@ type ApiController = {
|
||||
|
||||
const endpoints: ApiController = {
|
||||
jellyfin: {
|
||||
addToPlaylist: jellyfinApi.addToPlaylist,
|
||||
addToPlaylist: jfController.addToPlaylist,
|
||||
authenticate: jfController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: jellyfinApi.createFavorite,
|
||||
createPlaylist: jellyfinApi.createPlaylist,
|
||||
deleteFavorite: jellyfinApi.deleteFavorite,
|
||||
deletePlaylist: jellyfinApi.deletePlaylist,
|
||||
getAlbumArtistDetail: jellyfinApi.getAlbumArtistDetail,
|
||||
getAlbumArtistList: jellyfinApi.getAlbumArtistList,
|
||||
getAlbumDetail: jellyfinApi.getAlbumDetail,
|
||||
getAlbumList: jellyfinApi.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: jellyfinApi.getArtistList,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: jellyfinApi.getGenreList,
|
||||
getMusicFolderList: jellyfinApi.getMusicFolderList,
|
||||
getPlaylistDetail: jellyfinApi.getPlaylistDetail,
|
||||
getPlaylistList: jellyfinApi.getPlaylistList,
|
||||
getPlaylistSongList: jellyfinApi.getPlaylistSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jellyfinApi.getSongList,
|
||||
getTopSongs: jellyfinApi.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
|
||||
scrobble: jellyfinApi.scrobble,
|
||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
||||
updateRating: undefined,
|
||||
},
|
||||
navidrome: {
|
||||
addToPlaylist: navidromeApi.addToPlaylist,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: subsonicApi.createFavorite,
|
||||
createPlaylist: navidromeApi.createPlaylist,
|
||||
deleteFavorite: subsonicApi.deleteFavorite,
|
||||
deletePlaylist: navidromeApi.deletePlaylist,
|
||||
getAlbumArtistDetail: navidromeApi.getAlbumArtistDetail,
|
||||
getAlbumArtistList: navidromeApi.getAlbumArtistList,
|
||||
getAlbumDetail: navidromeApi.getAlbumDetail,
|
||||
getAlbumList: navidromeApi.getAlbumList,
|
||||
createFavorite: jfController.createFavorite,
|
||||
createPlaylist: jfController.createPlaylist,
|
||||
deleteFavorite: jfController.deleteFavorite,
|
||||
deletePlaylist: jfController.deletePlaylist,
|
||||
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: jfController.getAlbumArtistList,
|
||||
getAlbumDetail: jfController.getAlbumDetail,
|
||||
getAlbumList: jfController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
@@ -140,30 +114,64 @@ const endpoints: ApiController = {
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: navidromeApi.getGenreList,
|
||||
getMusicFolderList: subsonicApi.getMusicFolderList,
|
||||
getPlaylistDetail: navidromeApi.getPlaylistDetail,
|
||||
getPlaylistList: navidromeApi.getPlaylistList,
|
||||
getPlaylistSongList: navidromeApi.getPlaylistSongList,
|
||||
getSongDetail: navidromeApi.getSongDetail,
|
||||
getSongList: navidromeApi.getSongList,
|
||||
getTopSongs: subsonicApi.getTopSongList,
|
||||
getUserList: navidromeApi.getUserList,
|
||||
removeFromPlaylist: navidromeApi.removeFromPlaylist,
|
||||
scrobble: subsonicApi.scrobble,
|
||||
updatePlaylist: navidromeApi.updatePlaylist,
|
||||
updateRating: subsonicApi.updateRating,
|
||||
getGenreList: jfController.getGenreList,
|
||||
getMusicFolderList: jfController.getMusicFolderList,
|
||||
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jfController.getSongList,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||
scrobble: jfController.scrobble,
|
||||
setRating: undefined,
|
||||
updatePlaylist: jfController.updatePlaylist,
|
||||
},
|
||||
navidrome: {
|
||||
addToPlaylist: ndController.addToPlaylist,
|
||||
authenticate: ndController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: ndController.createPlaylist,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: ndController.deletePlaylist,
|
||||
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
|
||||
getAlbumArtistList: ndController.getAlbumArtistList,
|
||||
getAlbumDetail: ndController.getAlbumDetail,
|
||||
getAlbumList: ndController.getAlbumList,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
getFavoritesList: undefined,
|
||||
getFolderItemList: undefined,
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: ndController.getGenreList,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||
getPlaylistList: ndController.getPlaylistList,
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: ndController.getUserList,
|
||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||
scrobble: ssController.scrobble,
|
||||
setRating: ssController.setRating,
|
||||
updatePlaylist: ndController.updatePlaylist,
|
||||
},
|
||||
subsonic: {
|
||||
authenticate: ssController.authenticate,
|
||||
clearPlaylist: undefined,
|
||||
createFavorite: subsonicApi.createFavorite,
|
||||
createFavorite: ssController.createFavorite,
|
||||
createPlaylist: undefined,
|
||||
deleteFavorite: subsonicApi.deleteFavorite,
|
||||
deleteFavorite: ssController.removeFavorite,
|
||||
deletePlaylist: undefined,
|
||||
getAlbumArtistDetail: subsonicApi.getAlbumArtistDetail,
|
||||
getAlbumArtistList: subsonicApi.getAlbumArtistList,
|
||||
getAlbumDetail: subsonicApi.getAlbumDetail,
|
||||
getAlbumList: subsonicApi.getAlbumList,
|
||||
getAlbumArtistDetail: undefined,
|
||||
getAlbumArtistList: undefined,
|
||||
getAlbumDetail: undefined,
|
||||
getAlbumList: undefined,
|
||||
getArtistDetail: undefined,
|
||||
getArtistInfo: undefined,
|
||||
getArtistList: undefined,
|
||||
@@ -172,21 +180,21 @@ const endpoints: ApiController = {
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: undefined,
|
||||
getMusicFolderList: subsonicApi.getMusicFolderList,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: undefined,
|
||||
getPlaylistList: undefined,
|
||||
getSongDetail: undefined,
|
||||
getSongList: undefined,
|
||||
getTopSongs: subsonicApi.getTopSongList,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
scrobble: subsonicApi.scrobble,
|
||||
scrobble: ssController.scrobble,
|
||||
setRating: undefined,
|
||||
updatePlaylist: undefined,
|
||||
updateRating: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListItem | null) => {
|
||||
const serverType = server?.type || useAuthStore.getState().currentServer?.type;
|
||||
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
|
||||
const serverType = type || useAuthStore.getState().currentServer?.type;
|
||||
|
||||
if (!serverType) {
|
||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||
@@ -206,100 +214,209 @@ const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListIt
|
||||
return endpoints[serverType][endpoint];
|
||||
};
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: { legacy?: boolean; password: string; username: string },
|
||||
type: ServerType,
|
||||
) => {
|
||||
return (apiController('authenticate', type) as ControllerEndpoint['authenticate'])?.(url, body);
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs) => {
|
||||
return (apiController('getAlbumList') as ControllerEndpoint['getAlbumList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs) => {
|
||||
return (apiController('getAlbumDetail') as ControllerEndpoint['getAlbumDetail'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs) => {
|
||||
return (apiController('getSongList') as ControllerEndpoint['getSongList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs) => {
|
||||
return (apiController('getMusicFolderList') as ControllerEndpoint['getMusicFolderList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getMusicFolderList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getMusicFolderList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs) => {
|
||||
return (apiController('getGenreList') as ControllerEndpoint['getGenreList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getGenreList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getGenreList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs) => {
|
||||
return (apiController('getAlbumArtistDetail') as ControllerEndpoint['getAlbumArtistDetail'])?.(
|
||||
args,
|
||||
);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
|
||||
return (apiController('getAlbumArtistList') as ControllerEndpoint['getAlbumArtistList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getAlbumArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getAlbumArtistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs) => {
|
||||
return (apiController('getArtistList') as ControllerEndpoint['getArtistList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getArtistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getArtistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs) => {
|
||||
return (apiController('getPlaylistList') as ControllerEndpoint['getPlaylistList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs) => {
|
||||
return (apiController('createPlaylist') as ControllerEndpoint['createPlaylist'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'createPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs) => {
|
||||
return (apiController('updatePlaylist') as ControllerEndpoint['updatePlaylist'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'updatePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['updatePlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs) => {
|
||||
return (apiController('deletePlaylist') as ControllerEndpoint['deletePlaylist'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'deletePlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deletePlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs) => {
|
||||
return (apiController('addToPlaylist') as ControllerEndpoint['addToPlaylist'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'addToPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['addToPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
|
||||
return (apiController('removeFromPlaylist') as ControllerEndpoint['removeFromPlaylist'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'removeFromPlaylist',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['removeFromPlaylist']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
||||
return (apiController('getPlaylistDetail') as ControllerEndpoint['getPlaylistDetail'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistDetail']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
|
||||
return (apiController('getPlaylistSongList') as ControllerEndpoint['getPlaylistSongList'])?.(
|
||||
args,
|
||||
);
|
||||
return (
|
||||
apiController(
|
||||
'getPlaylistSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getPlaylistSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs) => {
|
||||
return (apiController('getUserList') as ControllerEndpoint['getUserList'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getUserList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getUserList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs) => {
|
||||
return (apiController('createFavorite') as ControllerEndpoint['createFavorite'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'createFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['createFavorite']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs) => {
|
||||
return (apiController('deleteFavorite') as ControllerEndpoint['deleteFavorite'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'deleteFavorite',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['deleteFavorite']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const updateRating = async (args: RatingArgs) => {
|
||||
return (apiController('updateRating') as ControllerEndpoint['updateRating'])?.(args);
|
||||
const updateRating = async (args: SetRatingArgs) => {
|
||||
return (
|
||||
apiController('setRating', args.apiClientProps.server?.type) as ControllerEndpoint['setRating']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs) => {
|
||||
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
|
||||
return (
|
||||
apiController(
|
||||
'getTopSongs',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getTopSongs']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs) => {
|
||||
return (apiController('scrobble', args.server) as ControllerEndpoint['scrobble'])?.(args);
|
||||
return (
|
||||
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
export const controller = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { normalize } from '/@/renderer/api/normalize';
|
||||
|
||||
export const api = {
|
||||
controller,
|
||||
normalize,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
||||
import qs from 'qs';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
addToPlaylist: {
|
||||
body: jfType._parameters.addToPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlists/:id/items',
|
||||
responses: {
|
||||
200: jfType._response.addToPlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
authenticate: {
|
||||
body: jfType._parameters.authenticate,
|
||||
method: 'POST',
|
||||
path: 'users/authenticatebyname',
|
||||
responses: {
|
||||
200: jfType._response.authenticate,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
createFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'POST',
|
||||
path: 'users/:userId/favoriteitems/:id',
|
||||
responses: {
|
||||
200: jfType._response.favorite,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
createPlaylist: {
|
||||
body: jfType._parameters.createPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlists',
|
||||
responses: {
|
||||
200: jfType._response.createPlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'items/:id',
|
||||
responses: {
|
||||
204: jfType._response.deletePlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.albumArtistDetail,
|
||||
responses: {
|
||||
200: jfType._response.albumArtist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getAlbumArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists/albumArtists',
|
||||
query: jfType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getAlbumDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.albumDetail,
|
||||
responses: {
|
||||
200: jfType._response.album,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getAlbumList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.albumList,
|
||||
responses: {
|
||||
200: jfType._response.albumList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists',
|
||||
query: jfType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genres',
|
||||
responses: {
|
||||
200: jfType._response.genreList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
responses: {
|
||||
200: jfType._response.musicFolderList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getPlaylistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
query: jfType._parameters.playlistDetail,
|
||||
responses: {
|
||||
200: jfType._response.playlist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getPlaylistList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.playlistList,
|
||||
responses: {
|
||||
200: jfType._response.playlistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getPlaylistSongList: {
|
||||
method: 'GET',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.playlistSongList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSimilarArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artists/:id/similar',
|
||||
query: jfType._parameters.similarArtistList,
|
||||
responses: {
|
||||
200: jfType._response.albumArtistList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
responses: {
|
||||
200: jfType._response.song,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getSongList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.songList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getTopSongsList: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.songList,
|
||||
responses: {
|
||||
200: jfType._response.topSongsList,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
body: jfType._parameters.favorite,
|
||||
method: 'DELETE',
|
||||
path: 'users/:userId/favoriteitems/:id',
|
||||
responses: {
|
||||
200: jfType._response.favorite,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
removeFromPlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'items/:id',
|
||||
query: jfType._parameters.removeFromPlaylist,
|
||||
responses: {
|
||||
200: jfType._response.removeFromPlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
scrobblePlaying: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
scrobbleProgress: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing/progress',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
scrobbleStopped: {
|
||||
body: jfType._parameters.scrobble,
|
||||
method: 'POST',
|
||||
path: 'sessions/playing/stopped',
|
||||
responses: {
|
||||
200: jfType._response.scrobble,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
body: jfType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
path: 'items/:id',
|
||||
responses: {
|
||||
200: jfType._response.updatePlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
axiosClient.defaults.paramsSerializer = (params) => {
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (currentServer) {
|
||||
const serverId = currentServer.id;
|
||||
const token = currentServer.credential;
|
||||
console.log(`token is expired: ${token}`);
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { credential: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const parsePath = (fullPath: string) => {
|
||||
const [path, params] = fullPath.split('?');
|
||||
|
||||
const parsedParams = qs.parse(params);
|
||||
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
};
|
||||
|
||||
export const jfApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
|
||||
const { params, path: api } = parsePath(path);
|
||||
|
||||
if (server) {
|
||||
baseUrl = `${server?.url}`;
|
||||
token = server?.credential;
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers: {
|
||||
...headers,
|
||||
...(token && { 'X-MediaBrowser-Token': token }),
|
||||
},
|
||||
method: method as Method,
|
||||
params,
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
return {
|
||||
body: result.data,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
return {
|
||||
body: response.data,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,728 @@
|
||||
import {
|
||||
AuthenticationResponse,
|
||||
MusicFolderListArgs,
|
||||
MusicFolderListResponse,
|
||||
GenreListArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
albumArtistListSortMap,
|
||||
sortOrderMap,
|
||||
ArtistListArgs,
|
||||
artistListSortMap,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
albumListSortMap,
|
||||
TopSongListArgs,
|
||||
SongListArgs,
|
||||
songListSortMap,
|
||||
AddToPlaylistArgs,
|
||||
RemoveFromPlaylistArgs,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistSongListArgs,
|
||||
PlaylistListArgs,
|
||||
playlistListSortMap,
|
||||
CreatePlaylistArgs,
|
||||
CreatePlaylistResponse,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
DeletePlaylistArgs,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
GenreListResponse,
|
||||
AlbumArtistDetailResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumListResponse,
|
||||
SongListResponse,
|
||||
AddToPlaylistResponse,
|
||||
RemoveFromPlaylistResponse,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistListResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||
import { jfNormalize } from './jellyfin-normalize';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import packageJson from '../../../../package.json';
|
||||
|
||||
const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
};
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: {
|
||||
password: string;
|
||||
username: string;
|
||||
},
|
||||
): Promise<AuthenticationResponse> => {
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||
body: {
|
||||
Pw: body.password,
|
||||
Username: body.username,
|
||||
},
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
|
||||
return {
|
||||
credential: res.body.AccessToken,
|
||||
userId: res.body.User.Id,
|
||||
username: res.body.User.Name,
|
||||
};
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
const userId = apiClientProps.server?.userId;
|
||||
|
||||
if (!userId) throw new Error('No userId found');
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getMusicFolderList({
|
||||
params: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get genre list');
|
||||
}
|
||||
|
||||
const musicFolders = res.body.Items.filter(
|
||||
(folder) => folder.CollectionType === jfType._enum.collection.MUSIC,
|
||||
);
|
||||
|
||||
return {
|
||||
items: musicFolders.map(jfNormalize.musicFolder),
|
||||
startIndex: 0,
|
||||
totalRecordCount: musicFolders?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getGenreList();
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get genre list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map(jfNormalize.genre),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body?.Items?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (
|
||||
args: AlbumArtistDetailArgs,
|
||||
): Promise<AlbumArtistDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, Overview',
|
||||
},
|
||||
});
|
||||
|
||||
const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Limit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200 || similarArtistsRes.status !== 200) {
|
||||
throw new Error('Failed to get album artist detail');
|
||||
}
|
||||
|
||||
return jfNormalize.albumArtist(
|
||||
{ ...res.body, similarArtists: similarArtistsRes.body },
|
||||
apiClientProps.server,
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||
ImageTypeLimit: 1,
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
UserId: apiClientProps.server?.userId || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get artist list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
userId: apiClientProps.server.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, ChildCount',
|
||||
},
|
||||
});
|
||||
|
||||
const songsRes = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
ParentId: query.id,
|
||||
SortBy: 'Album,SortName',
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200 || songsRes.status !== 200) {
|
||||
throw new Error('Failed to get album detail');
|
||||
}
|
||||
|
||||
return jfNormalize.album({ ...res.body, Songs: songsRes.body.Items }, apiClientProps.server);
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const yearsGroup = [];
|
||||
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
|
||||
for (
|
||||
let i = Number(query._custom?.jellyfin?.minYear);
|
||||
i <= Number(query._custom?.jellyfin?.maxYear);
|
||||
i += 1
|
||||
) {
|
||||
yearsGroup.push(String(i));
|
||||
}
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getAlbumList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getTopSongsList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
ArtistIds: query.artistId,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
Recursive: true,
|
||||
SortBy: 'CommunityRating,SortName',
|
||||
SortOrder: 'Descending',
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get top song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const yearsGroup = [];
|
||||
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
|
||||
for (
|
||||
let i = Number(query._custom?.jellyfin?.minYear);
|
||||
i <= Number(query._custom?.jellyfin?.maxYear);
|
||||
i += 1
|
||||
) {
|
||||
yearsGroup.push(String(i));
|
||||
}
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
|
||||
const artistIdsFilter = query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
AlbumIds: albumIdsFilter,
|
||||
ArtistIds: artistIdsFilter,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SearchTerm: query.searchTerm,
|
||||
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
...query._custom?.jellyfin,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).addToPlaylist({
|
||||
body: {
|
||||
Ids: body.songId,
|
||||
UserId: apiClientProps?.server?.userId,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to add to playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (
|
||||
args: RemoveFromPlaylistArgs,
|
||||
): Promise<RemoveFromPlaylistResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
EntryIds: query.songId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to remove from playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getPlaylistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
|
||||
Ids: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist detail');
|
||||
}
|
||||
|
||||
return jfNormalize.playlist(res.body, apiClientProps.server);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getPlaylistSongList({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
|
||||
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
|
||||
StartIndex: 0,
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||
IncludeItemTypes: 'Playlist',
|
||||
Limit: query.limit,
|
||||
MediaTypes: 'Audio',
|
||||
Recursive: true,
|
||||
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||
StartIndex: query.startIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.TotalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { body, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).createPlaylist({
|
||||
body: {
|
||||
MediaType: 'Audio',
|
||||
Name: body.name,
|
||||
Overview: body.comment || '',
|
||||
UserId: apiClientProps.server.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to create playlist');
|
||||
}
|
||||
|
||||
return {
|
||||
id: res.body.Id,
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
||||
body: {
|
||||
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
||||
MediaType: 'Audio',
|
||||
Name: body.name,
|
||||
Overview: body.comment || '',
|
||||
PremiereDate: null,
|
||||
ProviderIds: {},
|
||||
Tags: [],
|
||||
UserId: apiClientProps.server?.userId, // Required
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to update playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).deletePlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error('Failed to delete playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
for (const id of query.id) {
|
||||
await jfApiClient(apiClientProps).createFavorite({
|
||||
body: {},
|
||||
params: {
|
||||
id,
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
for (const id of query.id) {
|
||||
await jfApiClient(apiClientProps).removeFavorite({
|
||||
body: {},
|
||||
params: {
|
||||
id,
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const position = query.position && Math.round(query.position);
|
||||
|
||||
if (query.submission) {
|
||||
// Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
|
||||
jfApiClient(apiClientProps).scrobbleStopped({
|
||||
body: {
|
||||
IsPaused: true,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'start') {
|
||||
jfApiClient(apiClientProps).scrobblePlaying({
|
||||
body: {
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'pause') {
|
||||
jfApiClient(apiClientProps).scrobbleProgress({
|
||||
body: {
|
||||
EventName: query.event,
|
||||
IsPaused: true,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'unpause') {
|
||||
jfApiClient(apiClientProps).scrobbleProgress({
|
||||
body: {
|
||||
EventName: query.event,
|
||||
IsPaused: false,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
jfApiClient(apiClientProps).scrobbleProgress({
|
||||
body: {
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const jfController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
updatePlaylist,
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
|
||||
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||
import {
|
||||
Song,
|
||||
LibraryItem,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
MusicFolder,
|
||||
Genre,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
const getStreamUrl = (args: {
|
||||
container?: string;
|
||||
deviceId: string;
|
||||
eTag?: string;
|
||||
id: string;
|
||||
mediaSourceId?: string;
|
||||
server: ServerListItem | null;
|
||||
}) => {
|
||||
const { id, server, deviceId } = args;
|
||||
|
||||
return (
|
||||
`${server?.url}/audio` +
|
||||
`/${id}/universal` +
|
||||
`?userId=${server?.userId}` +
|
||||
`&deviceId=${deviceId}` +
|
||||
'&audioCodec=aac' +
|
||||
`&api_key=${server?.credential}` +
|
||||
`&playSessionId=${deviceId}` +
|
||||
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
|
||||
'&transcodingContainer=ts' +
|
||||
'&transcodingProtocol=hls'
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumArtistCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.albumArtist>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getSongCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
item: z.infer<typeof jfType._response.song>;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 100;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (args.item.ImageTags.Primary) {
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
|
||||
if (!args.item?.AlbumPrimaryImageTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fall back to album art if no image embedded
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item?.AlbumId}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
||||
const size = args.size ? args.size : 300;
|
||||
|
||||
if (!args.item.ImageTags?.Primary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof jfType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
return {
|
||||
album: item.Album,
|
||||
albumArtists: item.AlbumArtists?.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})),
|
||||
albumId: item.AlbumId,
|
||||
artistName: item.ArtistItems[0]?.Name,
|
||||
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
||||
createdAt: item.DateCreated,
|
||||
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
||||
duration: item.RunTimeTicks / 10000000,
|
||||
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||
releaseDate: null,
|
||||
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: item.MediaSources && item.MediaSources[0]?.Size,
|
||||
streamUrl: getStreamUrl({
|
||||
container: item.MediaSources[0]?.Container,
|
||||
deviceId,
|
||||
eTag: item.MediaSources[0]?.ETag,
|
||||
id: item.Id,
|
||||
mediaSourceId: item.MediaSources[0]?.Id,
|
||||
server,
|
||||
}),
|
||||
trackNumber: item.IndexNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.DateCreated,
|
||||
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof jfType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
return {
|
||||
albumArtists:
|
||||
item.AlbumArtists.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})) || [],
|
||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getAlbumCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof jfType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
const similarArtists =
|
||||
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
|
||||
(entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item: entry,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
name: entry.Name,
|
||||
}),
|
||||
) || [];
|
||||
|
||||
return {
|
||||
albumCount: null,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
similarArtists,
|
||||
songCount: null,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: z.infer<typeof jfType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getPlaylistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.Name,
|
||||
owner: null,
|
||||
ownerId: null,
|
||||
public: null,
|
||||
rules: null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
|
||||
return {
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeArtist = (item: any) => {
|
||||
// return {
|
||||
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
|
||||
// albumCount: item.AlbumCount,
|
||||
// duration: item.RunTimeTicks / 10000000,
|
||||
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
|
||||
// id: item.Id,
|
||||
// image: getCoverArtUrl(item),
|
||||
// info: {
|
||||
// biography: item.Overview,
|
||||
// externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)),
|
||||
// imageUrl: undefined,
|
||||
// similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)),
|
||||
// },
|
||||
// starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined,
|
||||
// title: item.Name,
|
||||
// uniqueId: nanoid(),
|
||||
// };
|
||||
// };
|
||||
|
||||
const normalizeGenre = (item: JFGenre): Genre => {
|
||||
return {
|
||||
albumCount: undefined,
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
songCount: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeFolder = (item: any) => {
|
||||
// return {
|
||||
// created: item.DateCreated,
|
||||
// id: item.Id,
|
||||
// image: getCoverArtUrl(item, 150),
|
||||
// isDir: true,
|
||||
// title: item.Name,
|
||||
// type: Item.Folder,
|
||||
// uniqueId: nanoid(),
|
||||
// };
|
||||
// };
|
||||
|
||||
// const normalizeScanStatus = () => {
|
||||
// return {
|
||||
// count: 'N/a',
|
||||
// scanning: false,
|
||||
// };
|
||||
// };
|
||||
|
||||
export const jfNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
genre: normalizeGenre,
|
||||
musicFolder: normalizeMusicFolder,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
@@ -0,0 +1,667 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const sortOrderValues = ['Ascending', 'Descending'] as const;
|
||||
|
||||
const jfExternal = {
|
||||
IMDB: 'Imdb',
|
||||
MUSIC_BRAINZ: 'MusicBrainz',
|
||||
THE_AUDIO_DB: 'TheAudioDb',
|
||||
THE_MOVIE_DB: 'TheMovieDb',
|
||||
TVDB: 'Tvdb',
|
||||
};
|
||||
|
||||
const jfImage = {
|
||||
BACKDROP: 'Backdrop',
|
||||
BANNER: 'Banner',
|
||||
BOX: 'Box',
|
||||
CHAPTER: 'Chapter',
|
||||
DISC: 'Disc',
|
||||
LOGO: 'Logo',
|
||||
PRIMARY: 'Primary',
|
||||
THUMB: 'Thumb',
|
||||
} as const;
|
||||
|
||||
const jfCollection = {
|
||||
MUSIC: 'music',
|
||||
PLAYLISTS: 'playlists',
|
||||
} as const;
|
||||
|
||||
const error = z.object({
|
||||
errors: z.object({
|
||||
recursive: z.array(z.string()),
|
||||
}),
|
||||
status: z.number(),
|
||||
title: z.string(),
|
||||
traceId: z.string(),
|
||||
type: z.string(),
|
||||
});
|
||||
|
||||
const baseParameters = z.object({
|
||||
AlbumArtistIds: z.string().optional(),
|
||||
ArtistIds: z.string().optional(),
|
||||
EnableImageTypes: z.string().optional(),
|
||||
EnableTotalRecordCount: z.boolean().optional(),
|
||||
EnableUserData: z.boolean().optional(),
|
||||
ExcludeItemTypes: z.string().optional(),
|
||||
Fields: z.string().optional(),
|
||||
ImageTypeLimit: z.number().optional(),
|
||||
IncludeItemTypes: z.string().optional(),
|
||||
IsFavorite: z.boolean().optional(),
|
||||
Limit: z.number().optional(),
|
||||
MediaTypes: z.string().optional(),
|
||||
ParentId: z.string().optional(),
|
||||
Recursive: z.boolean().optional(),
|
||||
SearchTerm: z.string().optional(),
|
||||
SortBy: z.string().optional(),
|
||||
SortOrder: z.enum(sortOrderValues).optional(),
|
||||
StartIndex: z.number().optional(),
|
||||
UserId: z.string().optional(),
|
||||
});
|
||||
|
||||
const paginationParameters = z.object({
|
||||
Limit: z.number().optional(),
|
||||
NameStartsWith: z.string().optional(),
|
||||
SortOrder: z.enum(sortOrderValues).optional(),
|
||||
StartIndex: z.number().optional(),
|
||||
});
|
||||
|
||||
const pagination = z.object({
|
||||
StartIndex: z.number(),
|
||||
TotalRecordCount: z.number(),
|
||||
});
|
||||
|
||||
const imageTags = z.object({
|
||||
Logo: z.string().optional(),
|
||||
Primary: z.string().optional(),
|
||||
});
|
||||
|
||||
const imageBlurHashes = z.object({
|
||||
Backdrop: z.string().optional(),
|
||||
Logo: z.string().optional(),
|
||||
Primary: z.string().optional(),
|
||||
});
|
||||
|
||||
const userData = z.object({
|
||||
IsFavorite: z.boolean(),
|
||||
Key: z.string(),
|
||||
PlayCount: z.number(),
|
||||
PlaybackPositionTicks: z.number(),
|
||||
Played: z.boolean(),
|
||||
});
|
||||
|
||||
const externalUrl = z.object({
|
||||
Name: z.string(),
|
||||
Url: z.string(),
|
||||
});
|
||||
|
||||
const mediaStream = z.object({
|
||||
AspectRatio: z.string().optional(),
|
||||
BitDepth: z.number().optional(),
|
||||
BitRate: z.number().optional(),
|
||||
ChannelLayout: z.string().optional(),
|
||||
Channels: z.number().optional(),
|
||||
Codec: z.string(),
|
||||
CodecTimeBase: z.string(),
|
||||
ColorSpace: z.string().optional(),
|
||||
Comment: z.string().optional(),
|
||||
DisplayTitle: z.string().optional(),
|
||||
Height: z.number().optional(),
|
||||
Index: z.number(),
|
||||
IsDefault: z.boolean(),
|
||||
IsExternal: z.boolean(),
|
||||
IsForced: z.boolean(),
|
||||
IsInterlaced: z.boolean(),
|
||||
IsTextSubtitleStream: z.boolean(),
|
||||
Level: z.number(),
|
||||
PixelFormat: z.string().optional(),
|
||||
Profile: z.string().optional(),
|
||||
RealFrameRate: z.number().optional(),
|
||||
RefFrames: z.number().optional(),
|
||||
SampleRate: z.number().optional(),
|
||||
SupportsExternalStream: z.boolean(),
|
||||
TimeBase: z.string(),
|
||||
Type: z.string(),
|
||||
Width: z.number().optional(),
|
||||
});
|
||||
|
||||
const mediaSources = z.object({
|
||||
Bitrate: z.number(),
|
||||
Container: z.string(),
|
||||
DefaultAudioStreamIndex: z.number(),
|
||||
ETag: z.string(),
|
||||
Formats: z.array(z.any()),
|
||||
GenPtsInput: z.boolean(),
|
||||
Id: z.string(),
|
||||
IgnoreDts: z.boolean(),
|
||||
IgnoreIndex: z.boolean(),
|
||||
IsInfiniteStream: z.boolean(),
|
||||
IsRemote: z.boolean(),
|
||||
MediaAttachments: z.array(z.any()),
|
||||
MediaStreams: z.array(mediaStream),
|
||||
Name: z.string(),
|
||||
Path: z.string(),
|
||||
Protocol: z.string(),
|
||||
ReadAtNativeFramerate: z.boolean(),
|
||||
RequiredHttpHeaders: z.any(),
|
||||
RequiresClosing: z.boolean(),
|
||||
RequiresLooping: z.boolean(),
|
||||
RequiresOpening: z.boolean(),
|
||||
RunTimeTicks: z.number(),
|
||||
Size: z.number(),
|
||||
SupportsDirectPlay: z.boolean(),
|
||||
SupportsDirectStream: z.boolean(),
|
||||
SupportsProbing: z.boolean(),
|
||||
SupportsTranscoding: z.boolean(),
|
||||
Type: z.string(),
|
||||
});
|
||||
|
||||
const sessionInfo = z.object({
|
||||
AdditionalUsers: z.array(z.any()),
|
||||
ApplicationVersion: z.string(),
|
||||
Capabilities: z.object({
|
||||
PlayableMediaTypes: z.array(z.any()),
|
||||
SupportedCommands: z.array(z.any()),
|
||||
SupportsContentUploading: z.boolean(),
|
||||
SupportsMediaControl: z.boolean(),
|
||||
SupportsPersistentIdentifier: z.boolean(),
|
||||
SupportsSync: z.boolean(),
|
||||
}),
|
||||
Client: z.string(),
|
||||
DeviceId: z.string(),
|
||||
DeviceName: z.string(),
|
||||
HasCustomDeviceName: z.boolean(),
|
||||
Id: z.string(),
|
||||
IsActive: z.boolean(),
|
||||
LastActivityDate: z.string(),
|
||||
LastPlaybackCheckIn: z.string(),
|
||||
NowPlayingQueue: z.array(z.any()),
|
||||
NowPlayingQueueFullItems: z.array(z.any()),
|
||||
PlayState: z.object({
|
||||
CanSeek: z.boolean(),
|
||||
IsMuted: z.boolean(),
|
||||
IsPaused: z.boolean(),
|
||||
RepeatMode: z.string(),
|
||||
}),
|
||||
PlayableMediaTypes: z.array(z.any()),
|
||||
RemoteEndPoint: z.string(),
|
||||
ServerId: z.string(),
|
||||
SupportedCommands: z.array(z.any()),
|
||||
SupportsMediaControl: z.boolean(),
|
||||
SupportsRemoteControl: z.boolean(),
|
||||
UserId: z.string(),
|
||||
UserName: z.string(),
|
||||
});
|
||||
|
||||
const configuration = z.object({
|
||||
DisplayCollectionsView: z.boolean(),
|
||||
DisplayMissingEpisodes: z.boolean(),
|
||||
EnableLocalPassword: z.boolean(),
|
||||
EnableNextEpisodeAutoPlay: z.boolean(),
|
||||
GroupedFolders: z.array(z.any()),
|
||||
HidePlayedInLatest: z.boolean(),
|
||||
LatestItemsExcludes: z.array(z.any()),
|
||||
MyMediaExcludes: z.array(z.any()),
|
||||
OrderedViews: z.array(z.any()),
|
||||
PlayDefaultAudioTrack: z.boolean(),
|
||||
RememberAudioSelections: z.boolean(),
|
||||
RememberSubtitleSelections: z.boolean(),
|
||||
SubtitleLanguagePreference: z.string(),
|
||||
SubtitleMode: z.string(),
|
||||
});
|
||||
|
||||
const policy = z.object({
|
||||
AccessSchedules: z.array(z.any()),
|
||||
AuthenticationProviderId: z.string(),
|
||||
BlockUnratedItems: z.array(z.any()),
|
||||
BlockedChannels: z.array(z.any()),
|
||||
BlockedMediaFolders: z.array(z.any()),
|
||||
BlockedTags: z.array(z.any()),
|
||||
EnableAllChannels: z.boolean(),
|
||||
EnableAllDevices: z.boolean(),
|
||||
EnableAllFolders: z.boolean(),
|
||||
EnableAudioPlaybackTranscoding: z.boolean(),
|
||||
EnableContentDeletion: z.boolean(),
|
||||
EnableContentDeletionFromFolders: z.array(z.any()),
|
||||
EnableContentDownloading: z.boolean(),
|
||||
EnableLiveTvAccess: z.boolean(),
|
||||
EnableLiveTvManagement: z.boolean(),
|
||||
EnableMediaConversion: z.boolean(),
|
||||
EnableMediaPlayback: z.boolean(),
|
||||
EnablePlaybackRemuxing: z.boolean(),
|
||||
EnablePublicSharing: z.boolean(),
|
||||
EnableRemoteAccess: z.boolean(),
|
||||
EnableRemoteControlOfOtherUsers: z.boolean(),
|
||||
EnableSharedDeviceControl: z.boolean(),
|
||||
EnableSyncTranscoding: z.boolean(),
|
||||
EnableUserPreferenceAccess: z.boolean(),
|
||||
EnableVideoPlaybackTranscoding: z.boolean(),
|
||||
EnabledChannels: z.array(z.any()),
|
||||
EnabledDevices: z.array(z.any()),
|
||||
EnabledFolders: z.array(z.any()),
|
||||
ForceRemoteSourceTranscoding: z.boolean(),
|
||||
InvalidLoginAttemptCount: z.number(),
|
||||
IsAdministrator: z.boolean(),
|
||||
IsDisabled: z.boolean(),
|
||||
IsHidden: z.boolean(),
|
||||
LoginAttemptsBeforeLockout: z.number(),
|
||||
MaxActiveSessions: z.number(),
|
||||
PasswordResetProviderId: z.string(),
|
||||
RemoteClientBitrateLimit: z.number(),
|
||||
SyncPlayAccess: z.string(),
|
||||
});
|
||||
|
||||
const user = z.object({
|
||||
Configuration: configuration,
|
||||
EnableAutoLogin: z.boolean(),
|
||||
HasConfiguredEasyPassword: z.boolean(),
|
||||
HasConfiguredPassword: z.boolean(),
|
||||
HasPassword: z.boolean(),
|
||||
Id: z.string(),
|
||||
LastActivityDate: z.string(),
|
||||
LastLoginDate: z.string(),
|
||||
Name: z.string(),
|
||||
Policy: policy,
|
||||
ServerId: z.string(),
|
||||
});
|
||||
|
||||
const authenticateParameters = z.object({
|
||||
Pw: z.string(),
|
||||
Username: z.string(),
|
||||
});
|
||||
|
||||
const authenticate = z.object({
|
||||
AccessToken: z.string(),
|
||||
ServerId: z.string(),
|
||||
SessionInfo: sessionInfo,
|
||||
User: user,
|
||||
});
|
||||
|
||||
const genreItem = z.object({
|
||||
Id: z.string(),
|
||||
Name: z.string(),
|
||||
});
|
||||
|
||||
const genre = z.object({
|
||||
BackdropImageTags: z.array(z.any()),
|
||||
ChannelId: z.null(),
|
||||
Id: z.string(),
|
||||
ImageBlurHashes: imageBlurHashes,
|
||||
ImageTags: imageTags,
|
||||
LocationType: z.string(),
|
||||
Name: z.string(),
|
||||
ServerId: z.string(),
|
||||
Type: z.string(),
|
||||
});
|
||||
|
||||
const genreList = z.object({
|
||||
Items: z.array(genre),
|
||||
});
|
||||
|
||||
const musicFolder = z.object({
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
CollectionType: z.string(),
|
||||
Id: z.string(),
|
||||
ImageBlurHashes: imageBlurHashes,
|
||||
ImageTags: imageTags,
|
||||
IsFolder: z.boolean(),
|
||||
LocationType: z.string(),
|
||||
Name: z.string(),
|
||||
ServerId: z.string(),
|
||||
Type: z.string(),
|
||||
UserData: userData,
|
||||
});
|
||||
|
||||
const musicFolderListParameters = z.object({
|
||||
UserId: z.string(),
|
||||
});
|
||||
|
||||
const musicFolderList = z.object({
|
||||
Items: z.array(musicFolder),
|
||||
});
|
||||
|
||||
const playlist = z.object({
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
ChildCount: z.number().optional(),
|
||||
DateCreated: z.string(),
|
||||
GenreItems: z.array(genreItem),
|
||||
Genres: z.array(z.string()),
|
||||
Id: z.string(),
|
||||
ImageBlurHashes: imageBlurHashes,
|
||||
ImageTags: imageTags,
|
||||
IsFolder: z.boolean(),
|
||||
LocationType: z.string(),
|
||||
MediaType: z.string(),
|
||||
Name: z.string(),
|
||||
Overview: z.string().optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
Type: z.string(),
|
||||
UserData: userData,
|
||||
});
|
||||
|
||||
const jfPlaylistListSort = {
|
||||
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||
DURATION: 'Runtime',
|
||||
NAME: 'SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
SONG_COUNT: 'ChildCount',
|
||||
} as const;
|
||||
|
||||
const playlistListParameters = paginationParameters.merge(
|
||||
baseParameters.extend({
|
||||
IncludeItemTypes: z.literal('Playlist'),
|
||||
SortBy: z.nativeEnum(jfPlaylistListSort).optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const playlistList = pagination.extend({
|
||||
Items: z.array(playlist),
|
||||
});
|
||||
|
||||
const genericItem = z.object({
|
||||
Id: z.string(),
|
||||
Name: z.string(),
|
||||
});
|
||||
|
||||
const song = z.object({
|
||||
Album: z.string(),
|
||||
AlbumArtist: z.string(),
|
||||
AlbumArtists: z.array(genericItem),
|
||||
AlbumId: z.string(),
|
||||
AlbumPrimaryImageTag: z.string(),
|
||||
ArtistItems: z.array(genericItem),
|
||||
Artists: z.array(z.string()),
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
DateCreated: z.string(),
|
||||
ExternalUrls: z.array(externalUrl),
|
||||
GenreItems: z.array(genericItem),
|
||||
Genres: z.array(z.string()),
|
||||
Id: z.string(),
|
||||
ImageBlurHashes: imageBlurHashes,
|
||||
ImageTags: imageTags,
|
||||
IndexNumber: z.number(),
|
||||
IsFolder: z.boolean(),
|
||||
LocationType: z.string(),
|
||||
MediaSources: z.array(mediaSources),
|
||||
MediaType: z.string(),
|
||||
Name: z.string(),
|
||||
ParentIndexNumber: z.number(),
|
||||
PlaylistItemId: z.string().optional(),
|
||||
PremiereDate: z.string().optional(),
|
||||
ProductionYear: z.number(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
SortName: z.string(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
|
||||
const albumArtist = z.object({
|
||||
BackdropImageTags: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
DateCreated: z.string(),
|
||||
ExternalUrls: z.array(externalUrl),
|
||||
GenreItems: z.array(genreItem),
|
||||
Genres: z.array(z.string()),
|
||||
Id: z.string(),
|
||||
ImageBlurHashes: imageBlurHashes,
|
||||
ImageTags: imageTags,
|
||||
LocationType: z.string(),
|
||||
Name: z.string(),
|
||||
Overview: z.string(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
|
||||
const albumDetailParameters = baseParameters;
|
||||
|
||||
const album = z.object({
|
||||
AlbumArtist: z.string(),
|
||||
AlbumArtists: z.array(genericItem),
|
||||
AlbumPrimaryImageTag: z.string(),
|
||||
ArtistItems: z.array(genericItem),
|
||||
Artists: z.array(z.string()),
|
||||
ChannelId: z.null(),
|
||||
ChildCount: z.number().optional(),
|
||||
DateCreated: z.string(),
|
||||
DateLastMediaAdded: z.string().optional(),
|
||||
ExternalUrls: z.array(externalUrl),
|
||||
GenreItems: z.array(genericItem),
|
||||
Genres: z.array(z.string()),
|
||||
Id: z.string(),
|
||||
ImageBlurHashes: imageBlurHashes,
|
||||
ImageTags: imageTags,
|
||||
IsFolder: z.boolean(),
|
||||
LocationType: z.string(),
|
||||
Name: z.string(),
|
||||
ParentLogoImageTag: z.string(),
|
||||
ParentLogoItemId: z.string(),
|
||||
PremiereDate: z.string().optional(),
|
||||
ProductionYear: z.number(),
|
||||
RunTimeTicks: z.number(),
|
||||
ServerId: z.string(),
|
||||
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
|
||||
Type: z.string(),
|
||||
UserData: userData.optional(),
|
||||
});
|
||||
|
||||
const jfAlbumListSort = {
|
||||
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||
CRITIC_RATING: 'CriticRating,SortName',
|
||||
NAME: 'SortName',
|
||||
RANDOM: 'Random,SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
RELEASE_DATE: 'ProductionYear,PremiereDate,SortName',
|
||||
} as const;
|
||||
|
||||
const albumListParameters = paginationParameters.merge(
|
||||
baseParameters.extend({
|
||||
Filters: z.string().optional(),
|
||||
GenreIds: z.string().optional(),
|
||||
Genres: z.string().optional(),
|
||||
IncludeItemTypes: z.literal('MusicAlbum'),
|
||||
IsFavorite: z.boolean().optional(),
|
||||
SearchTerm: z.string().optional(),
|
||||
SortBy: z.nativeEnum(jfAlbumListSort).optional(),
|
||||
Tags: z.string().optional(),
|
||||
Years: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const albumList = pagination.extend({
|
||||
Items: z.array(album),
|
||||
});
|
||||
|
||||
const jfAlbumArtistListSort = {
|
||||
ALBUM: 'Album,SortName',
|
||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME: 'Name,SortName',
|
||||
RANDOM: 'Random,SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
} as const;
|
||||
|
||||
const albumArtistListParameters = paginationParameters.merge(
|
||||
baseParameters.extend({
|
||||
Filters: z.string().optional(),
|
||||
Genres: z.string().optional(),
|
||||
SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(),
|
||||
Years: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const albumArtistList = pagination.extend({
|
||||
Items: z.array(albumArtist),
|
||||
});
|
||||
|
||||
const similarArtistListParameters = baseParameters.extend({
|
||||
Limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const jfSongListSort = {
|
||||
ALBUM: 'Album,SortName',
|
||||
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
|
||||
ARTIST: 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME: 'Name,SortName',
|
||||
PLAY_COUNT: 'PlayCount,SortName',
|
||||
RANDOM: 'Random,SortName',
|
||||
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||
RECENTLY_PLAYED: 'DatePlayed,SortName',
|
||||
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
} as const;
|
||||
|
||||
const songListParameters = baseParameters.extend({
|
||||
AlbumArtistIds: z.string().optional(),
|
||||
AlbumIds: z.string().optional(),
|
||||
ArtistIds: z.string().optional(),
|
||||
Filters: z.string().optional(),
|
||||
GenreIds: z.string().optional(),
|
||||
Genres: z.string().optional(),
|
||||
IsFavorite: z.boolean().optional(),
|
||||
SearchTerm: z.string().optional(),
|
||||
SortBy: z.nativeEnum(jfSongListSort).optional(),
|
||||
Tags: z.string().optional(),
|
||||
Years: z.string().optional(),
|
||||
});
|
||||
|
||||
const songList = pagination.extend({
|
||||
Items: z.array(song),
|
||||
});
|
||||
|
||||
const playlistSongList = songList;
|
||||
|
||||
const topSongsList = songList;
|
||||
|
||||
const playlistDetailParameters = baseParameters.extend({
|
||||
Ids: z.string(),
|
||||
});
|
||||
|
||||
const createPlaylistParameters = z.object({
|
||||
MediaType: z.literal('Audio'),
|
||||
Name: z.string(),
|
||||
Overview: z.string(),
|
||||
UserId: z.string(),
|
||||
});
|
||||
|
||||
const createPlaylist = z.object({
|
||||
Id: z.string(),
|
||||
});
|
||||
|
||||
const updatePlaylist = z.null();
|
||||
|
||||
const updatePlaylistParameters = z.object({
|
||||
Genres: z.array(genreItem),
|
||||
MediaType: z.literal('Audio'),
|
||||
Name: z.string(),
|
||||
Overview: z.string(),
|
||||
PremiereDate: z.null(),
|
||||
ProviderIds: z.object({}),
|
||||
Tags: z.array(genericItem),
|
||||
UserId: z.string(),
|
||||
});
|
||||
|
||||
const addToPlaylist = z.object({
|
||||
Added: z.number(),
|
||||
});
|
||||
|
||||
const addToPlaylistParameters = z.object({
|
||||
Ids: z.array(z.string()),
|
||||
UserId: z.string(),
|
||||
});
|
||||
|
||||
const removeFromPlaylist = z.null();
|
||||
|
||||
const removeFromPlaylistParameters = z.object({
|
||||
EntryIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const deletePlaylist = z.null();
|
||||
|
||||
const deletePlaylistParameters = z.object({
|
||||
Id: z.string(),
|
||||
});
|
||||
|
||||
const scrobbleParameters = z.object({
|
||||
EventName: z.string().optional(),
|
||||
IsPaused: z.boolean().optional(),
|
||||
ItemId: z.string(),
|
||||
PositionTicks: z.number().optional(),
|
||||
});
|
||||
|
||||
const scrobble = z.any();
|
||||
|
||||
const favorite = z.object({
|
||||
IsFavorite: z.boolean(),
|
||||
ItemId: z.string(),
|
||||
Key: z.string(),
|
||||
LastPlayedDate: z.string(),
|
||||
Likes: z.boolean(),
|
||||
PlayCount: z.number(),
|
||||
PlaybackPositionTicks: z.number(),
|
||||
Played: z.boolean(),
|
||||
PlayedPercentage: z.number(),
|
||||
Rating: z.number(),
|
||||
UnplayedItemCount: z.number(),
|
||||
});
|
||||
|
||||
const favoriteParameters = z.object({});
|
||||
|
||||
export const jfType = {
|
||||
_enum: {
|
||||
collection: jfCollection,
|
||||
external: jfExternal,
|
||||
image: jfImage,
|
||||
},
|
||||
_parameters: {
|
||||
addToPlaylist: addToPlaylistParameters,
|
||||
albumArtistDetail: baseParameters,
|
||||
albumArtistList: albumArtistListParameters,
|
||||
albumDetail: albumDetailParameters,
|
||||
albumList: albumListParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createPlaylist: createPlaylistParameters,
|
||||
deletePlaylist: deletePlaylistParameters,
|
||||
favorite: favoriteParameters,
|
||||
musicFolderList: musicFolderListParameters,
|
||||
playlistDetail: playlistDetailParameters,
|
||||
playlistList: playlistListParameters,
|
||||
removeFromPlaylist: removeFromPlaylistParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
similarArtistList: similarArtistListParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
},
|
||||
_response: {
|
||||
addToPlaylist,
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
error,
|
||||
favorite,
|
||||
genre,
|
||||
genreList,
|
||||
musicFolderList,
|
||||
playlist,
|
||||
playlistList,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
song,
|
||||
songList,
|
||||
topSongsList,
|
||||
updatePlaylist,
|
||||
user,
|
||||
},
|
||||
};
|
||||
@@ -1,756 +0,0 @@
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import ky from 'ky';
|
||||
import type {
|
||||
NDGenreListResponse,
|
||||
NDArtistListResponse,
|
||||
NDAlbumDetail,
|
||||
NDAlbumListParams,
|
||||
NDAlbumList,
|
||||
NDSongDetailResponse,
|
||||
NDAlbum,
|
||||
NDSong,
|
||||
NDAuthenticationResponse,
|
||||
NDAlbumDetailResponse,
|
||||
NDSongDetail,
|
||||
NDGenreList,
|
||||
NDAlbumArtistListParams,
|
||||
NDAlbumArtistDetail,
|
||||
NDAlbumListResponse,
|
||||
NDAlbumArtistDetailResponse,
|
||||
NDAlbumArtistList,
|
||||
NDSongListParams,
|
||||
NDCreatePlaylistParams,
|
||||
NDCreatePlaylistResponse,
|
||||
NDDeletePlaylist,
|
||||
NDDeletePlaylistResponse,
|
||||
NDPlaylistListParams,
|
||||
NDPlaylistDetail,
|
||||
NDPlaylistList,
|
||||
NDPlaylistListResponse,
|
||||
NDPlaylistDetailResponse,
|
||||
NDSongList,
|
||||
NDSongListResponse,
|
||||
NDAlbumArtist,
|
||||
NDPlaylist,
|
||||
NDUpdatePlaylistParams,
|
||||
NDUpdatePlaylistResponse,
|
||||
NDPlaylistSongListResponse,
|
||||
NDPlaylistSongList,
|
||||
NDPlaylistSong,
|
||||
NDUserList,
|
||||
NDUserListResponse,
|
||||
NDUserListParams,
|
||||
NDUser,
|
||||
NDAddToPlaylist,
|
||||
NDAddToPlaylistBody,
|
||||
NDAddToPlaylistResponse,
|
||||
NDRemoveFromPlaylistParams,
|
||||
NDRemoveFromPlaylistResponse,
|
||||
NDRemoveFromPlaylist,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
||||
import {
|
||||
Album,
|
||||
Song,
|
||||
AuthenticationResponse,
|
||||
AlbumDetailArgs,
|
||||
GenreListArgs,
|
||||
AlbumListArgs,
|
||||
AlbumArtistListArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
SongListArgs,
|
||||
SongDetailArgs,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
PlaylistListArgs,
|
||||
PlaylistDetailArgs,
|
||||
CreatePlaylistResponse,
|
||||
PlaylistSongListArgs,
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
UpdatePlaylistResponse,
|
||||
UpdatePlaylistArgs,
|
||||
UserListArgs,
|
||||
userListSortMap,
|
||||
playlistListSortMap,
|
||||
albumArtistListSortMap,
|
||||
songListSortMap,
|
||||
albumListSortMap,
|
||||
sortOrderMap,
|
||||
User,
|
||||
LibraryItem,
|
||||
AddToPlaylistArgs,
|
||||
RemoveFromPlaylistArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import { parseSearchParams } from '/@/renderer/utils';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
|
||||
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
|
||||
|
||||
const api = ky.create({
|
||||
hooks: {
|
||||
afterResponse: [
|
||||
async (_request, _options, response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: response.headers.get('x-nd-authorization') as string,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return error;
|
||||
},
|
||||
],
|
||||
},
|
||||
mode: IGNORE_CORS ? 'cors' : undefined,
|
||||
});
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
): Promise<AuthenticationResponse> => {
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const data = await ky
|
||||
.post(`${cleanServerUrl}/auth/login`, {
|
||||
json: {
|
||||
password: body.password,
|
||||
username: body.username,
|
||||
},
|
||||
})
|
||||
.json<NDAuthenticationResponse>();
|
||||
|
||||
return {
|
||||
credential: `u=${body.username}&s=${data.subsonicSalt}&t=${data.subsonicToken}`,
|
||||
ndCredential: data.token,
|
||||
userId: data.id,
|
||||
username: data.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs): Promise<NDUserList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDUserListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: userListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/user', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDUserListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
|
||||
const { server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get('api/genre', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDGenreListResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const artistInfo = await subsonicApi.getArtistInfo({
|
||||
query: {
|
||||
artistId: query.id,
|
||||
limit: 15,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await api
|
||||
.get(`api/artist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDAlbumArtistDetailResponse>();
|
||||
|
||||
return { ...data, similarArtists: artistInfo.similarArtist };
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDAlbumArtistListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/artist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDArtistListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<NDAlbumDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get(`api/album/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDAlbumDetailResponse>();
|
||||
|
||||
const songsData = await api
|
||||
.get('api/song', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: {
|
||||
_end: 0,
|
||||
_order: NDSortOrder.ASC,
|
||||
_sort: 'album',
|
||||
_start: 0,
|
||||
album_id: query.id,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
.json<NDSongListResponse>();
|
||||
|
||||
return { ...data, songs: songsData };
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<NDAlbumList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDAlbumListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
artist_id: query.artistIds?.[0],
|
||||
name: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/album', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDAlbumListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs): Promise<NDSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams = {
|
||||
_end: query.startIndex + (query.limit || -1),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
album_id: query.albumIds,
|
||||
artist_id: query.artistIds,
|
||||
title: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/song', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDSongListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get(`api/song/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDSongDetailResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { body, server } = args;
|
||||
|
||||
const json: NDCreatePlaylistParams = {
|
||||
comment: body.comment,
|
||||
name: body.name,
|
||||
...body.ndParams,
|
||||
public: body.ndParams?.public || false,
|
||||
rules: body.ndParams?.rules ? body.ndParams.rules : undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.post('api/playlist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
})
|
||||
.json<NDCreatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: body.name,
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const json: NDUpdatePlaylistParams = {
|
||||
comment: body.comment || '',
|
||||
name: body.name,
|
||||
ownerId: body.ndParams?.ownerId || undefined,
|
||||
ownerName: body.ndParams?.owner || undefined,
|
||||
public: body.ndParams?.public || false,
|
||||
rules: body.ndParams?.rules ? body.ndParams?.rules : undefined,
|
||||
sync: body.ndParams?.sync || undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.put(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDUpdatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
};
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<NDDeletePlaylist> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.delete(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDDeletePlaylistResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDPlaylistListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : undefined,
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/playlist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDPlaylistListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<NDPlaylistDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDPlaylistDetailResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlaylistSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams & { playlist_id: string } = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
|
||||
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : NDSongListSort.ID,
|
||||
_start: query.startIndex,
|
||||
playlist_id: query.id,
|
||||
};
|
||||
|
||||
const res = await api.get(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDPlaylistSongListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const json: NDAddToPlaylistBody = {
|
||||
ids: body.songId,
|
||||
};
|
||||
|
||||
await api
|
||||
.post(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDAddToPlaylistResponse>();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDRemoveFromPlaylistParams = {
|
||||
id: query.songId,
|
||||
};
|
||||
|
||||
await api
|
||||
.delete(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<NDRemoveFromPlaylistResponse>();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
coverArtId: string;
|
||||
credential: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 250;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: NDSong | NDPlaylistSong,
|
||||
server: ServerListItem,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
let id;
|
||||
let playlistItemId;
|
||||
|
||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||
if ('mediaFileId' in item) {
|
||||
id = item.mediaFileId;
|
||||
playlistItemId = item.id;
|
||||
} else {
|
||||
id = item.id;
|
||||
}
|
||||
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 100,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
bitRate: item.bitRate,
|
||||
bpm: item.bpm ? item.bpm : null,
|
||||
channels: item.channels ? item.channels : null,
|
||||
comment: item.comment ? item.comment : null,
|
||||
compilation: item.compilation,
|
||||
container: item.suffix,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
playlistItemId,
|
||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||
releaseYear: String(item.year),
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
|
||||
trackNumber: item.trackNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number): Album => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.coverArtId || item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isCompilation: item.compilation,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||
releaseYear: item.minYear,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): AlbumArtist => {
|
||||
const imageUrl =
|
||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
similarArtists:
|
||||
item.similarArtists?.map((artist) => ({
|
||||
id: artist.id,
|
||||
imageUrl: artist?.artistImageUrl || null,
|
||||
name: artist.name,
|
||||
})) || null,
|
||||
songCount: item.songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: NDPlaylist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.comment,
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.ownerName,
|
||||
ownerId: item.ownerId,
|
||||
public: item.public,
|
||||
rules: item?.rules || null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
sync: item.sync,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUser = (item: NDUser): User => {
|
||||
return {
|
||||
createdAt: item.createdAt,
|
||||
email: item.email,
|
||||
id: item.id,
|
||||
isAdmin: item.isAdmin,
|
||||
lastLoginAt: item.lastLoginAt,
|
||||
name: item.userName,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const navidromeApi = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getGenreList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
export const ndNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
user: normalizeUser,
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import qs from 'qs';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { resultWithHeaders } from '/@/renderer/api/utils';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
addToPlaylist: {
|
||||
body: ndType._parameters.addToPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlist/:id/tracks',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.addToPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
authenticate: {
|
||||
body: ndType._parameters.authenticate,
|
||||
method: 'POST',
|
||||
path: 'auth/login',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.authenticate),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
createPlaylist: {
|
||||
body: ndType._parameters.createPlaylist,
|
||||
method: 'POST',
|
||||
path: 'playlist',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.createPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.deletePlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'artist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumArtist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getAlbumArtistList: {
|
||||
method: 'GET',
|
||||
path: 'artist',
|
||||
query: ndType._parameters.albumArtistList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumArtistList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getAlbumDetail: {
|
||||
method: 'GET',
|
||||
path: 'album/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.album),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getAlbumList: {
|
||||
method: 'GET',
|
||||
path: 'album',
|
||||
query: ndType._parameters.albumList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.albumList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getGenreList: {
|
||||
method: 'GET',
|
||||
path: 'genre',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.genreList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getPlaylistDetail: {
|
||||
method: 'GET',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getPlaylistList: {
|
||||
method: 'GET',
|
||||
path: 'playlist',
|
||||
query: ndType._parameters.playlistList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlistList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getPlaylistSongList: {
|
||||
method: 'GET',
|
||||
path: 'playlist/:id/tracks',
|
||||
query: ndType._parameters.songList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.playlistSongList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getSongDetail: {
|
||||
method: 'GET',
|
||||
path: 'song/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.song),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getSongList: {
|
||||
method: 'GET',
|
||||
path: 'song',
|
||||
query: ndType._parameters.songList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.songList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
getUserList: {
|
||||
method: 'GET',
|
||||
path: 'user',
|
||||
query: ndType._parameters.userList,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.userList),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
removeFromPlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'playlist/:id/tracks',
|
||||
query: ndType._parameters.removeFromPlaylist,
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.removeFromPlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
updatePlaylist: {
|
||||
body: ndType._parameters.updatePlaylist,
|
||||
method: 'PUT',
|
||||
path: 'playlist/:id',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.updatePlaylist),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
axiosClient.defaults.paramsSerializer = (params) => {
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: response.headers['x-nd-authorization'] as string,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (currentServer) {
|
||||
const serverId = currentServer.id;
|
||||
const token = currentServer.ndCredential;
|
||||
console.log(`token is expired: ${token}`);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const parsePath = (fullPath: string) => {
|
||||
const [path, params] = fullPath.split('?');
|
||||
|
||||
const parsedParams = qs.parse(params);
|
||||
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
};
|
||||
|
||||
export const ndApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
let token: string | undefined;
|
||||
|
||||
const { params, path: api } = parsePath(path);
|
||||
|
||||
if (server) {
|
||||
baseUrl = `${server?.url}/api`;
|
||||
token = server?.ndCredential;
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers: {
|
||||
...headers,
|
||||
...(token && { 'x-nd-authorization': `Bearer ${token}` }),
|
||||
},
|
||||
method: method as Method,
|
||||
params,
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
return {
|
||||
body: { data: result.data, headers: result.headers },
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
return {
|
||||
body: { data: response.data, headers: response.headers },
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistDetailResponse,
|
||||
AddToPlaylistArgs,
|
||||
AddToPlaylistResponse,
|
||||
CreatePlaylistResponse,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
DeletePlaylistResponse,
|
||||
AlbumArtistListResponse,
|
||||
AlbumArtistListArgs,
|
||||
albumArtistListSortMap,
|
||||
sortOrderMap,
|
||||
AuthenticationResponse,
|
||||
UserListResponse,
|
||||
UserListArgs,
|
||||
userListSortMap,
|
||||
GenreListArgs,
|
||||
GenreListResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
albumListSortMap,
|
||||
AlbumListResponse,
|
||||
SongListResponse,
|
||||
SongListArgs,
|
||||
songListSortMap,
|
||||
SongDetailResponse,
|
||||
SongDetailArgs,
|
||||
UpdatePlaylistArgs,
|
||||
UpdatePlaylistResponse,
|
||||
PlaylistListResponse,
|
||||
PlaylistDetailArgs,
|
||||
PlaylistListArgs,
|
||||
playlistListSortMap,
|
||||
PlaylistDetailResponse,
|
||||
PlaylistSongListArgs,
|
||||
PlaylistSongListResponse,
|
||||
RemoveFromPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
} from '../types';
|
||||
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
): Promise<AuthenticationResponse> => {
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||
body: {
|
||||
password: body.password,
|
||||
username: body.username,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
|
||||
return {
|
||||
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
|
||||
ndCredential: res.body.data.token,
|
||||
userId: res.body.data.id,
|
||||
username: res.body.data.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getUserList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: userListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get user list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((user) => ndNormalize.user(user)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getGenreList({});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get genre list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data,
|
||||
startIndex: 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (
|
||||
args: AlbumArtistDetailArgs,
|
||||
): Promise<AlbumArtistDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist detail');
|
||||
}
|
||||
|
||||
if (!apiClientProps.server) {
|
||||
throw new Error('Server is required');
|
||||
}
|
||||
|
||||
return ndNormalize.albumArtist(res.body.data, apiClientProps.server);
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((albumArtist) =>
|
||||
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
|
||||
),
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
const songsData = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: 0,
|
||||
_order: 'ASC',
|
||||
_sort: 'album',
|
||||
_start: 0,
|
||||
album_id: [query.id],
|
||||
},
|
||||
});
|
||||
|
||||
if (albumRes.status !== 200 || songsData.status !== 200) {
|
||||
throw new Error('Failed to get album detail');
|
||||
}
|
||||
|
||||
return ndNormalize.album(
|
||||
{ ...albumRes.body.data, songs: songsData.body.data },
|
||||
apiClientProps.server,
|
||||
);
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getAlbumList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
artist_id: query.artistIds?.[0],
|
||||
name: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getSongList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || -1),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
album_id: query.albumIds,
|
||||
artist_id: query.artistIds,
|
||||
title: query.searchTerm,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getSongDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song detail');
|
||||
}
|
||||
|
||||
return ndNormalize.song(res.body.data, apiClientProps.server, '');
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { body, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).createPlaylist({
|
||||
body: {
|
||||
comment: body.comment,
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public,
|
||||
rules: body._custom?.navidrome?.rules,
|
||||
sync: body._custom?.navidrome?.sync,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to create playlist');
|
||||
}
|
||||
|
||||
return {
|
||||
id: res.body.data.id,
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).updatePlaylist({
|
||||
body: {
|
||||
comment: body.comment || '',
|
||||
name: body.name,
|
||||
public: body._custom?.navidrome?.public || false,
|
||||
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
|
||||
sync: body._custom?.navidrome?.sync || undefined,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to update playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).deletePlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistList({
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
...query._custom?.navidrome,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist detail');
|
||||
}
|
||||
|
||||
return ndNormalize.playlist(res.body.data, apiClientProps.server);
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (
|
||||
args: PlaylistSongListArgs,
|
||||
): Promise<PlaylistSongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
||||
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID,
|
||||
_start: query.startIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get playlist song list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||
};
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
|
||||
const { body, query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).addToPlaylist({
|
||||
body: {
|
||||
ids: body.songId,
|
||||
},
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to add to playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (
|
||||
args: RemoveFromPlaylistArgs,
|
||||
): Promise<RemoveFromPlaylistResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
|
||||
body: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
ids: query.songId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to remove from playlist');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ndController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getGenreList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
updatePlaylist,
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Song, LibraryItem, Album, AlbumArtist, Playlist, User } from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import z from 'zod';
|
||||
import { ndType } from './navidrome-types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string | undefined;
|
||||
coverArtId: string;
|
||||
credential: string | undefined;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 250;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
let id;
|
||||
let playlistItemId;
|
||||
|
||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||
if ('mediaFileId' in item) {
|
||||
id = item.mediaFileId;
|
||||
playlistItemId = item.id;
|
||||
} else {
|
||||
id = item.id;
|
||||
}
|
||||
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 100,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
bitRate: item.bitRate,
|
||||
bpm: item.bpm ? item.bpm : null,
|
||||
channels: item.channels ? item.channels : null,
|
||||
comment: item.comment ? item.comment : null,
|
||||
compilation: item.compilation,
|
||||
container: item.suffix,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
playlistItemId,
|
||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||
releaseYear: String(item.year),
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`,
|
||||
trackNumber: item.trackNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ndType._response.album> & {
|
||||
songs?: z.infer<typeof ndType._response.songList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArtId || item.id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isCompilation: item.compilation,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||
releaseYear: item.minYear,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof ndType._response.albumArtist>,
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
similarArtists: null,
|
||||
// similarArtists:
|
||||
// item.similarArtists?.map((artist) => ({
|
||||
// id: artist.id,
|
||||
// imageUrl: artist?.artistImageUrl || null,
|
||||
// name: artist.name,
|
||||
// })) || null,
|
||||
songCount: item.songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: z.infer<typeof ndType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.id,
|
||||
credential: server?.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.comment,
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.ownerName,
|
||||
ownerId: item.ownerId,
|
||||
public: item.public,
|
||||
rules: item?.rules || null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
sync: item.sync,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
||||
return {
|
||||
createdAt: item.createdAt,
|
||||
email: item.email || null,
|
||||
id: item.id,
|
||||
isAdmin: item.isAdmin,
|
||||
lastLoginAt: item.lastLoginAt,
|
||||
name: item.userName,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const ndNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
user: normalizeUser,
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const sortOrderValues = ['ASC', 'DESC'] as const;
|
||||
|
||||
const error = z.string();
|
||||
|
||||
const paginationParameters = z.object({
|
||||
_end: z.number().optional(),
|
||||
_order: z.enum(sortOrderValues),
|
||||
_start: z.number().optional(),
|
||||
});
|
||||
|
||||
const authenticate = z.object({
|
||||
id: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
name: z.string(),
|
||||
subsonicSalt: z.string(),
|
||||
subsonicToken: z.string(),
|
||||
token: z.string(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
const authenticateParameters = z.object({
|
||||
password: z.string(),
|
||||
username: z.string(),
|
||||
});
|
||||
|
||||
const user = z.object({
|
||||
createdAt: z.string(),
|
||||
email: z.string().optional(),
|
||||
id: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
lastAccessAt: z.string(),
|
||||
lastLoginAt: z.string(),
|
||||
name: z.string(),
|
||||
updatedAt: z.string(),
|
||||
userName: z.string(),
|
||||
});
|
||||
|
||||
const userList = z.array(user);
|
||||
|
||||
const ndUserListSort = {
|
||||
NAME: 'name',
|
||||
} as const;
|
||||
|
||||
const userListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndUserListSort).optional(),
|
||||
});
|
||||
|
||||
const genre = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const genreList = z.array(genre);
|
||||
|
||||
const albumArtist = z.object({
|
||||
albumCount: z.number(),
|
||||
biography: z.string(),
|
||||
externalInfoUpdatedAt: z.string(),
|
||||
externalUrl: z.string(),
|
||||
fullText: z.string(),
|
||||
genres: z.array(genre),
|
||||
id: z.string(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
name: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number(),
|
||||
size: z.number(),
|
||||
smallImageUrl: z.string().optional(),
|
||||
songCount: z.number(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string(),
|
||||
});
|
||||
|
||||
const albumArtistList = z.array(albumArtist);
|
||||
|
||||
const ndAlbumArtistListSort = {
|
||||
ALBUM_COUNT: 'albumCount',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
RATING: 'rating',
|
||||
SONG_COUNT: 'songCount',
|
||||
} as const;
|
||||
|
||||
const albumArtistListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const album = z.object({
|
||||
albumArtist: z.string(),
|
||||
albumArtistId: z.string(),
|
||||
allArtistIds: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
compilation: z.boolean(),
|
||||
coverArtId: z.string().optional(), // Removed after v0.48.0
|
||||
coverArtPath: z.string().optional(), // Removed after v0.48.0
|
||||
createdAt: z.string(),
|
||||
duration: z.number(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
id: z.string(),
|
||||
maxYear: z.number(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
mbzAlbumId: z.string().optional(),
|
||||
minYear: z.number(),
|
||||
name: z.string(),
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number().optional(),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
sortAlbumArtistName: z.string(),
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string().optional(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
const albumList = z.array(album);
|
||||
|
||||
const ndAlbumListSort = {
|
||||
ALBUM_ARTIST: 'albumArtist',
|
||||
ARTIST: 'artist',
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'play_date',
|
||||
RANDOM: 'random',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'recently_added',
|
||||
SONG_COUNT: 'songCount',
|
||||
STARRED: 'starred',
|
||||
YEAR: 'max_year',
|
||||
} as const;
|
||||
|
||||
const albumListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndAlbumListSort).optional(),
|
||||
album_id: z.string().optional(),
|
||||
artist_id: z.string().optional(),
|
||||
compilation: z.boolean().optional(),
|
||||
genre_id: z.string().optional(),
|
||||
has_rating: z.boolean().optional(),
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
recently_added: z.boolean().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const song = z.object({
|
||||
album: z.string(),
|
||||
albumArtist: z.string(),
|
||||
albumArtistId: z.string(),
|
||||
albumId: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
bitRate: z.number(),
|
||||
bookmarkPosition: z.number(),
|
||||
bpm: z.number().optional(),
|
||||
channels: z.number().optional(),
|
||||
comment: z.string().optional(),
|
||||
compilation: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
discNumber: z.number(),
|
||||
duration: z.number(),
|
||||
fullText: z.string(),
|
||||
genre: z.string(),
|
||||
genres: z.array(genre),
|
||||
hasCoverArt: z.boolean(),
|
||||
id: z.string(),
|
||||
lyrics: z.string().optional(),
|
||||
mbzAlbumArtistId: z.string().optional(),
|
||||
mbzAlbumId: z.string().optional(),
|
||||
mbzArtistId: z.string().optional(),
|
||||
mbzTrackId: z.string().optional(),
|
||||
orderAlbumArtistName: z.string(),
|
||||
orderAlbumName: z.string(),
|
||||
orderArtistName: z.string(),
|
||||
orderTitle: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number(),
|
||||
playDate: z.string(),
|
||||
rating: z.number().optional(),
|
||||
size: z.number(),
|
||||
sortAlbumArtistName: z.string(),
|
||||
sortArtistName: z.string(),
|
||||
starred: z.boolean(),
|
||||
starredAt: z.string().optional(),
|
||||
suffix: z.string(),
|
||||
title: z.string(),
|
||||
trackNumber: z.number(),
|
||||
updatedAt: z.string(),
|
||||
year: z.number(),
|
||||
});
|
||||
|
||||
const songList = z.array(song);
|
||||
|
||||
const ndSongListSort = {
|
||||
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
|
||||
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
|
||||
ALBUM_SONGS: 'album, discNumber, trackNumber',
|
||||
ARTIST: 'artist',
|
||||
BPM: 'bpm',
|
||||
CHANNELS: 'channels',
|
||||
COMMENT: 'comment',
|
||||
DURATION: 'duration',
|
||||
FAVORITED: 'starred ASC, starredAt ASC',
|
||||
GENRE: 'genre',
|
||||
ID: 'id',
|
||||
PLAY_COUNT: 'playCount',
|
||||
PLAY_DATE: 'playDate',
|
||||
RATING: 'rating',
|
||||
RECENTLY_ADDED: 'createdAt',
|
||||
TITLE: 'title',
|
||||
TRACK: 'track',
|
||||
YEAR: 'year, album, discNumber, trackNumber',
|
||||
};
|
||||
|
||||
const songListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndSongListSort).optional(),
|
||||
album_id: z.array(z.string()).optional(),
|
||||
artist_id: z.array(z.string()).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const playlist = z.object({
|
||||
comment: z.string(),
|
||||
createdAt: z.string(),
|
||||
duration: z.number(),
|
||||
evaluatedAt: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
ownerId: z.string(),
|
||||
ownerName: z.string(),
|
||||
path: z.string(),
|
||||
public: z.boolean(),
|
||||
rules: z.record(z.string(), z.any()),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
sync: z.boolean(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
const playlistList = z.array(playlist);
|
||||
|
||||
const ndPlaylistListSort = {
|
||||
DURATION: 'duration',
|
||||
NAME: 'name',
|
||||
OWNER: 'ownerName',
|
||||
PUBLIC: 'public',
|
||||
SONG_COUNT: 'songCount',
|
||||
UPDATED_AT: 'updatedAt',
|
||||
} as const;
|
||||
|
||||
const playlistListParameters = paginationParameters.extend({
|
||||
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
||||
owner_id: z.string().optional(),
|
||||
});
|
||||
|
||||
const playlistSong = song.extend({
|
||||
mediaFileId: z.string(),
|
||||
playlistId: z.string(),
|
||||
});
|
||||
|
||||
const playlistSongList = z.array(playlistSong);
|
||||
|
||||
const createPlaylist = playlist.pick({
|
||||
id: true,
|
||||
});
|
||||
|
||||
const createPlaylistParameters = z.object({
|
||||
comment: z.string().optional(),
|
||||
name: z.string(),
|
||||
public: z.boolean().optional(),
|
||||
rules: z.record(z.any()).optional(),
|
||||
sync: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updatePlaylist = playlist;
|
||||
|
||||
const updatePlaylistParameters = createPlaylistParameters.partial();
|
||||
|
||||
const deletePlaylist = z.null();
|
||||
|
||||
const addToPlaylist = z.object({
|
||||
added: z.number(),
|
||||
});
|
||||
|
||||
const addToPlaylistParameters = z.object({
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
const removeFromPlaylist = z.object({
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
const removeFromPlaylistParameters = z.object({
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ndType = {
|
||||
_enum: {
|
||||
albumArtistList: ndAlbumArtistListSort,
|
||||
albumList: ndAlbumListSort,
|
||||
playlistList: ndPlaylistListSort,
|
||||
songList: ndSongListSort,
|
||||
userList: ndUserListSort,
|
||||
},
|
||||
_parameters: {
|
||||
addToPlaylist: addToPlaylistParameters,
|
||||
albumArtistList: albumArtistListParameters,
|
||||
albumList: albumListParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createPlaylist: createPlaylistParameters,
|
||||
playlistList: playlistListParameters,
|
||||
removeFromPlaylist: removeFromPlaylistParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
userList: userListParameters,
|
||||
},
|
||||
_response: {
|
||||
addToPlaylist,
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
error,
|
||||
genre,
|
||||
genreList,
|
||||
playlist,
|
||||
playlistList,
|
||||
playlistSong,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
song,
|
||||
songList,
|
||||
updatePlaylist,
|
||||
user,
|
||||
userList,
|
||||
},
|
||||
};
|
||||
@@ -1,292 +0,0 @@
|
||||
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
|
||||
import type {
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFGenreList,
|
||||
JFMusicFolderList,
|
||||
JFPlaylist,
|
||||
JFSong,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import type {
|
||||
NDAlbum,
|
||||
NDAlbumArtist,
|
||||
NDGenreList,
|
||||
NDPlaylist,
|
||||
NDSong,
|
||||
NDUser,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic.api';
|
||||
import { SSGenreList, SSMusicFolderList, SSSong } from '/@/renderer/api/subsonic.types';
|
||||
import type {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
RawAlbumArtistDetailResponse,
|
||||
RawAlbumArtistListResponse,
|
||||
RawAlbumDetailResponse,
|
||||
RawAlbumListResponse,
|
||||
RawGenreListResponse,
|
||||
RawMusicFolderListResponse,
|
||||
RawPlaylistDetailResponse,
|
||||
RawPlaylistListResponse,
|
||||
RawSongListResponse,
|
||||
RawTopSongListResponse,
|
||||
RawUserListResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
const albumList = (data: RawAlbumListResponse | undefined, server: ServerListItem | null) => {
|
||||
let albums;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albums = data?.items.map((item) => jfNormalize.album(item as JFAlbum, server));
|
||||
break;
|
||||
case 'navidrome':
|
||||
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: albums,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const albumDetail = (
|
||||
data: RawAlbumDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
): Album | undefined => {
|
||||
let album: Album | undefined;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
album = jfNormalize.album(data as JFAlbum, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
album = ndNormalize.album(data as NDAlbum, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return album;
|
||||
};
|
||||
|
||||
const songList = (data: RawSongListResponse | undefined, server: ServerListItem | null) => {
|
||||
let songs;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
|
||||
break;
|
||||
case 'navidrome':
|
||||
songs = data?.items.map((item) => ndNormalize.song(item as NDSong, server, ''));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: songs,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const topSongList = (data: RawTopSongListResponse | undefined, server: ServerListItem | null) => {
|
||||
let songs;
|
||||
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
|
||||
break;
|
||||
case 'navidrome':
|
||||
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||
break;
|
||||
case 'subsonic':
|
||||
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: songs,
|
||||
};
|
||||
};
|
||||
|
||||
const musicFolderList = (
|
||||
data: RawMusicFolderListResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let musicFolders;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
musicFolders = (data as JFMusicFolderList)?.map((item) => ({
|
||||
id: String(item.Id),
|
||||
name: item.Name,
|
||||
}));
|
||||
break;
|
||||
case 'navidrome':
|
||||
musicFolders = (data as SSMusicFolderList)?.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
}));
|
||||
break;
|
||||
case 'subsonic':
|
||||
musicFolders = (data as SSMusicFolderList)?.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
return musicFolders;
|
||||
};
|
||||
|
||||
const genreList = (data: RawGenreListResponse | undefined, server: ServerListItem | null) => {
|
||||
let genres;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
genres = (data as JFGenreList)?.Items.map((item) => ({
|
||||
id: String(item.Id),
|
||||
name: item.Name,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'navidrome':
|
||||
genres = (data as NDGenreList)
|
||||
?.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'subsonic':
|
||||
genres = (data as SSGenreList)
|
||||
?.map((item) => ({
|
||||
id: item.value,
|
||||
name: item.value,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
}
|
||||
|
||||
return genres;
|
||||
};
|
||||
|
||||
const albumArtistDetail = (
|
||||
data: RawAlbumArtistDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist | undefined => {
|
||||
let albumArtist: AlbumArtist | undefined;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albumArtist = jfNormalize.albumArtist(data as JFAlbumArtist, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
albumArtist = ndNormalize.albumArtist(data as NDAlbumArtist, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return albumArtist;
|
||||
};
|
||||
|
||||
const albumArtistList = (
|
||||
data: RawAlbumArtistListResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let albumArtists;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albumArtists = data?.items.map((item) =>
|
||||
jfNormalize.albumArtist(item as JFAlbumArtist, server),
|
||||
);
|
||||
break;
|
||||
case 'navidrome':
|
||||
albumArtists = data?.items.map((item) =>
|
||||
ndNormalize.albumArtist(item as NDAlbumArtist, server),
|
||||
);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: albumArtists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const playlistList = (data: RawPlaylistListResponse | undefined, server: ServerListItem | null) => {
|
||||
let playlists;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
playlists = data?.items.map((item) => jfNormalize.playlist(item as JFPlaylist, server));
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlists = data?.items.map((item) => ndNormalize.playlist(item as NDPlaylist, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: playlists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const playlistDetail = (
|
||||
data: RawPlaylistDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let playlist;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
playlist = jfNormalize.playlist(data as JFPlaylist, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlist = ndNormalize.playlist(data as NDPlaylist, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return playlist;
|
||||
};
|
||||
|
||||
const userList = (data: RawUserListResponse | undefined, server: ServerListItem | null) => {
|
||||
let users;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
break;
|
||||
case 'navidrome':
|
||||
users = data?.items.map((item) => ndNormalize.user(item as NDUser));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: users,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalize = {
|
||||
albumArtistDetail,
|
||||
albumArtistList,
|
||||
albumDetail,
|
||||
albumList,
|
||||
genreList,
|
||||
musicFolderList,
|
||||
playlistDetail,
|
||||
playlistList,
|
||||
songList,
|
||||
topSongList,
|
||||
userList,
|
||||
};
|
||||
@@ -1,497 +0,0 @@
|
||||
import ky from 'ky';
|
||||
import md5 from 'md5';
|
||||
import { parseSearchParams, randomString } from '/@/renderer/utils';
|
||||
import type {
|
||||
SSAlbumListResponse,
|
||||
SSAlbumDetailResponse,
|
||||
SSArtistIndex,
|
||||
SSAlbumArtistList,
|
||||
SSAlbumArtistListResponse,
|
||||
SSGenreListResponse,
|
||||
SSMusicFolderList,
|
||||
SSMusicFolderListResponse,
|
||||
SSGenreList,
|
||||
SSAlbumDetail,
|
||||
SSAlbumList,
|
||||
SSAlbumArtistDetail,
|
||||
SSAlbumArtistDetailResponse,
|
||||
SSFavoriteParams,
|
||||
SSRatingParams,
|
||||
SSAlbumArtistDetailParams,
|
||||
SSAlbumArtistListParams,
|
||||
SSTopSongListParams,
|
||||
SSTopSongListResponse,
|
||||
SSArtistInfoParams,
|
||||
SSArtistInfoResponse,
|
||||
SSArtistInfo,
|
||||
SSSong,
|
||||
SSTopSongList,
|
||||
SSScrobbleParams,
|
||||
} from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
ArtistInfoArgs,
|
||||
AuthenticationResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
GenreListArgs,
|
||||
LibraryItem,
|
||||
MusicFolderListArgs,
|
||||
QueueSong,
|
||||
RatingArgs,
|
||||
RatingResponse,
|
||||
RawScrobbleResponse,
|
||||
ScrobbleArgs,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
TopSongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
|
||||
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
coverArtId: string;
|
||||
credential: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 150;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const api = ky.create({
|
||||
hooks: {
|
||||
afterResponse: [
|
||||
async (_request, _options, response) => {
|
||||
const data = await response.json();
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
title: 'Issue from Subsonic API',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
||||
},
|
||||
],
|
||||
},
|
||||
mode: IGNORE_CORS ? 'cors' : undefined,
|
||||
});
|
||||
|
||||
const getDefaultParams = (server: ServerListItem | null) => {
|
||||
if (!server) return {};
|
||||
|
||||
const authParams = server.credential.split(/&?\w=/gm);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
u: server.username,
|
||||
v: '1.13.0',
|
||||
};
|
||||
|
||||
if (authParams?.length === 4) {
|
||||
params.s = authParams[2];
|
||||
params.t = authParams[3];
|
||||
} else if (authParams?.length === 3) {
|
||||
params.p = authParams[2];
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: {
|
||||
legacy?: boolean;
|
||||
password: string;
|
||||
username: string;
|
||||
},
|
||||
): Promise<AuthenticationResponse> => {
|
||||
let credential;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
if (body.legacy) {
|
||||
credential = `u=${body.username}&p=${body.password}`;
|
||||
} else {
|
||||
const salt = randomString(12);
|
||||
const hash = md5(body.password + salt);
|
||||
credential = `u=${body.username}&s=${salt}&t=${hash}`;
|
||||
}
|
||||
|
||||
await ky.get(`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${credential}`);
|
||||
|
||||
return {
|
||||
credential,
|
||||
userId: null,
|
||||
username: body.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<SSMusicFolderList> => {
|
||||
const { signal, server } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const data = await api
|
||||
.get('rest/getMusicFolders.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: defaultParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSMusicFolderListResponse>();
|
||||
|
||||
return data.musicFolders.musicFolder;
|
||||
};
|
||||
|
||||
export const getAlbumArtistDetail = async (
|
||||
args: AlbumArtistDetailArgs,
|
||||
): Promise<SSAlbumArtistDetail> => {
|
||||
const { server, signal, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSAlbumArtistDetailParams = {
|
||||
id: query.id,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('/getArtist.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumArtistDetailResponse>();
|
||||
|
||||
return data.artist;
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSAlbumArtistListParams = {
|
||||
musicFolderId: query.musicFolderId,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getArtists.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumArtistListResponse>();
|
||||
|
||||
const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
|
||||
|
||||
return {
|
||||
items: artists,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||
const { server, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const data = await api
|
||||
.get('rest/getGenres.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: defaultParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSGenreListResponse>();
|
||||
|
||||
return data.genres.genre;
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams = {
|
||||
id: query.id,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getAlbum.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumDetailResponse>();
|
||||
|
||||
const { song: songs, ...dataWithoutSong } = data.album;
|
||||
return { ...dataWithoutSong, songs };
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams = {
|
||||
...defaultParams,
|
||||
};
|
||||
const data = await api
|
||||
.get('rest/getAlbumList2.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumListResponse>();
|
||||
|
||||
return {
|
||||
items: data.albumList2.album,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
};
|
||||
};
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
for (const id of query.id) {
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/star.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
// .json<SSFavoriteResponse>();
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
for (const id of query.id) {
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/unstar.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
// .json<SSFavoriteResponse>();
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const itemIds = query.item.map((item) => item.id);
|
||||
|
||||
for (const id of itemIds) {
|
||||
const searchParams: SSRatingParams = {
|
||||
id,
|
||||
rating: query.rating,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/setRating.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSTopSongListParams = {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getTopSongs.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSTopSongListResponse>();
|
||||
|
||||
return {
|
||||
items: data?.topSongs?.song,
|
||||
startIndex: 0,
|
||||
totalRecordCount: data?.topSongs?.song?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSArtistInfoParams = {
|
||||
count: query.limit,
|
||||
id: query.artistId,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getArtistInfo2.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSArtistInfoResponse>();
|
||||
|
||||
return data.artistInfo2;
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSScrobbleParams = {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/scrobble.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
|
||||
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist,
|
||||
},
|
||||
],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist,
|
||||
},
|
||||
],
|
||||
bitRate: item.bitRate,
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: item.contentType,
|
||||
createdAt: item.created,
|
||||
discNumber: item.discNumber || 1,
|
||||
duration: item.duration,
|
||||
genres: [
|
||||
{
|
||||
id: item.genre,
|
||||
name: item.genre,
|
||||
},
|
||||
],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
streamUrl,
|
||||
trackNumber: item.track,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: '',
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const subsonicApi = {
|
||||
authenticate,
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistInfo,
|
||||
getCoverArtUrl,
|
||||
getGenreList,
|
||||
getMusicFolderList,
|
||||
getTopSongList,
|
||||
scrobble,
|
||||
updateRating,
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
song: normalizeSong,
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
||||
import qs from 'qs';
|
||||
import { z } from 'zod';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { ServerListItem } from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
authenticate: {
|
||||
method: 'GET',
|
||||
path: 'ping.view',
|
||||
query: ssType._parameters.authenticate,
|
||||
responses: {
|
||||
200: ssType._response.authenticate,
|
||||
},
|
||||
},
|
||||
createFavorite: {
|
||||
method: 'GET',
|
||||
path: 'star.view',
|
||||
query: ssType._parameters.createFavorite,
|
||||
responses: {
|
||||
200: ssType._response.createFavorite,
|
||||
},
|
||||
},
|
||||
getArtistInfo: {
|
||||
method: 'GET',
|
||||
path: 'getArtistInfo.view',
|
||||
query: ssType._parameters.artistInfo,
|
||||
responses: {
|
||||
200: ssType._response.artistInfo,
|
||||
},
|
||||
},
|
||||
getMusicFolderList: {
|
||||
method: 'GET',
|
||||
path: 'getMusicFolders.view',
|
||||
responses: {
|
||||
200: ssType._response.musicFolderList,
|
||||
},
|
||||
},
|
||||
getTopSongsList: {
|
||||
method: 'GET',
|
||||
path: 'getTopSongs.view',
|
||||
query: ssType._parameters.topSongsList,
|
||||
responses: {
|
||||
200: ssType._response.topSongsList,
|
||||
},
|
||||
},
|
||||
removeFavorite: {
|
||||
method: 'GET',
|
||||
path: 'unstar.view',
|
||||
query: ssType._parameters.removeFavorite,
|
||||
responses: {
|
||||
200: ssType._response.removeFavorite,
|
||||
},
|
||||
},
|
||||
scrobble: {
|
||||
method: 'GET',
|
||||
path: 'scrobble.view',
|
||||
query: ssType._parameters.scrobble,
|
||||
responses: {
|
||||
200: ssType._response.scrobble,
|
||||
},
|
||||
},
|
||||
setRating: {
|
||||
method: 'GET',
|
||||
path: 'setRating.view',
|
||||
query: ssType._parameters.setRating,
|
||||
responses: {
|
||||
200: ssType._response.setRating,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
axiosClient.defaults.paramsSerializer = (params) => {
|
||||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const data = response.data;
|
||||
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
title: 'Issue from Subsonic API',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const ssApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
url?: string;
|
||||
}) => {
|
||||
const { server, url, signal } = args;
|
||||
|
||||
return initClient(contract, {
|
||||
api: async ({ path, method, headers, body }) => {
|
||||
let baseUrl: string | undefined;
|
||||
const authParams: Record<string, any> = {};
|
||||
|
||||
if (server) {
|
||||
baseUrl = `${server.url}/rest`;
|
||||
const token = server.credential;
|
||||
const params = token.split(/&?\w=/gm);
|
||||
|
||||
authParams.u = server.username;
|
||||
if (params?.length === 4) {
|
||||
authParams.s = params[2];
|
||||
authParams.t = params[3];
|
||||
} else if (params?.length === 3) {
|
||||
authParams.p = params[2];
|
||||
}
|
||||
} else {
|
||||
baseUrl = url;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({
|
||||
data: body,
|
||||
headers,
|
||||
method: method as Method,
|
||||
params: {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...authParams,
|
||||
},
|
||||
signal,
|
||||
url: `${baseUrl}/${path}`,
|
||||
});
|
||||
|
||||
return {
|
||||
body: result.data['subsonic-response'],
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
return {
|
||||
body: response.data,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
import md5 from 'md5';
|
||||
import { z } from 'zod';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import {
|
||||
ArtistInfoArgs,
|
||||
AuthenticationResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
LibraryItem,
|
||||
MusicFolderListArgs,
|
||||
MusicFolderListResponse,
|
||||
SetRatingArgs,
|
||||
RatingResponse,
|
||||
ScrobbleArgs,
|
||||
ScrobbleResponse,
|
||||
SongListResponse,
|
||||
TopSongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { randomString } from '/@/renderer/utils';
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: {
|
||||
legacy?: boolean;
|
||||
password: string;
|
||||
username: string;
|
||||
},
|
||||
): Promise<AuthenticationResponse> => {
|
||||
let credential: string;
|
||||
let credentialParams: {
|
||||
p?: string;
|
||||
s?: string;
|
||||
t?: string;
|
||||
u: string;
|
||||
};
|
||||
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
if (body.legacy) {
|
||||
credential = `u=${body.username}&p=${body.password}`;
|
||||
credentialParams = {
|
||||
p: body.password,
|
||||
u: body.username,
|
||||
};
|
||||
} else {
|
||||
const salt = randomString(12);
|
||||
const hash = md5(body.password + salt);
|
||||
credential = `u=${body.username}&s=${salt}&t=${hash}`;
|
||||
credentialParams = {
|
||||
s: salt,
|
||||
t: hash,
|
||||
u: body.username,
|
||||
};
|
||||
}
|
||||
|
||||
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||
query: {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
v: '1.13.0',
|
||||
...credentialParams,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
credential,
|
||||
userId: null,
|
||||
username: body.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
|
||||
const { apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getMusicFolderList({});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get music folder list');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.musicFolders.musicFolder,
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.musicFolders.musicFolder.length,
|
||||
};
|
||||
};
|
||||
|
||||
// export const getAlbumArtistDetail = async (
|
||||
// args: AlbumArtistDetailArgs,
|
||||
// ): Promise<SSAlbumArtistDetail> => {
|
||||
// const { server, signal, query } = args;
|
||||
// const defaultParams = getDefaultParams(server);
|
||||
|
||||
// const searchParams: SSAlbumArtistDetailParams = {
|
||||
// id: query.id,
|
||||
// ...defaultParams,
|
||||
// };
|
||||
|
||||
// const data = await api
|
||||
// .get('/getArtist.view', {
|
||||
// prefixUrl: server?.url,
|
||||
// searchParams,
|
||||
// signal,
|
||||
// })
|
||||
// .json<SSAlbumArtistDetailResponse>();
|
||||
|
||||
// return data.artist;
|
||||
// };
|
||||
|
||||
// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
||||
// const { signal, server, query } = args;
|
||||
// const defaultParams = getDefaultParams(server);
|
||||
|
||||
// const searchParams: SSAlbumArtistListParams = {
|
||||
// musicFolderId: query.musicFolderId,
|
||||
// ...defaultParams,
|
||||
// };
|
||||
|
||||
// const data = await api
|
||||
// .get('rest/getArtists.view', {
|
||||
// prefixUrl: server?.url,
|
||||
// searchParams,
|
||||
// signal,
|
||||
// })
|
||||
// .json<SSAlbumArtistListResponse>();
|
||||
|
||||
// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
|
||||
|
||||
// return {
|
||||
// items: artists,
|
||||
// startIndex: query.startIndex,
|
||||
// totalRecordCount: null,
|
||||
// };
|
||||
// };
|
||||
|
||||
// const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||
// const { server, signal } = args;
|
||||
// const defaultParams = getDefaultParams(server);
|
||||
|
||||
// const data = await api
|
||||
// .get('rest/getGenres.view', {
|
||||
// prefixUrl: server?.url,
|
||||
// searchParams: defaultParams,
|
||||
// signal,
|
||||
// })
|
||||
// .json<SSGenreListResponse>();
|
||||
|
||||
// return data.genres.genre;
|
||||
// };
|
||||
|
||||
// const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
||||
// const { server, query, signal } = args;
|
||||
// const defaultParams = getDefaultParams(server);
|
||||
|
||||
// const searchParams = {
|
||||
// id: query.id,
|
||||
// ...defaultParams,
|
||||
// };
|
||||
|
||||
// const data = await api
|
||||
// .get('rest/getAlbum.view', {
|
||||
// prefixUrl: server?.url,
|
||||
// searchParams: parseSearchParams(searchParams),
|
||||
// signal,
|
||||
// })
|
||||
// .json<SSAlbumDetailResponse>();
|
||||
|
||||
// const { song: songs, ...dataWithoutSong } = data.album;
|
||||
// return { ...dataWithoutSong, songs };
|
||||
// };
|
||||
|
||||
// const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||
// const { server, query, signal } = args;
|
||||
// const defaultParams = getDefaultParams(server);
|
||||
|
||||
// const searchParams = {
|
||||
// ...defaultParams,
|
||||
// };
|
||||
// const data = await api
|
||||
// .get('rest/getAlbumList2.view', {
|
||||
// prefixUrl: server?.url,
|
||||
// searchParams: parseSearchParams(searchParams),
|
||||
// signal,
|
||||
// })
|
||||
// .json<SSAlbumListResponse>();
|
||||
|
||||
// return {
|
||||
// items: data.albumList2.album,
|
||||
// startIndex: query.startIndex,
|
||||
// totalRecordCount: null,
|
||||
// };
|
||||
// };
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).createFavorite({
|
||||
query: {
|
||||
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to create favorite');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).removeFavorite({
|
||||
query: {
|
||||
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete favorite');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const setRating = async (args: SetRatingArgs): Promise<RatingResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const itemIds = query.item.map((item) => item.id);
|
||||
|
||||
for (const id of itemIds) {
|
||||
await ssApiClient(apiClientProps).setRating({
|
||||
query: {
|
||||
id,
|
||||
rating: query.rating,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||
query: {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get top songs');
|
||||
}
|
||||
|
||||
return {
|
||||
items:
|
||||
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) ||
|
||||
[],
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistInfo = async (
|
||||
args: ArtistInfoArgs,
|
||||
): Promise<z.infer<typeof ssType._response.artistInfo>> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
count: query.limit,
|
||||
id: query.artistId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get artist info');
|
||||
}
|
||||
|
||||
return res.body;
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).scrobble({
|
||||
query: {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to scrobble');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ssController = {
|
||||
authenticate,
|
||||
createFavorite,
|
||||
getArtistInfo,
|
||||
getMusicFolderList,
|
||||
getTopSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
setRating,
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { QueueSong, LibraryItem } from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string | undefined;
|
||||
coverArtId?: string;
|
||||
credential: string | undefined;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 150;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: z.infer<typeof ssType._response.song>,
|
||||
server: ServerListItem | null,
|
||||
deviceId: string,
|
||||
): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
}) || null;
|
||||
|
||||
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
|
||||
|
||||
return {
|
||||
album: item.album || '',
|
||||
albumArtists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
albumId: item.albumId || '',
|
||||
artistName: item.artist || '',
|
||||
artists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist || '',
|
||||
},
|
||||
],
|
||||
bitRate: item.bitRate || 0,
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: item.contentType,
|
||||
createdAt: item.created,
|
||||
discNumber: item.discNumber || 1,
|
||||
duration: item.duration || 0,
|
||||
genres: item.genre
|
||||
? [
|
||||
{
|
||||
id: item.genre,
|
||||
name: item.genre,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
streamUrl,
|
||||
trackNumber: item.track || 1,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: '',
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
song: normalizeSong,
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const baseResponse = z.object({
|
||||
'subsonic-response': z.object({
|
||||
status: z.string(),
|
||||
version: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const authenticate = z.null();
|
||||
|
||||
const authenticateParameters = z.object({
|
||||
c: z.string(),
|
||||
f: z.string(),
|
||||
p: z.string().optional(),
|
||||
s: z.string().optional(),
|
||||
t: z.string().optional(),
|
||||
u: z.string(),
|
||||
v: z.string(),
|
||||
});
|
||||
|
||||
const createFavoriteParameters = z.object({
|
||||
albumId: z.array(z.string()).optional(),
|
||||
artistId: z.array(z.string()).optional(),
|
||||
id: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const createFavorite = z.null();
|
||||
|
||||
const removeFavoriteParameters = z.object({
|
||||
albumId: z.array(z.string()).optional(),
|
||||
artistId: z.array(z.string()).optional(),
|
||||
id: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const removeFavorite = z.null();
|
||||
|
||||
const setRatingParameters = z.object({
|
||||
id: z.string(),
|
||||
rating: z.number(),
|
||||
});
|
||||
|
||||
const setRating = z.null();
|
||||
|
||||
const musicFolder = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const musicFolderList = z.object({
|
||||
musicFolders: z.object({
|
||||
musicFolder: z.array(musicFolder),
|
||||
}),
|
||||
});
|
||||
|
||||
const song = z.object({
|
||||
album: z.string().optional(),
|
||||
albumId: z.string().optional(),
|
||||
artist: z.string().optional(),
|
||||
artistId: z.string().optional(),
|
||||
averageRating: z.number().optional(),
|
||||
bitRate: z.number().optional(),
|
||||
contentType: z.string(),
|
||||
coverArt: z.string().optional(),
|
||||
created: z.string(),
|
||||
discNumber: z.number(),
|
||||
duration: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
parent: z.string(),
|
||||
path: z.string(),
|
||||
playCount: z.number().optional(),
|
||||
size: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
suffix: z.string(),
|
||||
title: z.string(),
|
||||
track: z.number().optional(),
|
||||
type: z.string(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const album = z.object({
|
||||
album: z.string(),
|
||||
artist: z.string(),
|
||||
artistId: z.string(),
|
||||
coverArt: z.string(),
|
||||
created: z.string(),
|
||||
duration: z.number(),
|
||||
genre: z.string().optional(),
|
||||
id: z.string(),
|
||||
isDir: z.boolean(),
|
||||
isVideo: z.boolean(),
|
||||
name: z.string(),
|
||||
parent: z.string(),
|
||||
song: z.array(song),
|
||||
songCount: z.number(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string(),
|
||||
userRating: z.number().optional(),
|
||||
year: z.number().optional(),
|
||||
});
|
||||
|
||||
const albumListParameters = z.object({
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
size: z.number().optional(),
|
||||
toYear: z.number().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
const albumList = z.array(album.omit({ song: true }));
|
||||
|
||||
const albumArtist = z.object({
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const albumArtistList = z.object({
|
||||
artist: z.array(albumArtist),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const artistInfoParameters = z.object({
|
||||
count: z.number().optional(),
|
||||
id: z.string(),
|
||||
includeNotPresent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const artistInfo = z.object({
|
||||
artistInfo2: z.object({
|
||||
biography: z.string().optional(),
|
||||
largeImageUrl: z.string().optional(),
|
||||
lastFmUrl: z.string().optional(),
|
||||
mediumImageUrl: z.string().optional(),
|
||||
musicBrainzId: z.string().optional(),
|
||||
similarArtist: z.array(
|
||||
z.object({
|
||||
albumCount: z.string(),
|
||||
artistImageUrl: z.string().optional(),
|
||||
coverArt: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
smallImageUrl: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const topSongsListParameters = z.object({
|
||||
artist: z.string(), // The name of the artist, not the artist ID
|
||||
count: z.number().optional(),
|
||||
});
|
||||
|
||||
const topSongsList = z.object({
|
||||
topSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
});
|
||||
|
||||
const scrobbleParameters = z.object({
|
||||
id: z.string(),
|
||||
submission: z.boolean().optional(),
|
||||
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
|
||||
});
|
||||
|
||||
const scrobble = z.null();
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumList: albumListParameters,
|
||||
artistInfo: artistInfoParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createFavorite: createFavoriteParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
setRating: setRatingParameters,
|
||||
topSongsList: topSongsListParameters,
|
||||
},
|
||||
_response: {
|
||||
albumArtistList,
|
||||
albumList,
|
||||
artistInfo,
|
||||
authenticate,
|
||||
baseResponse,
|
||||
createFavorite,
|
||||
musicFolderList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
setRating,
|
||||
song,
|
||||
topSongsList,
|
||||
},
|
||||
};
|
||||
+125
-170
@@ -1,50 +1,20 @@
|
||||
import {
|
||||
JFSortOrder,
|
||||
JFGenreList,
|
||||
JFAlbumList,
|
||||
JFAlbumListSort,
|
||||
JFAlbumDetail,
|
||||
JFSongList,
|
||||
JFSongListSort,
|
||||
JFAlbumArtistList,
|
||||
JFAlbumArtistListSort,
|
||||
JFAlbumArtistDetail,
|
||||
JFArtistList,
|
||||
JFArtistListSort,
|
||||
JFPlaylistList,
|
||||
JFPlaylistDetail,
|
||||
JFMusicFolderList,
|
||||
JFPlaylistListSort,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import {
|
||||
NDSortOrder,
|
||||
NDOrder,
|
||||
NDGenreList,
|
||||
NDAlbumList,
|
||||
NDAlbumListSort,
|
||||
NDAlbumDetail,
|
||||
NDSongList,
|
||||
NDSongDetail,
|
||||
NDAlbumArtistList,
|
||||
NDAlbumArtistListSort,
|
||||
NDAlbumArtistDetail,
|
||||
NDDeletePlaylist,
|
||||
NDPlaylistList,
|
||||
NDPlaylistListSort,
|
||||
NDPlaylistDetail,
|
||||
NDSongListSort,
|
||||
NDUserList,
|
||||
NDUserListSort,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import {
|
||||
SSAlbumList,
|
||||
SSAlbumDetail,
|
||||
SSAlbumArtistList,
|
||||
SSAlbumArtistDetail,
|
||||
SSMusicFolderList,
|
||||
SSGenreList,
|
||||
SSTopSongList,
|
||||
} from '/@/renderer/api/subsonic.types';
|
||||
|
||||
export enum LibraryItem {
|
||||
ALBUM = 'album',
|
||||
@@ -159,8 +129,10 @@ export type AuthenticationResponse = {
|
||||
};
|
||||
|
||||
export type Genre = {
|
||||
albumCount?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
songCount?: number;
|
||||
};
|
||||
|
||||
export type Album = {
|
||||
@@ -305,14 +277,13 @@ export type MusicFoldersResponse = MusicFolder[];
|
||||
export type ListSortOrder = NDOrder | JFSortOrder;
|
||||
|
||||
type BaseEndpointArgs = {
|
||||
_serverId?: string;
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
apiClientProps: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
};
|
||||
|
||||
// Genre List
|
||||
export type RawGenreListResponse = NDGenreList | JFGenreList | SSGenreList | undefined;
|
||||
|
||||
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
|
||||
|
||||
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
||||
@@ -320,8 +291,6 @@ export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
||||
export type GenreListQuery = null;
|
||||
|
||||
// Album List
|
||||
export type RawAlbumListResponse = NDAlbumList | SSAlbumList | JFAlbumList | undefined;
|
||||
|
||||
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
|
||||
|
||||
export enum AlbumListSort {
|
||||
@@ -343,31 +312,33 @@ export enum AlbumListSort {
|
||||
}
|
||||
|
||||
export type AlbumListQuery = {
|
||||
artistIds?: string[];
|
||||
jfParams?: {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
isFavorite?: boolean;
|
||||
maxYear?: number; // Parses to years
|
||||
minYear?: number; // Parses to years
|
||||
tags?: string;
|
||||
_custom?: {
|
||||
jellyfin?: {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
isFavorite?: boolean;
|
||||
maxYear?: number; // Parses to years
|
||||
minYear?: number; // Parses to years
|
||||
tags?: string;
|
||||
};
|
||||
navidrome?: {
|
||||
artist_id?: string;
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
};
|
||||
};
|
||||
artistIds?: string[];
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
artist_id?: string;
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumListSort;
|
||||
sortOrder: SortOrder;
|
||||
@@ -437,8 +408,6 @@ export const albumListSortMap: AlbumListSortMap = {
|
||||
};
|
||||
|
||||
// Album Detail
|
||||
export type RawAlbumDetailResponse = NDAlbumDetail | SSAlbumDetail | JFAlbumDetail | undefined;
|
||||
|
||||
export type AlbumDetailResponse = Album | null | undefined;
|
||||
|
||||
export type AlbumDetailQuery = { id: string };
|
||||
@@ -446,8 +415,6 @@ export type AlbumDetailQuery = { id: string };
|
||||
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Song List
|
||||
export type RawSongListResponse = NDSongList | JFSongList | undefined;
|
||||
|
||||
export type SongListResponse = BasePaginatedResponse<Song[]>;
|
||||
|
||||
export enum SongListSort {
|
||||
@@ -472,33 +439,35 @@ export enum SongListSort {
|
||||
}
|
||||
|
||||
export type SongListQuery = {
|
||||
_custom?: {
|
||||
jellyfin?: {
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
includeItemTypes: 'Audio';
|
||||
isFavorite?: boolean;
|
||||
maxYear?: number; // Parses to years
|
||||
minYear?: number; // Parses to years
|
||||
sortBy?: JFSongListSort;
|
||||
years?: string;
|
||||
};
|
||||
navidrome?: {
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
starred?: boolean;
|
||||
title?: string;
|
||||
year?: number;
|
||||
};
|
||||
};
|
||||
albumIds?: string[];
|
||||
artistIds?: string[];
|
||||
jfParams?: {
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
includeItemTypes: 'Audio';
|
||||
isFavorite?: boolean;
|
||||
maxYear?: number; // Parses to years
|
||||
minYear?: number; // Parses to years
|
||||
sortBy?: JFSongListSort;
|
||||
years?: string;
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
album_id?: string[];
|
||||
artist_id?: string[];
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
starred?: boolean;
|
||||
title?: string;
|
||||
year?: number;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: SongListSort;
|
||||
sortOrder: SortOrder;
|
||||
@@ -577,8 +546,6 @@ export const songListSortMap: SongListSortMap = {
|
||||
};
|
||||
|
||||
// Song Detail
|
||||
export type RawSongDetailResponse = NDSongDetail | undefined;
|
||||
|
||||
export type SongDetailResponse = Song | null | undefined;
|
||||
|
||||
export type SongDetailQuery = { id: string };
|
||||
@@ -586,13 +553,7 @@ export type SongDetailQuery = { id: string };
|
||||
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Album Artist List
|
||||
export type RawAlbumArtistListResponse =
|
||||
| NDAlbumArtistList
|
||||
| SSAlbumArtistList
|
||||
| JFAlbumArtistList
|
||||
| undefined;
|
||||
|
||||
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]>;
|
||||
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null;
|
||||
|
||||
export enum AlbumArtistListSort {
|
||||
ALBUM = 'album',
|
||||
@@ -609,13 +570,15 @@ export enum AlbumArtistListSort {
|
||||
}
|
||||
|
||||
export type AlbumArtistListQuery = {
|
||||
_custom?: {
|
||||
navidrome?: {
|
||||
genre_id?: string;
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
genre_id?: string;
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
@@ -673,21 +636,14 @@ export const albumArtistListSortMap: AlbumArtistListSortMap = {
|
||||
};
|
||||
|
||||
// Album Artist Detail
|
||||
export type RawAlbumArtistDetailResponse =
|
||||
| NDAlbumArtistDetail
|
||||
| SSAlbumArtistDetail
|
||||
| JFAlbumArtistDetail
|
||||
| undefined;
|
||||
|
||||
export type AlbumArtistDetailResponse = BasePaginatedResponse<AlbumArtist[]>;
|
||||
export type AlbumArtistDetailResponse = AlbumArtist | null;
|
||||
|
||||
export type AlbumArtistDetailQuery = { id: string };
|
||||
|
||||
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Artist List
|
||||
export type RawArtistListResponse = JFArtistList | undefined;
|
||||
|
||||
export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
|
||||
|
||||
export enum ArtistListSort {
|
||||
@@ -705,13 +661,15 @@ export enum ArtistListSort {
|
||||
}
|
||||
|
||||
export type ArtistListQuery = {
|
||||
_custom?: {
|
||||
navidrome?: {
|
||||
genre_id?: string;
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
genre_id?: string;
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
sortBy: ArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
@@ -770,31 +728,27 @@ export const artistListSortMap: ArtistListSortMap = {
|
||||
// Artist Detail
|
||||
|
||||
// Favorite
|
||||
export type RawFavoriteResponse = FavoriteResponse | undefined;
|
||||
|
||||
export type FavoriteResponse = { id: string[]; type: LibraryItem };
|
||||
export type FavoriteResponse = null | undefined;
|
||||
|
||||
export type FavoriteQuery = {
|
||||
id: string[];
|
||||
type: LibraryItem;
|
||||
};
|
||||
|
||||
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
|
||||
export type FavoriteArgs = { query: FavoriteQuery; serverId?: string } & BaseEndpointArgs;
|
||||
|
||||
// Rating
|
||||
export type RawRatingResponse = RatingResponse | undefined;
|
||||
|
||||
export type RatingResponse = null;
|
||||
export type RatingResponse = null | undefined;
|
||||
|
||||
export type RatingQuery = {
|
||||
item: AnyLibraryItems;
|
||||
rating: number;
|
||||
};
|
||||
|
||||
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
|
||||
export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs;
|
||||
|
||||
// Add to playlist
|
||||
export type RawAddToPlaylistResponse = null | undefined;
|
||||
export type AddToPlaylistResponse = null | undefined;
|
||||
|
||||
export type AddToPlaylistQuery = {
|
||||
id: string;
|
||||
@@ -807,76 +761,80 @@ export type AddToPlaylistBody = {
|
||||
export type AddToPlaylistArgs = {
|
||||
body: AddToPlaylistBody;
|
||||
query: AddToPlaylistQuery;
|
||||
serverId?: string;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Remove from playlist
|
||||
export type RawRemoveFromPlaylistResponse = null | undefined;
|
||||
export type RemoveFromPlaylistResponse = null | undefined;
|
||||
|
||||
export type RemoveFromPlaylistQuery = {
|
||||
id: string;
|
||||
songId: string[];
|
||||
};
|
||||
|
||||
export type RemoveFromPlaylistArgs = { query: RemoveFromPlaylistQuery } & BaseEndpointArgs;
|
||||
export type RemoveFromPlaylistArgs = {
|
||||
query: RemoveFromPlaylistQuery;
|
||||
serverId?: string;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Create Playlist
|
||||
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
|
||||
|
||||
export type CreatePlaylistResponse = { id: string; name: string };
|
||||
export type CreatePlaylistResponse = { id: string } | undefined;
|
||||
|
||||
export type CreatePlaylistBody = {
|
||||
_custom?: {
|
||||
navidrome?: {
|
||||
owner?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
};
|
||||
comment?: string;
|
||||
name: string;
|
||||
ndParams?: {
|
||||
owner?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type CreatePlaylistArgs = { body: CreatePlaylistBody } & BaseEndpointArgs;
|
||||
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
|
||||
|
||||
// Update Playlist
|
||||
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
|
||||
|
||||
export type UpdatePlaylistResponse = { id: string };
|
||||
export type UpdatePlaylistResponse = null | undefined;
|
||||
|
||||
export type UpdatePlaylistQuery = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type UpdatePlaylistBody = {
|
||||
_custom?: {
|
||||
navidrome?: {
|
||||
owner?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
};
|
||||
comment?: string;
|
||||
genres?: Genre[];
|
||||
name: string;
|
||||
ndParams?: {
|
||||
owner?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
rules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdatePlaylistArgs = {
|
||||
body: UpdatePlaylistBody;
|
||||
query: UpdatePlaylistQuery;
|
||||
serverId?: string;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Delete Playlist
|
||||
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined;
|
||||
|
||||
export type DeletePlaylistResponse = null;
|
||||
export type DeletePlaylistResponse = null | undefined;
|
||||
|
||||
export type DeletePlaylistQuery = { id: string };
|
||||
|
||||
export type DeletePlaylistArgs = { query: DeletePlaylistQuery } & BaseEndpointArgs;
|
||||
export type DeletePlaylistArgs = {
|
||||
query: DeletePlaylistQuery;
|
||||
serverId?: string;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
// Playlist List
|
||||
export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefined;
|
||||
|
||||
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
||||
|
||||
export enum PlaylistListSort {
|
||||
@@ -889,11 +847,13 @@ export enum PlaylistListSort {
|
||||
}
|
||||
|
||||
export type PlaylistListQuery = {
|
||||
limit?: number;
|
||||
ndParams?: {
|
||||
owner_id?: string;
|
||||
smart?: boolean;
|
||||
_custom?: {
|
||||
navidrome?: {
|
||||
owner_id?: string;
|
||||
smart?: boolean;
|
||||
};
|
||||
};
|
||||
limit?: number;
|
||||
searchTerm?: string;
|
||||
sortBy: PlaylistListSort;
|
||||
sortOrder: SortOrder;
|
||||
@@ -936,9 +896,7 @@ export const playlistListSortMap: PlaylistListSortMap = {
|
||||
};
|
||||
|
||||
// Playlist Detail
|
||||
export type RawPlaylistDetailResponse = NDPlaylistDetail | JFPlaylistDetail | undefined;
|
||||
|
||||
export type PlaylistDetailResponse = BasePaginatedResponse<Playlist[]>;
|
||||
export type PlaylistDetailResponse = Playlist;
|
||||
|
||||
export type PlaylistDetailQuery = {
|
||||
id: string;
|
||||
@@ -947,8 +905,6 @@ export type PlaylistDetailQuery = {
|
||||
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
|
||||
|
||||
// Playlist Songs
|
||||
export type RawPlaylistSongListResponse = JFSongList | undefined;
|
||||
|
||||
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
|
||||
|
||||
export type PlaylistSongListQuery = {
|
||||
@@ -962,16 +918,14 @@ export type PlaylistSongListQuery = {
|
||||
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
|
||||
|
||||
// Music Folder List
|
||||
export type RawMusicFolderListResponse = SSMusicFolderList | JFMusicFolderList | undefined;
|
||||
export type MusicFolderListResponse = BasePaginatedResponse<MusicFolder[]>;
|
||||
|
||||
export type MusicFolderListResponse = BasePaginatedResponse<Playlist[]>;
|
||||
export type MusicFolderListQuery = null;
|
||||
|
||||
export type MusicFolderListArgs = BaseEndpointArgs;
|
||||
|
||||
// User list
|
||||
// Playlist List
|
||||
export type RawUserListResponse = NDUserList | undefined;
|
||||
|
||||
export type UserListResponse = BasePaginatedResponse<User[]>;
|
||||
|
||||
export enum UserListSort {
|
||||
@@ -979,10 +933,12 @@ export enum UserListSort {
|
||||
}
|
||||
|
||||
export type UserListQuery = {
|
||||
limit?: number;
|
||||
ndParams?: {
|
||||
owner_id?: string;
|
||||
_custom?: {
|
||||
navidrome?: {
|
||||
owner_id?: string;
|
||||
};
|
||||
};
|
||||
limit?: number;
|
||||
searchTerm?: string;
|
||||
sortBy: UserListSort;
|
||||
sortOrder: SortOrder;
|
||||
@@ -1010,8 +966,6 @@ export const userListSortMap: UserListSortMap = {
|
||||
};
|
||||
|
||||
// Top Songs List
|
||||
export type RawTopSongListResponse = SSTopSongList | JFSongList | undefined;
|
||||
|
||||
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
||||
|
||||
export type TopSongListQuery = {
|
||||
@@ -1032,10 +986,11 @@ export type ArtistInfoQuery = {
|
||||
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
|
||||
|
||||
// Scrobble
|
||||
export type RawScrobbleResponse = null | undefined;
|
||||
export type ScrobbleResponse = null | undefined;
|
||||
|
||||
export type ScrobbleArgs = {
|
||||
query: ScrobbleQuery;
|
||||
serverId?: string;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type ScrobbleQuery = {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { AxiosHeaders } from 'axios';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
||||
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
||||
return z.object({
|
||||
data: itemSchema,
|
||||
headers: z.instanceof(AxiosHeaders),
|
||||
});
|
||||
};
|
||||
|
||||
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
|
||||
itemSchema: ItemType,
|
||||
) => {
|
||||
return z.object({
|
||||
'subsonic-response': z
|
||||
.object({
|
||||
status: z.string(),
|
||||
version: z.string(),
|
||||
})
|
||||
.extend(itemSchema),
|
||||
});
|
||||
};
|
||||
@@ -27,8 +27,6 @@ export * from './text';
|
||||
export * from './text-title';
|
||||
export * from './toast';
|
||||
export * from './tooltip';
|
||||
export * from './virtual-grid';
|
||||
export * from './virtual-table';
|
||||
export * from './motion';
|
||||
export * from './context-menu';
|
||||
export * from './query-builder';
|
||||
|
||||
@@ -1,50 +1,13 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import type { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { RawFavoriteResponse, FavoriteArgs, LibraryItem } from '/@/renderer/api/types';
|
||||
import { useCurrentServer, useSetAlbumListItemDataById } from '/@/renderer/store';
|
||||
|
||||
const useCreateFavorite = () => {
|
||||
const server = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
|
||||
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
|
||||
mutationFn: (args) => api.controller.createFavorite({ ...args, server }),
|
||||
onSuccess: (_data, variables) => {
|
||||
for (const id of variables.query.id) {
|
||||
// Set the userFavorite property to true for the album in the album list data store
|
||||
if (variables.query.type === LibraryItem.ALBUM) {
|
||||
setAlbumListData(id, { userFavorite: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const useDeleteFavorite = () => {
|
||||
const server = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
|
||||
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
|
||||
mutationFn: (args) => api.controller.deleteFavorite({ ...args, server }),
|
||||
onSuccess: (_data, variables) => {
|
||||
for (const id of variables.query.id) {
|
||||
// Set the userFavorite property to false for the album in the album list data store
|
||||
if (variables.query.type === LibraryItem.ALBUM) {
|
||||
setAlbumListData(id, { userFavorite: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
|
||||
export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
|
||||
const createMutation = useCreateFavorite();
|
||||
const deleteMutation = useDeleteFavorite();
|
||||
const createMutation = useCreateFavorite({});
|
||||
const deleteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
const newFavoriteValue = !value;
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { MouseEvent } from 'react';
|
||||
import type { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { Rating } from '/@/renderer/components/rating';
|
||||
import { CellContainer } from '/@/renderer/components/virtual-table/cells/generic-cell';
|
||||
import { useUpdateRating } from '/@/renderer/components/virtual-table/hooks/use-rating';
|
||||
import { useSetRating } from '/@/renderer/features/shared';
|
||||
|
||||
export const RatingCell = ({ value, node }: ICellRendererParams) => {
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!value) return;
|
||||
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
_serverId: value?.serverId,
|
||||
query: {
|
||||
item: [value],
|
||||
rating,
|
||||
},
|
||||
serverId: value?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -31,11 +32,11 @@ export const RatingCell = ({ value, node }: ICellRendererParams) => {
|
||||
e.stopPropagation();
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
_serverId: value?.serverId,
|
||||
query: {
|
||||
item: [value],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: value?.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { NDAlbumDetail, NDAlbumArtistDetail } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SSAlbumDetail, SSAlbumArtistDetail } from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
RawRatingResponse,
|
||||
RatingArgs,
|
||||
SetRatingArgs,
|
||||
Album,
|
||||
AlbumArtist,
|
||||
LibraryItem,
|
||||
AnyLibraryItems,
|
||||
RatingResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useSetAlbumListItemDataById,
|
||||
useSetQueueRating,
|
||||
useAuthStore,
|
||||
} from '/@/renderer/store';
|
||||
import { useSetAlbumListItemDataById, useSetQueueRating, getServerById } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
|
||||
export const useUpdateRating = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const currentServer = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueRating = useSetQueueRating();
|
||||
|
||||
return useMutation<
|
||||
RawRatingResponse,
|
||||
HTTPError,
|
||||
Omit<RatingArgs, 'server'>,
|
||||
RatingResponse,
|
||||
AxiosError,
|
||||
Omit<SetRatingArgs, 'server' | 'apiClientProps'>,
|
||||
{ previous: { items: AnyLibraryItems } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
|
||||
return api.controller.updateRating({ ...args, server });
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updateRating({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/no-cycle */
|
||||
import { Ref, forwardRef, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
ICellRendererParams,
|
||||
@@ -28,8 +29,8 @@ import { GenericTableHeader } from '/@/renderer/components/virtual-table/headers
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { PersistedTableColumn } from '/@/renderer/store/settings.store';
|
||||
import { TableColumn } from '/@/renderer/types';
|
||||
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
|
||||
import { FavoriteCell } from '/@/renderer/components/virtual-table/cells/favorite-cell';
|
||||
import { RatingCell } from '/@/renderer/components/virtual-table/cells/rating-cell';
|
||||
|
||||
export * from './table-config-dropdown';
|
||||
export * from './table-pagination';
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Center, Stack, Group, Divider, Box } from '@mantine/core';
|
||||
import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line } from 'react-icons/ri';
|
||||
import { RiArrowLeftSLine, RiErrorWarningLine, RiHome4Line, RiMenuFill } from 'react-icons/ri';
|
||||
import { useNavigate, useRouteError } from 'react-router';
|
||||
import { Button, Text } from '/@/renderer/components';
|
||||
import { Button, DropdownMenu, Text } from '/@/renderer/components';
|
||||
import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
|
||||
const RouteErrorBoundary = () => {
|
||||
@@ -54,6 +55,23 @@ const RouteErrorBoundary = () => {
|
||||
>
|
||||
Go home
|
||||
</Button>
|
||||
<DropdownMenu position="bottom-start">
|
||||
<DropdownMenu.Target>
|
||||
<Button
|
||||
leftIcon={<RiMenuFill />}
|
||||
size="md"
|
||||
sx={{ flex: 0.5 }}
|
||||
variant="default"
|
||||
>
|
||||
Menu
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
<AppMenu />
|
||||
</DropdownMenu.Dropdown>
|
||||
</DropdownMenu>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Button
|
||||
size="md"
|
||||
variant="filled"
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
Text,
|
||||
TextTitle,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { Button, GridCarousel, Text, TextTitle } from '/@/renderer/components';
|
||||
import { ColDef, RowDoubleClickedEvent, RowHeightParams, RowNode } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
@@ -33,6 +25,12 @@ import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/fe
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import { AlbumListSort, LibraryItem, QueueSong, SortOrder } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import {
|
||||
getColumnDefs,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components/virtual-table';
|
||||
|
||||
const isFullWidthRow = (node: RowNode) => {
|
||||
return node.id?.includes('disc-');
|
||||
@@ -60,7 +58,8 @@ interface AlbumDetailContentProps {
|
||||
|
||||
export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
@@ -165,26 +164,29 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
|
||||
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
|
||||
|
||||
const artistQuery = useAlbumList(
|
||||
{
|
||||
jfParams: {
|
||||
albumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
limit: itemsPerPage,
|
||||
ndParams: {
|
||||
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.artist * itemsPerPage,
|
||||
},
|
||||
{
|
||||
const artistQuery = useAlbumList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
enabled: detailQuery?.data?.albumArtists[0]?.id !== undefined,
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
albumArtistIds: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
navidrome: {
|
||||
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
},
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.artist * itemsPerPage,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
@@ -227,8 +229,8 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Link } from 'react-router-dom';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Button, Rating, Text } from '/@/renderer/components';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
|
||||
interface AlbumDetailHeaderProps {
|
||||
@@ -17,7 +18,8 @@ interface AlbumDetailHeaderProps {
|
||||
export const AlbumDetailHeader = forwardRef(
|
||||
({ background }: AlbumDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const metadataItems = [
|
||||
@@ -38,17 +40,17 @@ export const AlbumDetailHeader = forwardRef(
|
||||
},
|
||||
];
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery?.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -56,11 +58,11 @@ export const AlbumDetailHeader = forwardRef(
|
||||
if (!detailQuery?.data || !detailQuery?.data.userRating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
ALBUM_CARD_ROWS,
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
VirtualInfiniteGridRef,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ListDisplayType, CardRow } from '/@/renderer/types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
@@ -40,6 +32,12 @@ import { generatePath, useNavigate } from 'react-router';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import {
|
||||
VirtualInfiniteGridRef,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
|
||||
|
||||
interface AlbumListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -71,29 +69,36 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
if (!server) {
|
||||
return params.failCallback();
|
||||
}
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumList(albumsRes, server);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
return params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
@@ -165,15 +170,21 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
if (!server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -181,13 +192,15 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
return albums;
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
@@ -268,8 +281,8 @@ export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListCont
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
|
||||
@@ -20,16 +20,7 @@ import {
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListQuery, AlbumListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
ALBUM_TABLE_COLUMNS,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
MultiSelect,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
VirtualInfiniteGridRef,
|
||||
} from '/@/renderer/components';
|
||||
import { Button, DropdownMenu, MultiSelect, Slider, Switch, Text } from '/@/renderer/components';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
AlbumListFilter,
|
||||
@@ -43,6 +34,8 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { JellyfinAlbumFilters } from '/@/renderer/features/albums/components/jellyfin-album-filters';
|
||||
import { NavidromeAlbumFilters } from '/@/renderer/features/albums/components/navidrome-album-filters';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
@@ -100,7 +93,7 @@ export const AlbumListHeaderFilters = ({
|
||||
const { display, filter, table, grid } = useAlbumListStore({ id, key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
@@ -115,13 +108,15 @@ export const AlbumListHeaderFilters = ({
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
...customFilters,
|
||||
};
|
||||
@@ -132,14 +127,16 @@ export const AlbumListHeaderFilters = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
return albums;
|
||||
},
|
||||
[customFilters, queryClient, server],
|
||||
);
|
||||
@@ -157,13 +154,15 @@ export const AlbumListHeaderFilters = ({
|
||||
startIndex,
|
||||
...filters,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -173,15 +172,16 @@ export const AlbumListHeaderFilters = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumList(albumsRes, server);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
return params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
@@ -218,6 +218,7 @@ export const AlbumListHeaderFilters = ({
|
||||
handleFilterChange={handleFilterChange}
|
||||
id={id}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinAlbumFilters
|
||||
@@ -225,6 +226,7 @@ export const AlbumListHeaderFilters = ({
|
||||
handleFilterChange={handleFilterChange}
|
||||
id={id}
|
||||
pageKey={pageKey}
|
||||
serverId={server?.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -293,30 +295,32 @@ export const AlbumListHeaderFilters = ({
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
if (!itemCount || itemCount === 0 || !server) return;
|
||||
|
||||
const query = {
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumListRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => api.controller.getAlbumList({ query, server, signal }),
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey,
|
||||
});
|
||||
|
||||
const albumIds =
|
||||
api.normalize.albumList(albumListRes, server).items?.map((item) => item.id) || [];
|
||||
const albumIds = albumListRes?.items?.map((a) => a.id) || [];
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
@@ -382,16 +386,16 @@ export const AlbumListHeaderFilters = ({
|
||||
const isFilterApplied = useMemo(() => {
|
||||
const isNavidromeFilterApplied =
|
||||
server?.type === ServerType.NAVIDROME &&
|
||||
filter.ndParams &&
|
||||
Object.values(filter.ndParams).some((value) => value !== undefined);
|
||||
filter?._custom?.navidrome &&
|
||||
Object.values(filter?._custom?.navidrome).some((value) => value !== undefined);
|
||||
|
||||
const isJellyfinFilterApplied =
|
||||
server?.type === ServerType.JELLYFIN &&
|
||||
filter.jfParams &&
|
||||
Object.values(filter.jfParams).some((value) => value !== undefined);
|
||||
filter?._custom?.jellyfin &&
|
||||
Object.values(filter?._custom?.jellyfin).some((value) => value !== undefined);
|
||||
|
||||
return isNavidromeFilterApplied || isJellyfinFilterApplied;
|
||||
}, [filter.jfParams, filter.ndParams, server?.type]);
|
||||
}, [filter?._custom?.jellyfin, filter?._custom?.navidrome, server?.type]);
|
||||
|
||||
return (
|
||||
<Flex justify="space-between">
|
||||
@@ -456,7 +460,7 @@ export const AlbumListHeaderFilters = ({
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
{musicFoldersQuery.data?.items.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { PageHeader, SearchInput, VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { FilterBar, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
@@ -24,6 +24,7 @@ import { AlbumListHeaderFilters } from '/@/renderer/features/albums/components/a
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
interface AlbumListHeaderProps {
|
||||
customFilters?: Partial<AlbumListFilter>;
|
||||
@@ -54,15 +55,17 @@ export const AlbumListHeader = ({
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
},
|
||||
...customFilters,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
@@ -71,14 +74,16 @@ export const AlbumListHeader = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
return albums;
|
||||
},
|
||||
[customFilters, queryClient, server],
|
||||
);
|
||||
@@ -96,13 +101,15 @@ export const AlbumListHeader = ({
|
||||
startIndex,
|
||||
...filters,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filters.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filters.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filters._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filters._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -112,15 +119,16 @@ export const AlbumListHeader = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumList(albumsRes, server);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
@@ -164,24 +172,26 @@ export const AlbumListHeader = ({
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
...customFilters,
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
...customFilters?.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
...customFilters?.ndParams,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
...customFilters?._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
...customFilters?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumListRes = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => api.controller.getAlbumList({ query, server, signal }),
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getAlbumList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey,
|
||||
});
|
||||
|
||||
const albumIds =
|
||||
api.normalize.albumList(albumListRes, server).items?.map((item) => item.id) || [];
|
||||
const albumIds = albumListRes?.items?.map((item) => item.id) || [];
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface JellyfinAlbumFiltersProps {
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
id?: string;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const JellyfinAlbumFilters = ({
|
||||
@@ -19,24 +20,25 @@ export const JellyfinAlbumFilters = ({
|
||||
handleFilterChange,
|
||||
pageKey,
|
||||
id,
|
||||
serverId,
|
||||
}: JellyfinAlbumFiltersProps) => {
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
const genreListQuery = useGenreList(null);
|
||||
const genreListQuery = useGenreList({ query: null, serverId });
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.map((genre) => ({
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter.jfParams?.genreIds?.split(',');
|
||||
}, [filter.jfParams?.genreIds]);
|
||||
return filter._custom?.jellyfin?.genreIds?.split(',');
|
||||
}, [filter._custom?.jellyfin?.genreIds]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
@@ -44,17 +46,19 @@ export const JellyfinAlbumFilters = ({
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
includeItemTypes: 'Audio',
|
||||
isFavorite: e.currentTarget.checked ? true : undefined,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
isFavorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.jfParams?.isFavorite,
|
||||
value: filter._custom?.jellyfin?.isFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -62,9 +66,12 @@ export const JellyfinAlbumFilters = ({
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -76,9 +83,12 @@ export const JellyfinAlbumFilters = ({
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -90,9 +100,12 @@ export const JellyfinAlbumFilters = ({
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
genreIds: genreFilterString,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
genreIds: genreFilterString,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -102,17 +115,18 @@ export const JellyfinAlbumFilters = ({
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList(
|
||||
{
|
||||
const albumArtistListQuery = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
);
|
||||
serverId,
|
||||
});
|
||||
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
@@ -127,9 +141,12 @@ export const JellyfinAlbumFilters = ({
|
||||
const albumArtistFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
albumArtistIds: albumArtistFilterString,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
albumArtistIds: albumArtistFilterString,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -155,21 +172,21 @@ export const JellyfinAlbumFilters = ({
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter.jfParams?.minYear}
|
||||
defaultValue={filter._custom?.jellyfin?.minYear}
|
||||
hideControls={false}
|
||||
label="From year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter.jfParams?.maxYear}
|
||||
required={!!filter._custom?.jellyfin?.maxYear}
|
||||
onChange={(e) => handleMinYearFilter(e)}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter.jfParams?.maxYear}
|
||||
defaultValue={filter._custom?.jellyfin?.maxYear}
|
||||
hideControls={false}
|
||||
label="To year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
required={!!filter.jfParams?.minYear}
|
||||
required={!!filter._custom?.jellyfin?.minYear}
|
||||
onChange={(e) => handleMaxYearFilter(e)}
|
||||
/>
|
||||
</Group>
|
||||
@@ -189,7 +206,7 @@ export const JellyfinAlbumFilters = ({
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter.jfParams?.albumArtistIds?.split(',')}
|
||||
defaultValue={filter._custom?.jellyfin?.albumArtistIds?.split(',')}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface NavidromeAlbumFiltersProps {
|
||||
handleFilterChange: (filters: AlbumListFilter) => void;
|
||||
id?: string;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const NavidromeAlbumFilters = ({
|
||||
@@ -19,15 +20,16 @@ export const NavidromeAlbumFilters = ({
|
||||
disableArtistFilter,
|
||||
pageKey,
|
||||
id,
|
||||
serverId,
|
||||
}: NavidromeAlbumFiltersProps) => {
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setFilter } = useListStoreActions();
|
||||
|
||||
const genreListQuery = useGenreList(null);
|
||||
const genreListQuery = useGenreList({ query: null, serverId });
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.map((genre) => ({
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
@@ -36,9 +38,12 @@ export const NavidromeAlbumFilters = ({
|
||||
const handleGenresFilter = debounce((e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
genre_id: e || undefined,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
genre_id: e || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: 'album',
|
||||
@@ -52,70 +57,89 @@ export const NavidromeAlbumFilters = ({
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
has_rating: e.currentTarget.checked ? true : undefined,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
has_rating: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.ndParams?.has_rating,
|
||||
value: filter._custom?.navidrome?.has_rating,
|
||||
},
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
console.log('e.currentTarget.checked :>> ', e.currentTarget.checked);
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: { ...filter.ndParams, starred: e.currentTarget.checked ? true : undefined },
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
starred: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.ndParams?.starred,
|
||||
value: filter._custom?.navidrome?.starred,
|
||||
},
|
||||
{
|
||||
label: 'Is compilation',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
compilation: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.ndParams?.compilation,
|
||||
value: filter._custom?.navidrome?.compilation,
|
||||
},
|
||||
{
|
||||
label: 'Is recently played',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
recently_played: e.currentTarget.checked ? true : undefined,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
recently_played: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.ndParams?.recently_played,
|
||||
value: filter._custom?.navidrome?.recently_played,
|
||||
},
|
||||
];
|
||||
|
||||
const handleYearFilter = debounce((e: number | string) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
_custom: {
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
year: e === '' ? undefined : (e as number),
|
||||
},
|
||||
...filter._custom,
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -125,18 +149,19 @@ export const NavidromeAlbumFilters = ({
|
||||
|
||||
const [albumArtistSearchTerm, setAlbumArtistSearchTerm] = useState<string>('');
|
||||
|
||||
const albumArtistListQuery = useAlbumArtistList(
|
||||
{
|
||||
const albumArtistListQuery = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
query: {
|
||||
// searchTerm: debouncedSearchTerm,
|
||||
sortBy: AlbumArtistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
},
|
||||
);
|
||||
serverId,
|
||||
});
|
||||
|
||||
const selectableAlbumArtists = useMemo(() => {
|
||||
if (!albumArtistListQuery?.data?.items) return [];
|
||||
@@ -150,9 +175,12 @@ export const NavidromeAlbumFilters = ({
|
||||
const handleAlbumArtistFilter = (e: string | null) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
artist_id: e || undefined,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
artist_id: e || undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -177,7 +205,7 @@ export const NavidromeAlbumFilters = ({
|
||||
<Divider my="0.5rem" />
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
defaultValue={filter.ndParams?.year}
|
||||
defaultValue={filter._custom?.navidrome?.year}
|
||||
hideControls={false}
|
||||
label="Year"
|
||||
max={5000}
|
||||
@@ -188,7 +216,7 @@ export const NavidromeAlbumFilters = ({
|
||||
clearable
|
||||
searchable
|
||||
data={genreList}
|
||||
defaultValue={filter.ndParams?.genre_id}
|
||||
defaultValue={filter._custom?.navidrome?.genre_id}
|
||||
label="Genre"
|
||||
onChange={handleGenresFilter}
|
||||
/>
|
||||
@@ -198,7 +226,7 @@ export const NavidromeAlbumFilters = ({
|
||||
clearable
|
||||
searchable
|
||||
data={selectableAlbumArtists}
|
||||
defaultValue={filter.ndParams?.artist_id}
|
||||
defaultValue={filter._custom?.navidrome?.artist_id}
|
||||
disabled={disableArtistFilter}
|
||||
label="Artist"
|
||||
limit={300}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
import type { AlbumDetailQuery, RawAlbumDetailResponse } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '../../../store/auth.store';
|
||||
import type { AlbumDetailQuery } from '/@/renderer/api/types';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { useCallback } from 'react';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const useAlbumDetail = (query: AlbumDetailQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useAlbumDetail = (args: QueryHookArgs<AlbumDetailQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) => controller.getAlbumDetail({ query, server, signal }),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return controller.getAlbumDetail({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.albums.detail(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawAlbumDetailResponse | undefined) => api.normalize.albumDetail(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumListQuery, RawAlbumListResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
import type { AlbumListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useAlbumList = (query: AlbumListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useAlbumList = (args: QueryHookArgs<AlbumListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => controller.getAlbumList({ query, server, signal }),
|
||||
queryKey: queryKeys.albums.list(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawAlbumListResponse | undefined) => api.normalize.albumList(data, server),
|
||||
[server],
|
||||
),
|
||||
enabled: !!serverId,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albums.list(serverId || '', query),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AlbumDetailHeader } from '/@/renderer/features/albums/components/album-
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const AlbumDetailRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
@@ -17,7 +18,8 @@ const AlbumDetailRoute = () => {
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { albumId } = useParams() as { albumId: string };
|
||||
const detailQuery = useAlbumDetail({ id: albumId });
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumDetail({ query: { id: albumId }, serverId: server?.id });
|
||||
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { AlbumListHeader } from '/@/renderer/features/albums/components/album-list-header';
|
||||
import { AlbumListContent } from '/@/renderer/features/albums/components/album-list-content';
|
||||
@@ -24,17 +24,18 @@ const AlbumListRoute = () => {
|
||||
|
||||
const albumListFilter = useAlbumListFilter({ id: albumArtistId || undefined, key: pageKey });
|
||||
|
||||
const itemCountCheck = useAlbumList(
|
||||
{
|
||||
const itemCountCheck = useAlbumList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumListFilter,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
Text,
|
||||
TextTitle,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { Button, GridCarousel, Text, TextTitle } from '/@/renderer/components';
|
||||
import { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
@@ -38,6 +31,7 @@ import {
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -63,7 +57,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
const server = useCurrentServer();
|
||||
const itemsPerPage = cq.isXl ? 9 : cq.isLg ? 7 : cq.isMd ? 5 : cq.isSm ? 4 : 3;
|
||||
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
|
||||
|
||||
const artistDiscographyLink = `${generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY, {
|
||||
albumArtistId,
|
||||
@@ -80,34 +74,57 @@ export const AlbumArtistDetailContent = () => {
|
||||
})}`;
|
||||
|
||||
const recentAlbumsQuery = useAlbumList({
|
||||
jfParams: server?.type === ServerType.JELLYFIN ? { artistIds: albumArtistId } : undefined,
|
||||
limit: itemsPerPage,
|
||||
ndParams:
|
||||
server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: false }
|
||||
: undefined,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...(server?.type === ServerType.JELLYFIN ? { artistIds: albumArtistId } : undefined),
|
||||
},
|
||||
navidrome: {
|
||||
...(server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: false }
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const compilationAlbumsQuery = useAlbumList({
|
||||
jfParams:
|
||||
server?.type === ServerType.JELLYFIN ? { contributingArtistIds: albumArtistId } : undefined,
|
||||
limit: itemsPerPage,
|
||||
ndParams:
|
||||
server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: true }
|
||||
: undefined,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
query: {
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...(server?.type === ServerType.JELLYFIN
|
||||
? { contributingArtistIds: albumArtistId }
|
||||
: undefined),
|
||||
},
|
||||
navidrome: {
|
||||
...(server?.type === ServerType.NAVIDROME
|
||||
? { artist_id: albumArtistId, compilation: true }
|
||||
: undefined),
|
||||
},
|
||||
},
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.RELEASE_DATE,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const topSongsQuery = useTopSongsList(
|
||||
{ artist: detailQuery?.data?.name || '', artistId: albumArtistId },
|
||||
{ enabled: !!detailQuery?.data?.name },
|
||||
);
|
||||
const topSongsQuery = useTopSongsList({
|
||||
options: {
|
||||
enabled: !!detailQuery?.data?.name,
|
||||
},
|
||||
query: {
|
||||
artist: detailQuery?.data?.name || '',
|
||||
artistId: albumArtistId,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const topSongsColumnDefs: ColDef[] = useMemo(
|
||||
() =>
|
||||
@@ -242,8 +259,8 @@ export const AlbumArtistDetailContent = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Group, Rating, Stack } from '@mantine/core';
|
||||
import { forwardRef, Fragment, Ref, MouseEvent } from 'react';
|
||||
import { Group, Rating, Stack } from '@mantine/core';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Text } from '/@/renderer/components';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { LibraryHeader, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { LibraryHeader, useSetRating } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
interface AlbumArtistDetailHeaderProps {
|
||||
background: string;
|
||||
@@ -16,7 +17,11 @@ interface AlbumArtistDetailHeaderProps {
|
||||
export const AlbumArtistDetailHeader = forwardRef(
|
||||
({ background }: AlbumArtistDetailHeaderProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = useAlbumArtistDetail({
|
||||
query: { id: albumArtistId },
|
||||
serverId: server?.id,
|
||||
});
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const metadataItems = [
|
||||
@@ -37,17 +42,17 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
},
|
||||
];
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const handleUpdateRating = (rating: number) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery?.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating,
|
||||
},
|
||||
serverId: detailQuery?.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -58,11 +63,11 @@ export const AlbumArtistDetailHeader = forwardRef(
|
||||
if (!isSameRatingAsPrevious) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: detailQuery.data.serverId,
|
||||
query: {
|
||||
item: [detailQuery.data],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -1,13 +1,14 @@
|
||||
import { MutableRefObject, useMemo } from 'react';
|
||||
import type { ColDef, RowDoubleClickedEvent } from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { getColumnDefs, VirtualGridAutoSizerContainer, VirtualTable } from '/@/renderer/components';
|
||||
import { useCurrentServer, useSongListStore } from '/@/renderer/store';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { SONG_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
|
||||
interface AlbumArtistSongListContentProps {
|
||||
data: QueueSong[];
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
ALBUMARTIST_CARD_ROWS,
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
VirtualInfiniteGridRef,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { ALBUMARTIST_CARD_ROWS } from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ListDisplayType, CardRow } from '/@/renderer/types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
@@ -35,6 +27,12 @@ import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-a
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useAlbumArtistListFilter, useListStoreActions } from '../../../store/list.store';
|
||||
import { useAlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
|
||||
import {
|
||||
VirtualInfiniteGridRef,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
|
||||
|
||||
interface AlbumArtistListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -54,17 +52,18 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
|
||||
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkAlbumArtistList = useAlbumArtistList(
|
||||
{
|
||||
const checkAlbumArtistList = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: Infinity,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filter,
|
||||
},
|
||||
{
|
||||
cacheTime: Infinity,
|
||||
staleTime: 60 * 1000 * 5,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
|
||||
|
||||
@@ -85,19 +84,23 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(albums?.items || [], albumArtistsRes?.totalRecordCount || 0);
|
||||
params.successCallback(
|
||||
albumArtistsRes?.items || [],
|
||||
albumArtistsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
@@ -181,18 +184,20 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
return albumArtistsRes;
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
@@ -259,27 +264,29 @@ export const AlbumArtistListContent = ({ gridRef, tableRef }: AlbumArtistListCon
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.ALBUM_ARTIST}
|
||||
loading={checkAlbumArtistList.isLoading}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
<>
|
||||
<VirtualInfiniteGrid
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
itemCount={checkAlbumArtistList?.data?.totalRecordCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.ALBUM_ARTIST}
|
||||
loading={checkAlbumArtistList.isLoading}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
|
||||
@@ -15,16 +15,7 @@ import {
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
ALBUMARTIST_TABLE_COLUMNS,
|
||||
VirtualInfiniteGridRef,
|
||||
Text,
|
||||
Button,
|
||||
Slider,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
} from '/@/renderer/components';
|
||||
import { DropdownMenu, Text, Button, Slider, MultiSelect, Switch } from '/@/renderer/components';
|
||||
import { useMusicFolders } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
@@ -36,6 +27,8 @@ import {
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn, ServerType } from '/@/renderer/types';
|
||||
import { useAlbumArtistListContext } from '../context/album-artist-list-context';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { ALBUMARTIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
@@ -83,7 +76,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
const filter = useAlbumArtistListFilter({ key: pageKey });
|
||||
const cq = useContainerQuery();
|
||||
|
||||
const musicFoldersQuery = useMusicFolders();
|
||||
const musicFoldersQuery = useMusicFolders({ query: null, serverId: server?.id });
|
||||
|
||||
const sortByLabel =
|
||||
(server?.type &&
|
||||
@@ -114,18 +107,20 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albums, server);
|
||||
return albums;
|
||||
},
|
||||
[queryClient, server],
|
||||
);
|
||||
@@ -148,20 +143,21 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(
|
||||
albumArtists?.items || [],
|
||||
albumArtistsRes?.items || [],
|
||||
albumArtistsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
@@ -355,7 +351,7 @@ export const AlbumArtistListHeaderFilters = ({
|
||||
</Button>
|
||||
</DropdownMenu.Target>
|
||||
<DropdownMenu.Dropdown>
|
||||
{musicFoldersQuery.data?.map((folder) => (
|
||||
{musicFoldersQuery.data?.items.map((folder) => (
|
||||
<DropdownMenu.Item
|
||||
key={`musicFolder-${folder.id}`}
|
||||
$isActive={filter.musicFolderId === folder.id}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { PageHeader, SearchInput, VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { PageHeader, SearchInput } from '/@/renderer/components';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import { ListDisplayType } from '/@/renderer/types';
|
||||
import { AlbumArtistListHeaderFilters } from '/@/renderer/features/artists/components/album-artist-list-header-filters';
|
||||
import { useAlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
|
||||
import { FilterBar } from '../../shared/components/filter-bar';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
interface AlbumArtistListHeaderProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -51,18 +52,20 @@ export const AlbumArtistListHeader = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return api.normalize.albumArtistList(albums, server);
|
||||
return albums;
|
||||
},
|
||||
[queryClient, server],
|
||||
);
|
||||
@@ -85,20 +88,21 @@ export const AlbumArtistListHeader = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumArtistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albumArtists = api.normalize.albumArtistList(albumArtistsRes, server);
|
||||
params.successCallback(
|
||||
albumArtists?.items || [],
|
||||
albumArtistsRes?.items || [],
|
||||
albumArtistsRes?.totalRecordCount || 0,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import type { AlbumArtistDetailQuery } from '/@/renderer/api/types';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { QueryHookArgs } from '../../../lib/react-query';
|
||||
|
||||
export const useAlbumArtistDetail = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useAlbumArtistDetail = (args: QueryHookArgs<AlbumArtistDetailQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id && !!query.id,
|
||||
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumArtistDetail({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawAlbumArtistDetailResponse | undefined) =>
|
||||
api.normalize.albumArtistDetail(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumArtistListQuery, RawAlbumArtistListResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import type { AlbumArtistListQuery } from '/@/renderer/api/types';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { QueryHookArgs } from '../../../lib/react-query';
|
||||
|
||||
export const useAlbumArtistList = (query: AlbumArtistListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useAlbumArtistList = (args: QueryHookArgs<AlbumArtistListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getAlbumArtistList({ query, server, signal }),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumArtistList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.list(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawAlbumArtistListResponse | undefined) => api.normalize.albumArtistList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { AlbumArtistDetailQuery, RawAlbumArtistDetailResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import type { AlbumArtistDetailQuery } from '/@/renderer/api/types';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { QueryHookArgs } from '../../../lib/react-query';
|
||||
|
||||
export const useAlbumArtistInfo = (query: AlbumArtistDetailQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useAlbumArtistInfo = (args: QueryHookArgs<AlbumArtistDetailQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id && !!query.id,
|
||||
queryFn: ({ signal }) => api.controller.getAlbumArtistDetail({ query, server, signal }),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumArtistDetail({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.detail(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawAlbumArtistDetailResponse | undefined) =>
|
||||
api.normalize.albumArtistDetail(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { RawTopSongListResponse, TopSongListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import type { TopSongListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const useTopSongsList = (query: TopSongListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useTopSongsList = (args: QueryHookArgs<TopSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getTopSongList({ query, server, signal }),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getTopSongList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.albumArtists.topSongs(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawTopSongListResponse | undefined) => api.normalize.topSongList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,15 +9,17 @@ import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { AlbumArtistDetailHeader } from '/@/renderer/features/artists/components/album-artist-detail-header';
|
||||
import { AlbumArtistDetailContent } from '/@/renderer/features/artists/components/album-artist-detail-content';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const AlbumArtistDetailRoute = () => {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const server = useCurrentServer();
|
||||
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
|
||||
const background = useFastAverageColor(detailQuery.data?.imageUrl, !detailQuery.isLoading);
|
||||
|
||||
const handlePlay = () => {
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useParams } from 'react-router';
|
||||
import { AlbumArtistDetailTopSongsListContent } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-content';
|
||||
import { AlbumArtistDetailTopSongsListHeader } from '/@/renderer/features/artists/components/album-artist-detail-top-songs-list-header';
|
||||
import { useAlbumArtistDetail } from '/@/renderer/features/artists/queries/album-artist-detail-query';
|
||||
import { useTopSongsList } from '/@/renderer/features/artists/queries/top-songs-list-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
const AlbumArtistDetailTopSongsListRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { albumArtistId } = useParams() as { albumArtistId: string };
|
||||
const server = useCurrentServer();
|
||||
|
||||
const detailQuery = useAlbumArtistDetail({ id: albumArtistId });
|
||||
const detailQuery = useAlbumArtistDetail({ query: { id: albumArtistId }, serverId: server?.id });
|
||||
|
||||
const topSongsQuery = useTopSongsList(
|
||||
{ artist: detailQuery?.data?.name || '', artistId: albumArtistId },
|
||||
{ enabled: !!detailQuery?.data?.name },
|
||||
);
|
||||
const topSongsQuery = useTopSongsList({
|
||||
options: { enabled: !!detailQuery?.data?.name },
|
||||
query: { artist: detailQuery?.data?.name || '', artistId: albumArtistId },
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount = topSongsQuery?.data?.items?.length || 0;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components';
|
||||
import { AlbumArtistListHeader } from '/@/renderer/features/artists/components/album-artist-list-header';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
@@ -7,25 +6,29 @@ import { AlbumArtistListContent } from '/@/renderer/features/artists/components/
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { generatePageKey, useAlbumArtistListFilter } from '/@/renderer/store';
|
||||
import { AlbumArtistListContext } from '/@/renderer/features/artists/context/album-artist-list-context';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
const AlbumArtistListRoute = () => {
|
||||
const gridRef = useRef<VirtualInfiniteGridRef | null>(null);
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const pageKey = generatePageKey('albumArtist', undefined);
|
||||
const server = useCurrentServer();
|
||||
|
||||
const albumArtistListFilter = useAlbumArtistListFilter({ id: undefined, key: pageKey });
|
||||
|
||||
const itemCountCheck = useAlbumArtistList(
|
||||
{
|
||||
const itemCountCheck = useAlbumArtistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...albumArtistListFilter,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useDeletePlaylist } from '/@/renderer/features/playlists';
|
||||
import { useRemoveFromPlaylist } from '/@/renderer/features/playlists/mutations/remove-from-playlist-mutation';
|
||||
import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
||||
import { useAuthStore, useCurrentServer, useQueueControls } from '/@/renderer/store';
|
||||
import { usePlayerType } from '/@/renderer/store/settings.store';
|
||||
import { Play, PlaybackType } from '/@/renderer/types';
|
||||
@@ -190,7 +190,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
[ctx.data, ctx.type, handlePlayQueueAdd],
|
||||
);
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist();
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = useCallback(() => {
|
||||
for (const item of ctx.data) {
|
||||
@@ -236,8 +236,8 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
});
|
||||
}, [ctx.data, handleDeletePlaylist]);
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
const handleAddToFavorites = useCallback(() => {
|
||||
if (!ctx.dataNodes && !ctx.data) return;
|
||||
|
||||
@@ -414,7 +414,7 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
serverType,
|
||||
]);
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
const updateRatingMutation = useSetRating({});
|
||||
|
||||
const handleUpdateRating = useCallback(
|
||||
(rating: number) => {
|
||||
@@ -450,11 +450,11 @@ export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
|
||||
|
||||
updateRatingMutation.mutate(
|
||||
{
|
||||
_serverId: serverId,
|
||||
query: {
|
||||
item: items,
|
||||
rating,
|
||||
},
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { GenreListQuery, RawGenreListResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
import type { GenreListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useGenreList = (query: GenreListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useGenreList = (args: QueryHookArgs<GenreListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => controller.getGenreList({ query, server, signal }),
|
||||
enabled: !!server,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return controller.getGenreList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.genres.list(server?.id || ''),
|
||||
select: useCallback(
|
||||
(data: RawGenreListResponse | undefined) => api.normalize.genreList(data, server),
|
||||
[server],
|
||||
),
|
||||
staleTime: 1000 * 60 * 60,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import { NDAlbum } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
AlbumListQuery,
|
||||
AlbumListSort,
|
||||
RawAlbumListResponse,
|
||||
SortOrder,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { AlbumListQuery, AlbumListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
|
||||
export const useRecentlyPlayed = (query: Partial<AlbumListQuery>, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const useRecentlyPlayed = (args: QueryHookArgs<Partial<AlbumListQuery>>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
const requestQuery: AlbumListQuery = {
|
||||
limit: 5,
|
||||
@@ -25,34 +18,19 @@ export const useRecentlyPlayed = (query: Partial<AlbumListQuery>, options?: Quer
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: requestQuery,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
|
||||
select: useCallback(
|
||||
(data: RawAlbumListResponse | undefined) => {
|
||||
let albums;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
break;
|
||||
case 'navidrome':
|
||||
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
return {
|
||||
items: albums,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
},
|
||||
[server],
|
||||
),
|
||||
queryKey: queryKeys.albums.list(server?.id || '', requestQuery),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -23,75 +23,80 @@ const HomeRoute = () => {
|
||||
recentlyPlayed: 0,
|
||||
});
|
||||
|
||||
const feature = useAlbumList(
|
||||
{
|
||||
const feature = useAlbumList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: 20,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: 0,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const featureItemsWithImage = useMemo(() => {
|
||||
return feature.data?.items?.filter((item) => item.imageUrl) ?? [];
|
||||
}, [feature.data?.items]);
|
||||
|
||||
const random = useAlbumList(
|
||||
{
|
||||
const random = useAlbumList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60,
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.RANDOM,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: pagination.random * itemsPerPage,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const recentlyPlayed = useRecentlyPlayed(
|
||||
{
|
||||
const recentlyPlayed = useRecentlyPlayed({
|
||||
options: {
|
||||
keepPreviousData: true,
|
||||
staleTime: 0,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.RECENTLY_PLAYED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.recentlyPlayed * itemsPerPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
staleTime: 0,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const recentlyAdded = useAlbumList(
|
||||
{
|
||||
const recentlyAdded = useAlbumList({
|
||||
options: {
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.RECENTLY_ADDED,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.recentlyAdded * itemsPerPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const mostPlayed = useAlbumList(
|
||||
{
|
||||
const mostPlayed = useAlbumList({
|
||||
options: {
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
},
|
||||
query: {
|
||||
limit: itemsPerPage,
|
||||
sortBy: AlbumListSort.PLAY_COUNT,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.mostPlayed * itemsPerPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
staleTime: 1000 * 60 * 60,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const handleNextPage = useCallback(
|
||||
(key: 'mostPlayed' | 'random' | 'recentlyAdded' | 'recentlyPlayed') => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MutableRefObject } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { Group } from '@mantine/core';
|
||||
import { Button, Popover, TableConfigDropdown } from '/@/renderer/components';
|
||||
import { Button, Popover } from '/@/renderer/components';
|
||||
import isElectron from 'is-electron';
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
@@ -16,6 +16,7 @@ import { usePlayerControls, useQueueControls } from '/@/renderer/store';
|
||||
import { PlaybackType, TableType } from '/@/renderer/types';
|
||||
import { usePlayerType } from '/@/renderer/store/settings.store';
|
||||
import { useSetCurrentTime } from '../../../store/player.store';
|
||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import '@ag-grid-community/styles/ag-theme-alpine.css';
|
||||
import { VirtualGridAutoSizerContainer, getColumnDefs } from '/@/renderer/components';
|
||||
import {
|
||||
useAppStoreActions,
|
||||
useCurrentSong,
|
||||
@@ -27,12 +26,13 @@ import { useMergedRef } from '@mantine/hooks';
|
||||
import isElectron from 'is-electron';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { getColumnDefs, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { ErrorFallback } from '/@/renderer/features/action-required';
|
||||
import { PlaybackType, TableType } from '/@/renderer/types';
|
||||
import { LibraryItem, QueueSong } from '/@/renderer/api/types';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { QUEUE_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
const utils = isElectron() ? window.electron.utils : null;
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Stack } from '@mantine/core';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import { PlayQueueListControls } from './play-queue-list-controls';
|
||||
import { Song } from '/@/renderer/api/types';
|
||||
import { PageHeader, Paper, VirtualGridContainer } from '/@/renderer/components';
|
||||
import { PageHeader, Paper } from '/@/renderer/components';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
export const SidebarPlayQueue = () => {
|
||||
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
|
||||
|
||||
@@ -4,8 +4,9 @@ import { NowPlayingHeader } from '/@/renderer/features/now-playing/components/no
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import type { Song } from '/@/renderer/api/types';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { Paper, VirtualGridContainer } from '/@/renderer/components';
|
||||
import { Paper } from '/@/renderer/components';
|
||||
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
|
||||
import { VirtualGridContainer } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
const NowPlayingRoute = () => {
|
||||
const queueRef = useRef<{ grid: AgGridReactType<Song> } | null>(null);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Variants, motion } from 'framer-motion';
|
||||
import { RiArrowDownSLine, RiSettings3Line } from 'react-icons/ri';
|
||||
import { useLocation } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
import { Button, Option, Popover, Switch, TableConfigDropdown } from '/@/renderer/components';
|
||||
import { Button, Option, Popover, Switch } from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentSong,
|
||||
useFullScreenPlayerStore,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useFastAverageColor } from '../../../hooks/use-fast-average-color';
|
||||
import { FullScreenPlayerImage } from '/@/renderer/features/player/components/full-screen-player-image';
|
||||
import { FullScreenPlayerQueue } from '/@/renderer/features/player/components/full-screen-player-queue';
|
||||
import { TableConfigDropdown } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const Container = styled(motion.div)`
|
||||
z-index: 100;
|
||||
|
||||
@@ -36,6 +36,7 @@ export const PlayerbarSlider = ({ ...props }: SliderProps) => {
|
||||
track: {
|
||||
'&::before': {
|
||||
backgroundColor: 'var(--playerbar-slider-track-bg)',
|
||||
right: 'calc(0.1rem * -1)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { useRightControls } from '../hooks/use-right-controls';
|
||||
import { PlayerButton } from './player-button';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { useCreateFavorite, useDeleteFavorite, useUpdateRating } from '/@/renderer/features/shared';
|
||||
import { useCreateFavorite, useDeleteFavorite, useSetRating } from '/@/renderer/features/shared';
|
||||
import { Rating } from '/@/renderer/components';
|
||||
import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider';
|
||||
|
||||
@@ -32,9 +32,9 @@ export const RightControls = () => {
|
||||
const { rightExpanded: isQueueExpanded } = useSidebarStore();
|
||||
const { handleVolumeSlider, handleVolumeWheel, handleMute } = useRightControls();
|
||||
|
||||
const updateRatingMutation = useUpdateRating();
|
||||
const addToFavoritesMutation = useCreateFavorite();
|
||||
const removeFromFavoritesMutation = useDeleteFavorite();
|
||||
const updateRatingMutation = useSetRating({});
|
||||
const addToFavoritesMutation = useCreateFavorite({});
|
||||
const removeFromFavoritesMutation = useDeleteFavorite({});
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!currentSong) return;
|
||||
@@ -44,6 +44,7 @@ export const RightControls = () => {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,11 +52,11 @@ export const RightControls = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -63,11 +64,11 @@ export const RightControls = () => {
|
||||
if (!currentSong || !rating) return;
|
||||
|
||||
updateRatingMutation.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
item: [currentSong],
|
||||
rating: 0,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -79,6 +80,7 @@ export const RightControls = () => {
|
||||
id: [currentSong.id],
|
||||
type: LibraryItem.SONG,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api/index';
|
||||
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
|
||||
import { JFSong } from '/@/renderer/api/jellyfin.types';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import { NDSong } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
useAuthStore,
|
||||
useCurrentServer,
|
||||
usePlayerControls,
|
||||
usePlayerStore,
|
||||
} from '/@/renderer/store';
|
||||
import { useCurrentServer, usePlayerControls, usePlayerStore } from '/@/renderer/store';
|
||||
import { usePlayerType } from '/@/renderer/store/settings.store';
|
||||
import { PlayQueueAddOptions, Play, PlaybackType } from '/@/renderer/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import isElectron from 'is-electron';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import { LibraryItem, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
@@ -25,7 +16,6 @@ const mpris = isElectron() && utils?.isLinux() ? window.electron.mpris : null;
|
||||
export const useHandlePlayQueueAdd = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const playerType = usePlayerType();
|
||||
const deviceId = useAuthStore.getState().deviceId;
|
||||
const server = useCurrentServer();
|
||||
const { play } = usePlayerControls();
|
||||
|
||||
@@ -114,9 +104,11 @@ export const useHandlePlayQueueAdd = () => {
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
@@ -128,9 +120,11 @@ export const useHandlePlayQueueAdd = () => {
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: queryFilter,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{
|
||||
cacheTime: 1000 * 60,
|
||||
@@ -147,20 +141,7 @@ export const useHandlePlayQueueAdd = () => {
|
||||
|
||||
if (!songsList) return toast.warn({ message: 'No songs found' });
|
||||
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
songs = songsList.items?.map((song) =>
|
||||
jfNormalize.song(song as JFSong, server, deviceId),
|
||||
);
|
||||
break;
|
||||
case 'navidrome':
|
||||
songs = songsList.items?.map((song) =>
|
||||
ndNormalize.song(song as NDSong, server, deviceId),
|
||||
);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
songs = songsList.items?.map((song) => ({ ...song, uniqueId: nanoid() }));
|
||||
} else if (options.byData) {
|
||||
songs = options.byData.map((song) => ({ ...song, uniqueId: nanoid() }));
|
||||
}
|
||||
@@ -207,7 +188,7 @@ export const useHandlePlayQueueAdd = () => {
|
||||
|
||||
return null;
|
||||
},
|
||||
[deviceId, play, playerType, queryClient, server],
|
||||
[play, playerType, queryClient, server],
|
||||
);
|
||||
|
||||
return handlePlayQueueAdd;
|
||||
|
||||
@@ -67,13 +67,13 @@ export const useScrobble = () => {
|
||||
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
||||
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'timeupdate',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
},
|
||||
[isScrobbleEnabled, sendScrobble],
|
||||
@@ -110,12 +110,12 @@ export const useScrobble = () => {
|
||||
previousSong?.serverType === ServerType.JELLYFIN ? previousSongTime * 1e7 : undefined;
|
||||
|
||||
sendScrobble.mutate({
|
||||
_serverId: previousSong?.serverId,
|
||||
query: {
|
||||
id: previousSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
serverId: previousSong?.serverId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,13 @@ export const useScrobble = () => {
|
||||
// Send start scrobble when song changes and the new song is playing
|
||||
if (status === PlayerStatus.PLAYING && currentSong?.id) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
@@ -175,13 +175,13 @@ export const useScrobble = () => {
|
||||
// Whenever the player is restarted, send a 'start' scrobble
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'unpause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
@@ -194,13 +194,13 @@ export const useScrobble = () => {
|
||||
// Jellyfin is the only one that needs to send a 'pause' event to the server
|
||||
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'pause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
@@ -217,11 +217,11 @@ export const useScrobble = () => {
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
submission: true,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
|
||||
setIsCurrentSongScrobbled(true);
|
||||
@@ -261,24 +261,24 @@ export const useScrobble = () => {
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
serverId: currentSong?.serverId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { RawScrobbleResponse, ScrobbleArgs } from '/@/renderer/api/types';
|
||||
import { ScrobbleResponse, ScrobbleArgs } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useAuthStore, useCurrentServer, useIncrementQueuePlayCount } from '/@/renderer/store';
|
||||
import { getServerById, useIncrementQueuePlayCount } from '/@/renderer/store';
|
||||
|
||||
export const useSendScrobble = (options?: MutationOptions) => {
|
||||
const currentServer = useCurrentServer();
|
||||
const incrementPlayCount = useIncrementQueuePlayCount();
|
||||
|
||||
return useMutation<RawScrobbleResponse, HTTPError, Omit<ScrobbleArgs, 'server'>, null>({
|
||||
return useMutation<
|
||||
ScrobbleResponse,
|
||||
AxiosError,
|
||||
Omit<ScrobbleArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
|
||||
return api.controller.scrobble({ ...args, server });
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.scrobble({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Manually increment the play count for the song in the queue if scrobble was submitted
|
||||
|
||||
@@ -23,15 +23,20 @@ export const AddToPlaylistContextModal = ({
|
||||
const server = useCurrentServer();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const addToPlaylistMutation = useAddToPlaylist();
|
||||
const addToPlaylistMutation = useAddToPlaylist({});
|
||||
|
||||
const playlistList = usePlaylistList({
|
||||
ndParams: {
|
||||
smart: false,
|
||||
query: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
smart: false,
|
||||
},
|
||||
},
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const playlistSelect = useMemo(() => {
|
||||
@@ -60,11 +65,12 @@ export const AddToPlaylistContextModal = ({
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) =>
|
||||
api.controller.getSongList({ query, server, signal }),
|
||||
);
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||
});
|
||||
|
||||
return api.normalize.songList(songsRes, server);
|
||||
return songsRes;
|
||||
};
|
||||
|
||||
const getSongsByArtist = async (artistId: string) => {
|
||||
@@ -77,11 +83,12 @@ export const AddToPlaylistContextModal = ({
|
||||
|
||||
const queryKey = queryKeys.songs.list(server?.id || '', query);
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) =>
|
||||
api.controller.getSongList({ query, server, signal }),
|
||||
);
|
||||
const songsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getSongList({ apiClientProps: { server, signal }, query });
|
||||
});
|
||||
|
||||
return api.normalize.songList(songsRes, server);
|
||||
return songsRes;
|
||||
};
|
||||
|
||||
const isSubmitDisabled = form.values.playlistId.length === 0 || addToPlaylistMutation.isLoading;
|
||||
@@ -118,17 +125,18 @@ export const AddToPlaylistContextModal = ({
|
||||
|
||||
const queryKey = queryKeys.playlists.songList(server?.id || '', playlistId, query);
|
||||
|
||||
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
const playlistSongsRes = await queryClient.fetchQuery(queryKey, ({ signal }) => {
|
||||
if (!server) throw new Error('No server');
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: { id: playlistId, startIndex: 0 },
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const playlistSongIds = api.normalize
|
||||
.songList(playlistSongsRes, server)
|
||||
.items?.map((song) => song.id);
|
||||
const playlistSongIds = playlistSongsRes?.items?.map((song) => song.id);
|
||||
|
||||
for (const songId of allSongIds) {
|
||||
if (!playlistSongIds?.includes(songId)) {
|
||||
@@ -138,10 +146,12 @@ export const AddToPlaylistContextModal = ({
|
||||
}
|
||||
|
||||
if (values.skipDuplicates ? uniqueSongIds.length > 0 : allSongIds.length > 0) {
|
||||
if (!server) return null;
|
||||
addToPlaylistMutation.mutate(
|
||||
{
|
||||
body: { songId: values.skipDuplicates ? uniqueSongIds : allSongIds },
|
||||
query: { id: playlistId },
|
||||
serverId: server?.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
|
||||
@@ -16,47 +16,54 @@ interface CreatePlaylistFormProps {
|
||||
}
|
||||
|
||||
export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
|
||||
const mutation = useCreatePlaylist();
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
const queryBuilderRef = useRef<PlaylistQueryBuilderRef>(null);
|
||||
|
||||
const form = useForm<CreatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
},
|
||||
},
|
||||
comment: '',
|
||||
name: '',
|
||||
ndParams: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
const [isSmartPlaylist, setIsSmartPlaylist] = useState(false);
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
if (isSmartPlaylist) {
|
||||
values.ndParams = {
|
||||
...values.ndParams,
|
||||
values._custom!.navidrome = {
|
||||
...values._custom?.navidrome,
|
||||
rules: queryBuilderRef.current?.getFilters(),
|
||||
};
|
||||
}
|
||||
|
||||
const smartPlaylist = queryBuilderRef.current?.getFilters();
|
||||
|
||||
if (!server) return;
|
||||
|
||||
mutation.mutate(
|
||||
{
|
||||
body: {
|
||||
...values,
|
||||
ndParams: {
|
||||
...values.ndParams,
|
||||
rules:
|
||||
isSmartPlaylist && smartPlaylist?.filters
|
||||
? {
|
||||
...convertQueryGroupToNDQuery(smartPlaylist.filters),
|
||||
...smartPlaylist.extraFilters,
|
||||
}
|
||||
: undefined,
|
||||
_custom: {
|
||||
navidrome: {
|
||||
...values._custom?.navidrome,
|
||||
rules:
|
||||
isSmartPlaylist && smartPlaylist?.filters
|
||||
? {
|
||||
...convertQueryGroupToNDQuery(smartPlaylist.filters),
|
||||
...smartPlaylist.extraFilters,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
serverId: server.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
|
||||
@@ -17,16 +17,12 @@ import {
|
||||
UserListQuery,
|
||||
UserListSort,
|
||||
} from '/@/renderer/api/types';
|
||||
import { Button, ConfirmModal, DropdownMenu, MotionGroup, toast } from '/@/renderer/components';
|
||||
import {
|
||||
Button,
|
||||
ConfirmModal,
|
||||
DropdownMenu,
|
||||
getColumnDefs,
|
||||
MotionGroup,
|
||||
toast,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
} from '/@/renderer/components/virtual-table';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import {
|
||||
PLAYLIST_SONG_CONTEXT_MENU_ITEMS,
|
||||
@@ -68,19 +64,23 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const page = useSongListStore();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const playlistSongsQueryInfinite = usePlaylistSongListInfinite(
|
||||
{
|
||||
const playlistSongsQueryInfinite = usePlaylistSongListInfinite({
|
||||
options: {
|
||||
cacheTime: 0,
|
||||
keepPreviousData: false,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 50,
|
||||
startIndex: 0,
|
||||
},
|
||||
{ cacheTime: 0, keepPreviousData: false },
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const handleLoadMore = () => {
|
||||
playlistSongsQueryInfinite.fetchNextPage();
|
||||
@@ -105,17 +105,17 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
|
||||
});
|
||||
|
||||
const playlistSongData = useMemo(
|
||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p.items),
|
||||
() => playlistSongsQueryInfinite.data?.pages.flatMap((p) => p?.items),
|
||||
[playlistSongsQueryInfinite.data?.pages],
|
||||
);
|
||||
|
||||
const { intersectRef, tableContainerRef } = useFixedTableHeader();
|
||||
|
||||
const deletePlaylistMutation = useDeletePlaylist();
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleDeletePlaylist = () => {
|
||||
deletePlaylistMutation.mutate(
|
||||
{ query: { id: playlistId } },
|
||||
{ query: { id: playlistId }, serverId: server?.id },
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({
|
||||
@@ -165,30 +165,33 @@ export const PlaylistDetailContent = ({ tableRef }: PlaylistDetailContentProps)
|
||||
startIndex: 0,
|
||||
};
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const users = await queryClient.fetchQuery({
|
||||
queryFn: ({ signal }) => api.controller.getUserList({ query, server, signal }),
|
||||
queryFn: ({ signal }) =>
|
||||
api.controller.getUserList({ apiClientProps: { server, signal }, query }),
|
||||
queryKey: queryKeys.users.list(server?.id || '', query),
|
||||
});
|
||||
|
||||
const normalizedUsers = api.normalize.userList(users, server);
|
||||
|
||||
openModal({
|
||||
children: (
|
||||
<UpdatePlaylistForm
|
||||
body={{
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: detailQuery?.data?.owner || undefined,
|
||||
ownerId: detailQuery?.data?.ownerId || undefined,
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules: detailQuery?.data?.rules || undefined,
|
||||
sync: detailQuery?.data?.sync || undefined,
|
||||
},
|
||||
},
|
||||
comment: detailQuery?.data?.description || undefined,
|
||||
genres: detailQuery?.data?.genres,
|
||||
name: detailQuery?.data?.name,
|
||||
ndParams: {
|
||||
owner: detailQuery?.data?.owner || undefined,
|
||||
ownerId: detailQuery?.data?.ownerId || undefined,
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules: detailQuery?.data?.rules || undefined,
|
||||
sync: detailQuery?.data?.sync || undefined,
|
||||
},
|
||||
}}
|
||||
query={{ id: playlistId }}
|
||||
users={normalizedUsers.items}
|
||||
users={users?.items}
|
||||
onCancel={closeAllModals}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { LibraryHeader } from '/@/renderer/features/shared';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
interface PlaylistDetailHeaderProps {
|
||||
background: string;
|
||||
@@ -20,7 +21,8 @@ export const PlaylistDetailHeader = forwardRef(
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
|
||||
const metadataItems = [
|
||||
{
|
||||
|
||||
@@ -8,12 +8,6 @@ import type {
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import {
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentServer,
|
||||
usePlaylistDetailStore,
|
||||
@@ -44,6 +38,8 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
|
||||
|
||||
interface PlaylistDetailContentProps {
|
||||
tableRef: MutableRefObject<AgGridReactType | null>;
|
||||
@@ -61,7 +57,7 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
};
|
||||
}, [page?.table.id, playlistId]);
|
||||
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
|
||||
const p = usePlaylistDetailTablePagination(playlistId);
|
||||
const pagination = {
|
||||
@@ -80,9 +76,12 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
const isPaginationEnabled = page.display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const checkPlaylistList = usePlaylistSongList({
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(
|
||||
@@ -104,24 +103,27 @@ export const PlaylistDetailSongListContent = ({ tableRef }: PlaylistDetailConten
|
||||
...filters,
|
||||
});
|
||||
|
||||
if (!server) return;
|
||||
|
||||
const songsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
|
||||
+8
-14
@@ -18,15 +18,7 @@ import {
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { LibraryItem, PlaylistSongListQuery, SongListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
SONG_TABLE_COLUMNS,
|
||||
Button,
|
||||
Slider,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
Text,
|
||||
} from '/@/renderer/components';
|
||||
import { DropdownMenu, Button, Slider, MultiSelect, Switch, Text } from '/@/renderer/components';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
@@ -41,6 +33,7 @@ import {
|
||||
import { ListDisplayType, ServerType, Play, TableColumn } from '/@/renderer/types';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { useParams } from 'react-router';
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
@@ -100,7 +93,7 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const isSmartPlaylist = detailQuery.data?.rules;
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
@@ -139,20 +132,21 @@ export const PlaylistDetailSongListHeaderFilters = ({
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistSongList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit,
|
||||
startIndex,
|
||||
...filters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const songs = api.normalize.songList(songsRes, server);
|
||||
params.successCallback(songs?.items || [], songsRes?.totalRecordCount || 0);
|
||||
params.successCallback(songsRes?.items || [], songsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
|
||||
import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playlist-detail-query';
|
||||
import { LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
@@ -23,7 +24,8 @@ export const PlaylistDetailSongListHeader = ({
|
||||
handleToggleShowQueryBuilder,
|
||||
}: PlaylistDetailHeaderProps) => {
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const server = useCurrentServer();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const handlePlay = async (playType: Play) => {
|
||||
|
||||
@@ -12,12 +12,6 @@ import { Stack } from '@mantine/core';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import {
|
||||
useCurrentServer,
|
||||
usePlaylistListStore,
|
||||
@@ -33,6 +27,8 @@ import { PLAYLIST_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/c
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
import { getColumnDefs, VirtualTable, TablePagination } from '/@/renderer/components/virtual-table';
|
||||
|
||||
interface PlaylistListContentProps {
|
||||
itemCount?: number;
|
||||
@@ -81,19 +77,20 @@ export const PlaylistListContent = ({ tableRef, itemCount }: PlaylistListContent
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...page.filter,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const playlists = api.normalize.playlistList(playlistsRes, server);
|
||||
params.successCallback(playlists?.items || [], playlistsRes?.totalRecordCount || 0);
|
||||
params.successCallback(playlistsRes?.items || [], playlistsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
|
||||
@@ -7,15 +7,7 @@ import { RiSortAsc, RiSortDesc, RiMoreFill, RiRefreshLine, RiSettings3Fill } fro
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SortOrder, PlaylistListSort } from '/@/renderer/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
PLAYLIST_TABLE_COLUMNS,
|
||||
Text,
|
||||
Button,
|
||||
Slider,
|
||||
MultiSelect,
|
||||
Switch,
|
||||
} from '/@/renderer/components';
|
||||
import { DropdownMenu, Text, Button, Slider, MultiSelect, Switch } from '/@/renderer/components';
|
||||
import { useContainerQuery } from '/@/renderer/hooks';
|
||||
import {
|
||||
PlaylistListFilter,
|
||||
@@ -27,6 +19,7 @@ import {
|
||||
useSetPlaylistTablePagination,
|
||||
} from '/@/renderer/store';
|
||||
import { ListDisplayType, TableColumn } from '/@/renderer/types';
|
||||
import { PLAYLIST_TABLE_COLUMNS } from '/@/renderer/components/virtual-table';
|
||||
|
||||
const FILTERS = {
|
||||
jellyfin: [
|
||||
@@ -91,19 +84,20 @@ export const PlaylistListHeaderFilters = ({ tableRef }: PlaylistListHeaderFilter
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getPlaylistList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
limit,
|
||||
startIndex,
|
||||
...pageFilters,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const playlists = api.normalize.playlistList(playlistsRes, server);
|
||||
params.successCallback(playlists?.items || [], playlistsRes?.totalRecordCount || 0);
|
||||
params.successCallback(playlistsRes?.items || [], playlistsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { CreatePlaylistBody, RawCreatePlaylistResponse, ServerType } from '/@/renderer/api/types';
|
||||
import { CreatePlaylistBody, CreatePlaylistResponse, ServerType } from '/@/renderer/api/types';
|
||||
import { Button, Switch, TextInput, toast } from '/@/renderer/components';
|
||||
import { useCreatePlaylist } from '/@/renderer/features/playlists/mutations/create-playlist-mutation';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
@@ -8,22 +8,24 @@ import { useCurrentServer } from '/@/renderer/store';
|
||||
interface SaveAsPlaylistFormProps {
|
||||
body: Partial<CreatePlaylistBody>;
|
||||
onCancel: () => void;
|
||||
onSuccess: (data: RawCreatePlaylistResponse) => void;
|
||||
onSuccess: (data: CreatePlaylistResponse) => void;
|
||||
}
|
||||
|
||||
export const SaveAsPlaylistForm = ({ body, onSuccess, onCancel }: SaveAsPlaylistFormProps) => {
|
||||
const mutation = useCreatePlaylist();
|
||||
const mutation = useCreatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
const form = useForm<CreatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
...body?._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
comment: body.comment || '',
|
||||
name: body.name || '',
|
||||
ndParams: {
|
||||
public: false,
|
||||
rules: undefined,
|
||||
...body.ndParams,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ interface UpdatePlaylistFormProps {
|
||||
}
|
||||
|
||||
export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlaylistFormProps) => {
|
||||
const mutation = useUpdatePlaylist();
|
||||
const mutation = useUpdatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
const userList = users?.map((user) => ({
|
||||
@@ -23,21 +23,27 @@ export const UpdatePlaylistForm = ({ users, query, body, onCancel }: UpdatePlayl
|
||||
|
||||
const form = useForm<UpdatePlaylistBody>({
|
||||
initialValues: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: body?._custom?.navidrome?.owner || '',
|
||||
ownerId: body?._custom?.navidrome?.ownerId || '',
|
||||
public: body?._custom?.navidrome?.public || false,
|
||||
rules: undefined,
|
||||
sync: body?._custom?.navidrome?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: body?.comment || '',
|
||||
name: body?.name || '',
|
||||
ndParams: {
|
||||
owner: body?.ndParams?.owner || '',
|
||||
ownerId: body?.ndParams?.ownerId || '',
|
||||
public: body?.ndParams?.public || false,
|
||||
rules: undefined,
|
||||
sync: body?.ndParams?.sync || false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{ body: values, query },
|
||||
{
|
||||
body: values,
|
||||
query,
|
||||
serverId: server?.id,
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error({ message: err.message, title: 'Error updating playlist' });
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AddToPlaylistArgs, RawAddToPlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { AddToPlaylistArgs, AddToPlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useAddToPlaylist = (options?: MutationOptions) => {
|
||||
export const useAddToPlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<RawAddToPlaylistResponse, HTTPError, Omit<AddToPlaylistArgs, 'server'>, null>({
|
||||
mutationFn: (args) => api.controller.addToPlaylist({ ...args, server }),
|
||||
return useMutation<
|
||||
AddToPlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<AddToPlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.addToPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
|
||||
const { serverId } = variables;
|
||||
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detail(server?.id || '', variables.query.id),
|
||||
);
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(server?.id || '', variables.query.id),
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
);
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { CreatePlaylistArgs, RawCreatePlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { CreatePlaylistArgs, CreatePlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { AxiosError } from 'axios';
|
||||
import { queryKeys } from '../../../api/query-keys';
|
||||
|
||||
export const useCreatePlaylist = (options?: MutationOptions) => {
|
||||
export const useCreatePlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
RawCreatePlaylistResponse,
|
||||
HTTPError,
|
||||
Omit<CreatePlaylistArgs, 'server'>,
|
||||
CreatePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<CreatePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => api.controller.createPlaylist({ ...args, server }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.createPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_args, variables) => {
|
||||
const server = getServerById(variables.serverId);
|
||||
if (server) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server.id));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { DeletePlaylistArgs, RawDeletePlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { DeletePlaylistArgs, DeletePlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById, useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
export const useDeletePlaylist = (options?: MutationOptions) => {
|
||||
export const useDeletePlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
RawDeletePlaylistResponse,
|
||||
HTTPError,
|
||||
Omit<DeletePlaylistArgs, 'server'>,
|
||||
DeletePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<DeletePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => api.controller.deletePlaylist({ ...args, server }),
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.deletePlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onMutate: () => {
|
||||
queryClient.cancelQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
return null;
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { RawRemoveFromPlaylistResponse, RemoveFromPlaylistArgs } from '/@/renderer/api/types';
|
||||
import { RemoveFromPlaylistArgs, RemoveFromPlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useRemoveFromPlaylist = (options?: MutationOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
RawRemoveFromPlaylistResponse,
|
||||
HTTPError,
|
||||
Omit<RemoveFromPlaylistArgs, 'server'>,
|
||||
RemoveFromPlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<RemoveFromPlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => api.controller.removeFromPlaylist({ ...args, server }),
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.removeFromPlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''), { exact: false });
|
||||
const { serverId } = variables;
|
||||
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detail(server?.id || '', variables.query.id),
|
||||
);
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId), { exact: false });
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, variables.query.id));
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.playlists.detailSongList(server?.id || '', variables.query.id),
|
||||
queryKeys.playlists.detailSongList(serverId, variables.query.id),
|
||||
);
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { RawUpdatePlaylistResponse, UpdatePlaylistArgs } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { UpdatePlaylistArgs, UpdatePlaylistResponse } from '/@/renderer/api/types';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
|
||||
export const useUpdatePlaylist = (options?: MutationOptions) => {
|
||||
export const useUpdatePlaylist = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
|
||||
return useMutation<
|
||||
RawUpdatePlaylistResponse,
|
||||
HTTPError,
|
||||
Omit<UpdatePlaylistArgs, 'server'>,
|
||||
UpdatePlaylistResponse,
|
||||
AxiosError,
|
||||
Omit<UpdatePlaylistArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => api.controller.updatePlaylist({ ...args, server }),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(server?.id || ''));
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updatePlaylist({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { query, serverId } = variables;
|
||||
|
||||
if (data?.id) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(server?.id || '', data.id));
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries(queryKeys.playlists.list(serverId));
|
||||
|
||||
if (query?.id) {
|
||||
queryClient.invalidateQueries(queryKeys.playlists.detail(serverId, query.id));
|
||||
}
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistDetailQuery, RawPlaylistDetailResponse } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import type { PlaylistDetailQuery } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistDetail = (query: PlaylistDetailQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const usePlaylistDetail = (args: QueryHookArgs<PlaylistDetailQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getPlaylistDetail({ query, server, signal }),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistDetail({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.detail(server?.id || '', query.id, query),
|
||||
select: useCallback(
|
||||
(data: RawPlaylistDetailResponse | undefined) => api.normalize.playlistDetail(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistListQuery, RawPlaylistListResponse } from '/@/renderer/api/types';
|
||||
import type { PlaylistListQuery } from '/@/renderer/api/types';
|
||||
import type { QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistList = (query: PlaylistListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const usePlaylistList = (args: {
|
||||
options?: QueryOptions;
|
||||
query: PlaylistListQuery;
|
||||
serverId?: string;
|
||||
}) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
cacheTime: 1000 * 60 * 60,
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getPlaylistList({ query, server, signal }),
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.list(server?.id || '', query),
|
||||
select: useCallback(
|
||||
(data: RawPlaylistListResponse | undefined) => api.normalize.playlistList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,61 +1,46 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useInfiniteQuery, InfiniteData } from '@tanstack/react-query';
|
||||
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import type { PlaylistSongListQuery, RawSongListResponse } from '/@/renderer/api/types';
|
||||
import type { InfiniteQueryOptions, QueryOptions } from '/@/renderer/lib/react-query';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import type { PlaylistSongListQuery, PlaylistSongListResponse } from '/@/renderer/api/types';
|
||||
import type { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
export const usePlaylistSongList = (query: PlaylistSongListQuery, options?: QueryOptions) => {
|
||||
const server = useCurrentServer();
|
||||
export const usePlaylistSongList = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getPlaylistSongList({ query, server, signal }),
|
||||
enabled: !!server,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getPlaylistSongList({ apiClientProps: { server, signal }, query });
|
||||
},
|
||||
queryKey: queryKeys.playlists.songList(server?.id || '', query.id, query),
|
||||
select: useCallback(
|
||||
(data: RawSongListResponse | undefined) => api.normalize.songList(data, server),
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlaylistSongListInfinite = (
|
||||
query: PlaylistSongListQuery,
|
||||
options?: InfiniteQueryOptions,
|
||||
) => {
|
||||
const server = useCurrentServer();
|
||||
export const usePlaylistSongListInfinite = (args: QueryHookArgs<PlaylistSongListQuery>) => {
|
||||
const { options, query, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
return useInfiniteQuery({
|
||||
enabled: !!server?.id,
|
||||
getNextPageParam: (lastPage: RawSongListResponse, allPages) => {
|
||||
enabled: !!server,
|
||||
getNextPageParam: (lastPage: PlaylistSongListResponse | undefined, pages) => {
|
||||
if (!lastPage?.items) return undefined;
|
||||
if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
return allPages?.length;
|
||||
return pages?.length;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
return api.controller.getPlaylistSongList({
|
||||
apiClientProps: { server, signal },
|
||||
query: { ...query, limit: query.limit || 50, startIndex: pageParam * (query.limit || 50) },
|
||||
server,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.playlists.detailSongList(server?.id || '', query.id, query),
|
||||
select: useCallback(
|
||||
(data: InfiniteData<RawSongListResponse | undefined>) => {
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page, index) => {
|
||||
return { ...api.normalize.songList(page, server), pageIndex: index };
|
||||
}),
|
||||
};
|
||||
},
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useRef } from 'react';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import { useParams } from 'react-router';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { NativeScrollArea } from '/@/renderer/components';
|
||||
@@ -10,14 +10,16 @@ import { usePlaylistDetail } from '/@/renderer/features/playlists/queries/playli
|
||||
import { AnimatedPage, LibraryHeaderBar } from '/@/renderer/features/shared';
|
||||
import { useFastAverageColor } from '/@/renderer/hooks';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { useCurrentServer } from '../../../store/auth.store';
|
||||
|
||||
const PlaylistDetailRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const server = useCurrentServer();
|
||||
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const background = useFastAverageColor(
|
||||
detailQuery?.data?.imageUrl,
|
||||
!detailQuery?.isLoading,
|
||||
|
||||
@@ -23,11 +23,11 @@ const PlaylistDetailSongListRoute = () => {
|
||||
const navigate = useNavigate();
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const { playlistId } = useParams() as { playlistId: string };
|
||||
const currentServer = useCurrentServer();
|
||||
const server = useCurrentServer();
|
||||
|
||||
const detailQuery = usePlaylistDetail({ id: playlistId });
|
||||
const createPlaylistMutation = useCreatePlaylist();
|
||||
const deletePlaylistMutation = useDeletePlaylist();
|
||||
const detailQuery = usePlaylistDetail({ query: { id: playlistId }, serverId: server?.id });
|
||||
const createPlaylistMutation = useCreatePlaylist({});
|
||||
const deletePlaylistMutation = useDeletePlaylist({});
|
||||
|
||||
const handleSave = (
|
||||
filter: Record<string, any>,
|
||||
@@ -45,15 +45,17 @@ const PlaylistDetailSongListRoute = () => {
|
||||
createPlaylistMutation.mutate(
|
||||
{
|
||||
body: {
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules,
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: detailQuery?.data?.description || '',
|
||||
name: detailQuery?.data?.name,
|
||||
ndParams: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules,
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -73,19 +75,21 @@ const PlaylistDetailSongListRoute = () => {
|
||||
children: (
|
||||
<SaveAsPlaylistForm
|
||||
body={{
|
||||
_custom: {
|
||||
navidrome: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules: {
|
||||
...filter,
|
||||
order: 'desc',
|
||||
sort: 'year',
|
||||
},
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
},
|
||||
comment: detailQuery?.data?.description || '',
|
||||
name: detailQuery?.data?.name,
|
||||
ndParams: {
|
||||
owner: detailQuery?.data?.owner || '',
|
||||
ownerId: detailQuery?.data?.ownerId || '',
|
||||
public: detailQuery?.data?.public || false,
|
||||
rules: {
|
||||
...filter,
|
||||
order: 'desc',
|
||||
sort: 'year',
|
||||
},
|
||||
sync: detailQuery?.data?.sync || false,
|
||||
},
|
||||
}}
|
||||
onCancel={closeAllModals}
|
||||
onSuccess={(data) =>
|
||||
@@ -120,9 +124,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
};
|
||||
|
||||
const isSmartPlaylist =
|
||||
!detailQuery?.isLoading &&
|
||||
detailQuery?.data?.rules &&
|
||||
currentServer?.type === ServerType.NAVIDROME;
|
||||
!detailQuery?.isLoading && detailQuery?.data?.rules && server?.type === ServerType.NAVIDROME;
|
||||
|
||||
const [showQueryBuilder, setShowQueryBuilder] = useState(false);
|
||||
const [isQueryBuilderExpanded, setIsQueryBuilderExpanded] = useState(false);
|
||||
@@ -142,18 +144,19 @@ const PlaylistDetailSongListRoute = () => {
|
||||
sortOrder: page?.table.id[playlistId]?.filter?.sortOrder || SortOrder.ASC,
|
||||
};
|
||||
|
||||
const itemCountCheck = usePlaylistSongList(
|
||||
{
|
||||
const itemCountCheck = usePlaylistSongList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
query: {
|
||||
id: playlistId,
|
||||
limit: 1,
|
||||
startIndex: 0,
|
||||
...filters,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
|
||||
@@ -5,22 +5,25 @@ import { PlaylistListContent } from '/@/renderer/features/playlists/components/p
|
||||
import { PlaylistListHeader } from '/@/renderer/features/playlists/components/playlist-list-header';
|
||||
import { usePlaylistList } from '/@/renderer/features/playlists/queries/playlist-list-query';
|
||||
import { AnimatedPage } from '/@/renderer/features/shared';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const PlaylistListRoute = () => {
|
||||
const tableRef = useRef<AgGridReactType | null>(null);
|
||||
const server = useCurrentServer();
|
||||
|
||||
const itemCountCheck = usePlaylistList(
|
||||
{
|
||||
const itemCountCheck = usePlaylistList({
|
||||
options: {
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
query: {
|
||||
limit: 1,
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
{
|
||||
cacheTime: 1000 * 60 * 60 * 2,
|
||||
staleTime: 1000 * 60 * 60 * 2,
|
||||
},
|
||||
);
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const itemCount =
|
||||
itemCountCheck.data?.totalRecordCount === null
|
||||
|
||||
@@ -5,12 +5,10 @@ import { useForm } from '@mantine/form';
|
||||
import { useFocusTrap } from '@mantine/hooks';
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { navidromeApi } from '/@/renderer/api/navidrome.api';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { AuthenticationResponse } from '/@/renderer/api/types';
|
||||
import { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerType } from '/@/renderer/types';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
const SERVER_TYPES = [
|
||||
{ label: 'Jellyfin', value: ServerType.JELLYFIN },
|
||||
@@ -18,12 +16,6 @@ const SERVER_TYPES = [
|
||||
// { label: 'Subsonic', value: ServerType.SUBSONIC },
|
||||
];
|
||||
|
||||
const AUTH_FUNCTIONS = {
|
||||
[ServerType.JELLYFIN]: jellyfinApi.authenticate,
|
||||
[ServerType.NAVIDROME]: navidromeApi.authenticate,
|
||||
[ServerType.SUBSONIC]: subsonicApi.authenticate,
|
||||
};
|
||||
|
||||
interface AddServerFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -48,7 +40,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const authFunction = AUTH_FUNCTIONS[values.type];
|
||||
const authFunction = api.controller.authenticate;
|
||||
|
||||
if (!authFunction) {
|
||||
return toast.error({ message: 'Selected server type is invalid' });
|
||||
@@ -56,11 +48,19 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data: AuthenticationResponse = await authFunction(values.url, {
|
||||
legacy: values.legacyAuth,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
});
|
||||
const data: AuthenticationResponse | undefined = await authFunction(
|
||||
values.url,
|
||||
{
|
||||
legacy: values.legacyAuth,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
},
|
||||
values.type,
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return toast.error({ message: 'Authentication failed' });
|
||||
}
|
||||
|
||||
const serverItem = {
|
||||
credential: data.credential,
|
||||
@@ -77,7 +77,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||
setCurrentServer(serverItem);
|
||||
closeAllModals();
|
||||
|
||||
if (serverList.length === 0) {
|
||||
if (Object.keys(serverList).length === 0) {
|
||||
toast.success({ message: 'Server has been added, reloading...' });
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { Checkbox, Stack, Group } from '@mantine/core';
|
||||
import { Button, PasswordInput, TextInput, toast } from '/@/renderer/components';
|
||||
import { Button, PasswordInput, TextInput, toast, Tooltip } from '/@/renderer/components';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useFocusTrap } from '@mantine/hooks';
|
||||
import { closeAllModals } from '@mantine/modals';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import { RiInformationLine } from 'react-icons/ri';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { navidromeApi } from '/@/renderer/api/navidrome.api';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { AuthenticationResponse } from '/@/renderer/api/types';
|
||||
import { useAuthStoreActions } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import { api } from '/@/renderer/api';
|
||||
|
||||
interface EditServerFormProps {
|
||||
isUpdate?: boolean;
|
||||
@@ -18,14 +16,19 @@ interface EditServerFormProps {
|
||||
server: ServerListItem;
|
||||
}
|
||||
|
||||
const AUTH_FUNCTIONS = {
|
||||
[ServerType.JELLYFIN]: jellyfinApi.authenticate,
|
||||
[ServerType.NAVIDROME]: navidromeApi.authenticate,
|
||||
[ServerType.SUBSONIC]: subsonicApi.authenticate,
|
||||
const ModifiedFieldIndicator = () => {
|
||||
return (
|
||||
<Tooltip label="Field has been modified">
|
||||
<span>
|
||||
<RiInformationLine color="red" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormProps) => {
|
||||
const { updateServer, setCurrentServer } = useAuthStoreActions();
|
||||
const { updateServer } = useAuthStoreActions();
|
||||
const focusTrapRef = useFocusTrap();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
@@ -41,11 +44,8 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
|
||||
|
||||
const isSubsonic = form.values.type === ServerType.SUBSONIC;
|
||||
|
||||
const isSubmitDisabled =
|
||||
!form.values.name || !form.values.url || !form.values.username || !form.values.password;
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const authFunction = AUTH_FUNCTIONS[values.type];
|
||||
const authFunction = api.controller.authenticate;
|
||||
|
||||
if (!authFunction) {
|
||||
return toast.error({ message: 'Selected server type is invalid' });
|
||||
@@ -53,15 +53,22 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data: AuthenticationResponse = await authFunction(values.url, {
|
||||
legacy: values.legacyAuth,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
});
|
||||
const data: AuthenticationResponse | undefined = await authFunction(
|
||||
values.url,
|
||||
{
|
||||
legacy: values.legacyAuth,
|
||||
password: values.password,
|
||||
username: values.username,
|
||||
},
|
||||
values.type,
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return toast.error({ message: 'Authentication failed' });
|
||||
}
|
||||
|
||||
const serverItem = {
|
||||
credential: data.credential,
|
||||
id: nanoid(),
|
||||
name: values.name,
|
||||
ndCredential: data.ndCredential,
|
||||
type: values.type,
|
||||
@@ -71,8 +78,6 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
|
||||
};
|
||||
|
||||
updateServer(server.id, serverItem);
|
||||
setCurrentServer(serverItem);
|
||||
|
||||
toast.success({ message: 'Server has been updated' });
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
@@ -85,25 +90,28 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack>
|
||||
<Stack ref={focusTrapRef}>
|
||||
<TextInput
|
||||
required
|
||||
label="Name"
|
||||
rightSection={form.isDirty('name') && <RiInformationLine color="red" />}
|
||||
rightSection={form.isDirty('name') && <ModifiedFieldIndicator />}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Url"
|
||||
rightSection={form.isDirty('url') && <RiInformationLine color="red" />}
|
||||
rightSection={form.isDirty('url') && <ModifiedFieldIndicator />}
|
||||
{...form.getInputProps('url')}
|
||||
/>
|
||||
<TextInput
|
||||
required
|
||||
label="Username"
|
||||
rightSection={form.isDirty('username') && <RiInformationLine color="red" />}
|
||||
rightSection={form.isDirty('username') && <ModifiedFieldIndicator />}
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
data-autofocus
|
||||
required
|
||||
label="Password"
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
@@ -123,7 +131,6 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isLoading}
|
||||
type="submit"
|
||||
variant="filled"
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
|
||||
<Divider my="sm" />
|
||||
<TimeoutButton
|
||||
leftIcon={<RiDeleteBin2Line />}
|
||||
timeoutProps={{ callback: handleDeleteServer, duration: 1500 }}
|
||||
timeoutProps={{ callback: handleDeleteServer, duration: 1000 }}
|
||||
variant="subtle"
|
||||
>
|
||||
Remove server
|
||||
|
||||
@@ -62,6 +62,7 @@ export const ServerList = () => {
|
||||
position: 'absolute',
|
||||
right: 55,
|
||||
transform: 'translateY(-3.5rem)',
|
||||
zIndex: 2000,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -77,21 +78,24 @@ export const ServerList = () => {
|
||||
</Group>
|
||||
<Stack>
|
||||
<Accordion variant="separated">
|
||||
{serverListQuery?.map((s) => (
|
||||
<Accordion.Item
|
||||
key={s.id}
|
||||
value={s.name}
|
||||
>
|
||||
<Accordion.Control icon={<RiServerFill size={15} />}>
|
||||
<Group position="apart">
|
||||
{titleCase(s.type)} - {s.name}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ServerListItem server={s} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
{Object.keys(serverListQuery)?.map((serverId) => {
|
||||
const server = serverListQuery[serverId];
|
||||
return (
|
||||
<Accordion.Item
|
||||
key={server.id}
|
||||
value={server.name}
|
||||
>
|
||||
<Accordion.Control icon={<RiServerFill size={15} />}>
|
||||
<Group position="apart">
|
||||
{titleCase(server?.type)} - {server?.name}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ServerListItem server={server} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
<Divider />
|
||||
<Group>
|
||||
|
||||
@@ -6,5 +6,5 @@ export * from './components/library-header';
|
||||
export * from './components/library-header-bar';
|
||||
export * from './mutations/create-favorite-mutation';
|
||||
export * from './mutations/delete-favorite-mutation';
|
||||
export * from './mutations/update-rating-mutation';
|
||||
export * from './mutations/set-rating-mutation';
|
||||
export * from './components/filter-bar';
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { JFAlbumArtistDetail, JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
|
||||
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
||||
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useSetAlbumListItemDataById,
|
||||
useSetQueueFavorite,
|
||||
} from '/@/renderer/store';
|
||||
AlbumArtistDetailResponse,
|
||||
AlbumDetailResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
LibraryItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store';
|
||||
|
||||
export const useCreateFavorite = (options?: MutationOptions) => {
|
||||
export const useCreateFavorite = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueFavorite = useSetQueueFavorite();
|
||||
|
||||
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
|
||||
mutationFn: (args) => api.controller.createFavorite({ ...args, server }),
|
||||
return useMutation<
|
||||
FavoriteResponse,
|
||||
AxiosError,
|
||||
Omit<FavoriteArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.createFavorite({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { serverId } = variables;
|
||||
|
||||
if (!serverId) return;
|
||||
|
||||
for (const id of variables.query.id) {
|
||||
// Set the userFavorite property to true for the album in the album list data store
|
||||
if (variables.query.type === LibraryItem.ALBUM) {
|
||||
@@ -35,71 +47,33 @@ export const useCreateFavorite = (options?: MutationOptions) => {
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
if (variables.query.type === LibraryItem.ALBUM && variables.query.id.length === 1) {
|
||||
const queryKey = queryKeys.albums.detail(server?.id || '', { id: variables.query.id[0] });
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
const queryKey = queryKeys.albums.detail(serverId, { id: variables.query.id[0] });
|
||||
const previous = queryClient.getQueryData<AlbumDetailResponse>(queryKey);
|
||||
|
||||
if (previous) {
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: true,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: true,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
queryClient.setQueryData<JFAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
UserData: {
|
||||
...previous.UserData,
|
||||
IsFavorite: true,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
queryClient.setQueryData<AlbumDetailResponse>(queryKey, {
|
||||
...previous,
|
||||
userFavorite: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
if (variables.query.type === LibraryItem.ALBUM_ARTIST && variables.query.id.length === 1) {
|
||||
const queryKey = queryKeys.albumArtists.detail(server?.id || '', {
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId, {
|
||||
id: variables.query.id[0],
|
||||
});
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
|
||||
const previous = queryClient.getQueryData<AlbumArtistDetailResponse>(queryKey);
|
||||
|
||||
if (previous) {
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: true,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: true,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
queryClient.setQueryData<JFAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
UserData: {
|
||||
...previous.UserData,
|
||||
IsFavorite: true,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
queryClient.setQueryData<AlbumArtistDetailResponse>(queryKey, {
|
||||
...previous,
|
||||
userFavorite: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { JFAlbumArtistDetail, JFAlbumDetail } from '/@/renderer/api/jellyfin.types';
|
||||
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
||||
import { FavoriteArgs, LibraryItem, RawFavoriteResponse, ServerType } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useSetAlbumListItemDataById,
|
||||
useSetQueueFavorite,
|
||||
} from '/@/renderer/store';
|
||||
AlbumArtistDetailResponse,
|
||||
AlbumDetailResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
LibraryItem,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById, useSetAlbumListItemDataById, useSetQueueFavorite } from '/@/renderer/store';
|
||||
|
||||
export const useDeleteFavorite = (options?: MutationOptions) => {
|
||||
export const useDeleteFavorite = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueFavorite = useSetQueueFavorite();
|
||||
|
||||
return useMutation<RawFavoriteResponse, HTTPError, Omit<FavoriteArgs, 'server'>, null>({
|
||||
mutationFn: (args) => api.controller.deleteFavorite({ ...args, server }),
|
||||
return useMutation<
|
||||
FavoriteResponse,
|
||||
AxiosError,
|
||||
Omit<FavoriteArgs, 'server' | 'apiClientProps'>,
|
||||
null
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.deleteFavorite({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { serverId } = variables;
|
||||
|
||||
if (!serverId) return;
|
||||
|
||||
for (const id of variables.query.id) {
|
||||
// Set the userFavorite property to false for the album in the album list data store
|
||||
if (variables.query.type === LibraryItem.ALBUM) {
|
||||
@@ -35,67 +47,29 @@ export const useDeleteFavorite = (options?: MutationOptions) => {
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
if (variables.query.type === LibraryItem.ALBUM && variables.query.id.length === 1) {
|
||||
const queryKey = queryKeys.albums.detail(server?.id || '', { id: variables.query.id[0] });
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
const queryKey = queryKeys.albums.detail(serverId, { id: variables.query.id[0] });
|
||||
const previous = queryClient.getQueryData<AlbumDetailResponse>(queryKey);
|
||||
|
||||
if (previous) {
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: false,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: false,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
queryClient.setQueryData<JFAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
UserData: {
|
||||
...previous.UserData,
|
||||
IsFavorite: false,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
queryClient.setQueryData<AlbumDetailResponse>(queryKey, {
|
||||
...previous,
|
||||
userFavorite: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
// We only need to set if we're already on the album artist detail page
|
||||
if (variables.query.type === LibraryItem.ALBUM_ARTIST && variables.query.id.length === 1) {
|
||||
const queryKey = queryKeys.albumArtists.detail(server?.id || '', {
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId, {
|
||||
id: variables.query.id[0],
|
||||
});
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
const previous = queryClient.getQueryData<AlbumArtistDetailResponse>(queryKey);
|
||||
|
||||
if (previous) {
|
||||
switch (server?.type) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: false,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
starred: false,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
queryClient.setQueryData<JFAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
UserData: {
|
||||
...previous.UserData,
|
||||
IsFavorite: false,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
queryClient.setQueryData<AlbumArtistDetailResponse>(queryKey, {
|
||||
...previous,
|
||||
userFavorite: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AxiosError } from 'axios';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AnyLibraryItems,
|
||||
LibraryItem,
|
||||
SetRatingArgs,
|
||||
RatingResponse,
|
||||
AlbumDetailResponse,
|
||||
AlbumArtistDetailResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MutationHookArgs } from '/@/renderer/lib/react-query';
|
||||
import { getServerById, useSetAlbumListItemDataById, useSetQueueRating } from '/@/renderer/store';
|
||||
|
||||
export const useSetRating = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueRating = useSetQueueRating();
|
||||
|
||||
return useMutation<
|
||||
RatingResponse,
|
||||
AxiosError,
|
||||
Omit<SetRatingArgs, 'server' | 'apiClientProps'>,
|
||||
{ previous: { items: AnyLibraryItems } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.updateRating({ ...args, apiClientProps: { server } });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: item.userRating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], item.userRating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onMutate: (variables) => {
|
||||
for (const item of variables.query.item) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: variables.query.rating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], variables.query.rating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { previous: { items: variables.query.item } };
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumDetailPage =
|
||||
variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM;
|
||||
|
||||
if (isAlbumDetailPage) {
|
||||
const { id: albumId, serverId } = variables.query.item[0] as Album;
|
||||
|
||||
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
|
||||
const previous = queryClient.getQueryData<AlbumDetailResponse>(queryKey);
|
||||
if (previous) {
|
||||
queryClient.setQueryData<AlbumDetailResponse>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// We only need to set if we're already on the album artist detail page
|
||||
const isAlbumArtistDetailPage =
|
||||
variables.query.item.length === 1 &&
|
||||
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
|
||||
|
||||
if (isAlbumArtistDetailPage) {
|
||||
const { id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
|
||||
id: albumArtistId,
|
||||
});
|
||||
const previous = queryClient.getQueryData<AlbumArtistDetailResponse>(queryKey);
|
||||
if (previous) {
|
||||
queryClient.setQueryData<AlbumArtistDetailResponse>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { NDAlbumArtistDetail, NDAlbumDetail } from '/@/renderer/api/navidrome.types';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { SSAlbumArtistDetail, SSAlbumDetail } from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AnyLibraryItems,
|
||||
LibraryItem,
|
||||
RatingArgs,
|
||||
RawRatingResponse,
|
||||
ServerType,
|
||||
} from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
useAuthStore,
|
||||
useCurrentServer,
|
||||
useSetAlbumListItemDataById,
|
||||
useSetQueueRating,
|
||||
} from '/@/renderer/store';
|
||||
|
||||
export const useUpdateRating = (options?: MutationOptions) => {
|
||||
const queryClient = useQueryClient();
|
||||
const currentServer = useCurrentServer();
|
||||
const setAlbumListData = useSetAlbumListItemDataById();
|
||||
const setQueueRating = useSetQueueRating();
|
||||
|
||||
return useMutation<
|
||||
RawRatingResponse,
|
||||
HTTPError,
|
||||
Omit<RatingArgs, 'server'>,
|
||||
{ previous: { items: AnyLibraryItems } | undefined }
|
||||
>({
|
||||
mutationFn: (args) => {
|
||||
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
|
||||
return api.controller.updateRating({ ...args, server });
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
for (const item of context?.previous?.items || []) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: item.userRating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], item.userRating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onMutate: (variables) => {
|
||||
for (const item of variables.query.item) {
|
||||
switch (item.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
setAlbumListData(item.id, { userRating: variables.query.rating });
|
||||
break;
|
||||
case LibraryItem.SONG:
|
||||
setQueueRating([item.id], variables.query.rating);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { previous: { items: variables.query.item } };
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumDetailPage =
|
||||
variables.query.item.length === 1 && variables.query.item[0].itemType === LibraryItem.ALBUM;
|
||||
|
||||
if (isAlbumDetailPage) {
|
||||
const { serverType, id: albumId, serverId } = variables.query.item[0] as Album;
|
||||
|
||||
const queryKey = queryKeys.albums.detail(serverId || '', { id: albumId });
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
rating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only need to set if we're already on the album detail page
|
||||
const isAlbumArtistDetailPage =
|
||||
variables.query.item.length === 1 &&
|
||||
variables.query.item[0].itemType === LibraryItem.ALBUM_ARTIST;
|
||||
|
||||
if (isAlbumArtistDetailPage) {
|
||||
const { serverType, id: albumArtistId, serverId } = variables.query.item[0] as AlbumArtist;
|
||||
|
||||
const queryKey = queryKeys.albumArtists.detail(serverId || '', {
|
||||
id: albumArtistId,
|
||||
});
|
||||
const previous = queryClient.getQueryData<any>(queryKey);
|
||||
if (previous) {
|
||||
switch (serverType) {
|
||||
case ServerType.NAVIDROME:
|
||||
queryClient.setQueryData<NDAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
rating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.SUBSONIC:
|
||||
queryClient.setQueryData<SSAlbumArtistDetail>(queryKey, {
|
||||
...previous,
|
||||
userRating: variables.query.rating,
|
||||
});
|
||||
break;
|
||||
case ServerType.JELLYFIN:
|
||||
// Jellyfin does not support ratings
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -1,23 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { RawMusicFolderListResponse } from '/@/renderer/api/types';
|
||||
import { getServerById } from '/@/renderer/store';
|
||||
import { MusicFolderListQuery } from '../../../api/types';
|
||||
import { QueryHookArgs } from '../../../lib/react-query';
|
||||
|
||||
export const useMusicFolders = () => {
|
||||
const server = useCurrentServer();
|
||||
export const useMusicFolders = (args: QueryHookArgs<MusicFolderListQuery>) => {
|
||||
const { options, serverId } = args || {};
|
||||
const server = getServerById(serverId);
|
||||
|
||||
const query = useQuery({
|
||||
enabled: !!server?.id,
|
||||
queryFn: ({ signal }) => api.controller.getMusicFolderList({ server, signal }),
|
||||
enabled: !!server,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getMusicFolderList({ apiClientProps: { server, signal } });
|
||||
},
|
||||
queryKey: queryKeys.musicFolders.list(server?.id || ''),
|
||||
select: useCallback(
|
||||
(data: RawMusicFolderListResponse | undefined) => {
|
||||
return api.normalize.musicFolderList(data, server);
|
||||
},
|
||||
[server],
|
||||
),
|
||||
...options,
|
||||
});
|
||||
|
||||
return query;
|
||||
|
||||
@@ -100,9 +100,12 @@ export const Sidebar = () => {
|
||||
};
|
||||
|
||||
const playlistsQuery = usePlaylistList({
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
query: {
|
||||
sortBy: PlaylistListSort.NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
startIndex: 0,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const setFullScreenPlayerStore = useSetFullScreenPlayerStore();
|
||||
@@ -152,7 +155,6 @@ export const Sidebar = () => {
|
||||
control: {
|
||||
'&:hover': { background: 'none', color: 'var(--sidebar-fg-hover)' },
|
||||
color: 'var(--sidebar-fg)',
|
||||
padding: '1rem 1rem',
|
||||
transition: 'color 0.2s ease-in-out',
|
||||
},
|
||||
item: { borderBottom: 'none', color: 'var(--sidebar-fg)' },
|
||||
|
||||
@@ -9,30 +9,32 @@ interface JellyfinSongFiltersProps {
|
||||
handleFilterChange: (filters: SongListFilter) => void;
|
||||
id?: string;
|
||||
pageKey: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const JellyfinSongFilters = ({
|
||||
id,
|
||||
pageKey,
|
||||
handleFilterChange,
|
||||
serverId,
|
||||
}: JellyfinSongFiltersProps) => {
|
||||
const { setFilter } = useListStoreActions();
|
||||
const filter = useSongListFilter({ id, key: pageKey });
|
||||
|
||||
// TODO - eventually replace with /items/filters endpoint to fetch genres and tags specific to the selected library
|
||||
const genreListQuery = useGenreList(null);
|
||||
const genreListQuery = useGenreList({ query: null, serverId });
|
||||
|
||||
const genreList = useMemo(() => {
|
||||
if (!genreListQuery?.data) return [];
|
||||
return genreListQuery.data.map((genre) => ({
|
||||
return genreListQuery.data.items.map((genre) => ({
|
||||
label: genre.name,
|
||||
value: genre.id,
|
||||
}));
|
||||
}, [genreListQuery.data]);
|
||||
|
||||
const selectedGenres = useMemo(() => {
|
||||
return filter.jfParams?.genreIds?.split(',');
|
||||
}, [filter.jfParams?.genreIds]);
|
||||
return filter._custom?.jellyfin?.genreIds?.split(',');
|
||||
}, [filter._custom?.jellyfin?.genreIds]);
|
||||
|
||||
const toggleFilters = [
|
||||
{
|
||||
@@ -40,17 +42,20 @@ export const JellyfinSongFilters = ({
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
includeItemTypes: 'Audio',
|
||||
isFavorite: e.currentTarget.checked ? true : undefined,
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
includeItemTypes: 'Audio',
|
||||
isFavorite: e.currentTarget.checked ? true : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
}) as SongListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.jfParams?.isFavorite,
|
||||
value: filter._custom?.jellyfin?.isFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,10 +63,13 @@ export const JellyfinSongFilters = ({
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
includeItemTypes: 'Audio',
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
includeItemTypes: 'Audio',
|
||||
minYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -73,10 +81,13 @@ export const JellyfinSongFilters = ({
|
||||
if (typeof e === 'number' && (e < 1700 || e > 2300)) return;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
includeItemTypes: 'Audio',
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
includeItemTypes: 'Audio',
|
||||
maxYear: e === '' ? undefined : (e as number),
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -88,10 +99,13 @@ export const JellyfinSongFilters = ({
|
||||
const genreFilterString = e?.length ? e.join(',') : undefined;
|
||||
const updatedFilters = setFilter({
|
||||
data: {
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
genreIds: genreFilterString,
|
||||
includeItemTypes: 'Audio',
|
||||
_custom: {
|
||||
...filter._custom,
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
genreIds: genreFilterString,
|
||||
includeItemTypes: 'Audio',
|
||||
},
|
||||
},
|
||||
},
|
||||
key: pageKey,
|
||||
@@ -117,14 +131,14 @@ export const JellyfinSongFilters = ({
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
required
|
||||
defaultValue={filter.jfParams?.minYear}
|
||||
defaultValue={filter._custom?.jellyfin?.minYear}
|
||||
label="From year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
onChange={handleMinYearFilter}
|
||||
/>
|
||||
<NumberInput
|
||||
defaultValue={filter.jfParams?.maxYear}
|
||||
defaultValue={filter._custom?.jellyfin?.maxYear}
|
||||
label="To year"
|
||||
max={2300}
|
||||
min={1700}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user