Compare commits

...

40 Commits

Author SHA1 Message Date
jeffvli a19673d3c2 Replace mutation error types with AxiosError 2023-05-09 05:53:57 -07:00
jeffvli 3efeaa7359 Improve multi-server controller 2023-05-09 05:49:05 -07:00
jeffvli 63be8c8fb8 Add authenticate function to controller 2023-05-09 05:48:11 -07:00
jeffvli 975c31635a Remove old API implementation 2023-05-09 05:45:55 -07:00
jeffvli 9b5bce34a0 Fix jellyfin auth endpoint 2023-05-09 05:06:32 -07:00
jeffvli bb27758310 Re-serialize subsonic array params 2023-05-09 05:05:15 -07:00
jeffvli 2d7c52a6b6 Improve UX for edit server form
- Auto focus the password field on edit server form
- Don't disable save button when fields blank
- Add tooltip for modified fields
2023-05-09 02:40:49 -07:00
jeffvli cbb15ac7ee Fix various issues 2023-05-09 02:25:57 -07:00
jeffvli b2db2b27da Refactor server list to object instead of array
- Improve performance due to frequency of accessing the list
2023-05-09 00:39:11 -07:00
jeffvli 3dfeed1432 Invalidate playlist list on creation 2023-05-08 03:35:51 -07:00
jeffvli 2101f1e9a7 Fix legacy normalizations 2023-05-08 03:35:23 -07:00
jeffvli 8a0a8e4d54 Refactor jellyfin api with ts-rest/axios 2023-05-08 03:34:15 -07:00
jeffvli a9ca3f9083 Add additional undefined check for custom filters 2023-05-08 03:33:38 -07:00
jeffvli 6d5e10a31c Add albumCount and songCount to genre 2023-05-08 02:42:38 -07:00
jeffvli 5b616d5928 Update initial list store filters 2023-04-30 22:53:11 -07:00
jeffvli 62670964c0 Add menu in error boundary 2023-04-30 22:05:06 -07:00
jeffvli 314bd766df Refactor all api instances in components 2023-04-30 22:01:52 -07:00
jeffvli bdd023fde3 Refactor remaining queries/mutations for new controller 2023-04-30 18:00:50 -07:00
jeffvli 40aabd2217 Additional refactor for navidrome api controller types 2023-04-30 17:55:23 -07:00
jeffvli b9d5447b4f Allow serverId to be undefined 2023-04-27 22:20:35 -07:00
jeffvli 68a1cb9aaa Refactor all mutation hooks 2023-04-27 21:44:25 -07:00
jeffvli bf3024939a Refactor all query hooks 2023-04-27 21:25:57 -07:00
jeffvli df9464f762 Additional refactor to api and types 2023-04-27 20:34:28 -07:00
jeffvli 17cf624f6a Add generic query/mutation types 2023-04-27 20:32:56 -07:00
jeffvli 8f042ad448 Pass full server to controller 2023-04-25 16:25:26 -07:00
jeffvli 1cbd61888f Refactor server list as hash table 2023-04-25 01:36:26 -07:00
jeffvli 2ce49fc54e Add new server api to main controller 2023-04-24 01:22:58 -07:00
jeffvli bec328f1f4 Add Subsonic API and types 2023-04-24 01:21:29 -07:00
jeffvli ea8c63b71b Add new navidrome api controller 2023-04-23 19:57:10 -07:00
jeffvli 52049ce163 Add missing elements from Navidrome API 2023-04-23 19:54:36 -07:00
jeffvli 70c62c8b52 Refactor api client to support dynamic server 2023-04-23 14:26:41 -07:00
jeffvli fa79b4cbe0 Fix artist path 2023-04-23 14:25:09 -07:00
jeffvli 438085633b Modify navidrome responses to include header 2023-04-23 02:09:48 -07:00
jeffvli fe043d1823 Add function to modify base response 2023-04-23 02:09:25 -07:00
jeffvli 9bd12df8f6 Add navidrome API and types 2023-04-23 01:39:47 -07:00
jeffvli 637d420e1c Add ts-rest and axios 2023-04-23 01:25:16 -07:00
jeffvli c593b7bc46 Fix slider styles to account for transparent thumb (#85) 2023-04-20 01:54:51 -07:00
jeffvli 5e90139b17 Fix styles from mantine upgrade 2023-04-20 01:47:42 -07:00
jeffvli ed86d8ffd2 Bump mantine to v6.0.8 2023-04-20 01:45:27 -07:00
jeffvli bcaaaac586 Set auto-update as default 2023-04-03 18:26:56 -07:00
112 changed files with 6223 additions and 4438 deletions
+139 -119
View File
@@ -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
View File
@@ -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
View File
@@ -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,
-2
View File
@@ -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
+336
View File
@@ -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,
};
+667
View File
@@ -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,
},
};
-756
View File
@@ -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,
};
+271
View File
@@ -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,
},
};
-292
View File
@@ -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,
};
-497
View File
@@ -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,
};
+168
View File
@@ -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,
};
+201
View File
@@ -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
View File
@@ -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 = {
+23
View File
@@ -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),
});
};
-2
View File
@@ -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,
});
};
@@ -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,
};
@@ -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>
+1 -1
View File
@@ -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