mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
Compare commits
265 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8e23e8d4 | |||
| 7fa4641dfe | |||
| 4167af098f | |||
| c5f551e963 | |||
| fbd0e5b27b | |||
| 5877b8cc6f | |||
| 23f4bfde99 | |||
| 4898fa7dcf | |||
| a6990fd732 | |||
| 2fac9efc1b | |||
| a3a84766e4 | |||
| 0e9a77ffe0 | |||
| 8f7e6a5222 | |||
| 736945d6ef | |||
| f97e855f51 | |||
| d6e628099c | |||
| d7ca25525c | |||
| 72099cb1fe | |||
| eeefe9d9dc | |||
| 86c3e54119 | |||
| ea0737cf1f | |||
| f4eaacc64a | |||
| 7f6efbe6dc | |||
| 72811dbedb | |||
| 493e13ebc0 | |||
| 14aeee888f | |||
| cbc08d6f03 | |||
| 77703b904f | |||
| 762644d23d | |||
| 75403078d2 | |||
| 255a131f3b | |||
| e56350c1c2 | |||
| aaa1b5f63a | |||
| 3d409bb6f1 | |||
| 7ab532be07 | |||
| 946b73d215 | |||
| 2f0634dc03 | |||
| f8ecb3fc53 | |||
| 01608fa875 | |||
| 14a6766072 | |||
| 0fa5b6496f | |||
| 43c11ab6e3 | |||
| 41a901f3c4 | |||
| 2bdc664619 | |||
| 8835fc640a | |||
| f92cd89c46 | |||
| a1a113d3c6 | |||
| 3f78c3f420 | |||
| f10912d930 | |||
| 98fa47348c | |||
| d38c846e80 | |||
| 007a099951 | |||
| 9622cd346c | |||
| c3c1f4cc5f | |||
| d97fe4c621 | |||
| 7e5733db34 | |||
| d1dde2428f | |||
| 190dd71b3c | |||
| feb61c28d7 | |||
| f380eccc68 | |||
| cf43bf360e | |||
| 48dfd469ed | |||
| 5dd860735d | |||
| 7cd2077dcd | |||
| 7430bba853 | |||
| 782c351ca6 | |||
| 3aef2a80a7 | |||
| 85a10c799a | |||
| 9eef570740 | |||
| 58f38b2655 | |||
| 85d2576bdc | |||
| 23f9bd4e9f | |||
| 8eb0029bb8 | |||
| c8a0df4759 | |||
| e7bc29a8f1 | |||
| 5295c69f46 | |||
| f58552be84 | |||
| cd57142caf | |||
| 86ad2d0383 | |||
| 7d5aa6fd13 | |||
| f2ef630921 | |||
| 9250b30249 | |||
| 2b16cce0aa | |||
| 34870556b4 | |||
| 7e2d9bd585 | |||
| 691bc8f1ef | |||
| 5dbc0c61c5 | |||
| 0bc1ee3492 | |||
| 7403a46f91 | |||
| 8ffb81093d | |||
| d312c3c70a | |||
| cd66a9dccb | |||
| f2690b262f | |||
| 63c5a83911 | |||
| 17b1acad9d | |||
| e7c7eb3ec0 | |||
| fa0a21a021 | |||
| 791088deb6 | |||
| 9c1a2a4a8d | |||
| 6d092d9ebc | |||
| 73997cf6c7 | |||
| 1d074dae2e | |||
| a878875f83 | |||
| d055ae89e0 | |||
| f83639d5f8 | |||
| 97ccf3bc6d | |||
| 76805a0b19 | |||
| 0103a84358 | |||
| 611cbc6dd9 | |||
| 011f260e94 | |||
| e937425f4f | |||
| bc2624bffd | |||
| 4f21c26e5d | |||
| e6a4ce2e64 | |||
| 5b98238b3a | |||
| d96c0d547a | |||
| 3c62de8347 | |||
| 07d4dc37b5 | |||
| 64c5f25d18 | |||
| 098e86b1f4 | |||
| adc3e421f6 | |||
| d289797d65 | |||
| 6218b27117 | |||
| 549db7b1bf | |||
| 8ee99adb2d | |||
| da519c2250 | |||
| 7cd33ad388 | |||
| 8ae368ea4f | |||
| 22e31b92a4 | |||
| a308efaf06 | |||
| 977cb89481 | |||
| 0c3b030b13 | |||
| 86080c7875 | |||
| b71c3c7c53 | |||
| debdb92dcf | |||
| ba6f2a1637 | |||
| 7c6f62023a | |||
| de50002ea7 | |||
| 41a251c2ac | |||
| 10d7664733 | |||
| fed96d1fce | |||
| 106fc90c4a | |||
| c1c6ce33e4 | |||
| 26bc7d23ae | |||
| 30dc833b79 | |||
| 292737d53c | |||
| 652c4a1f81 | |||
| fb158bc069 | |||
| 51c2731b07 | |||
| 93530008a9 | |||
| 6747fbb701 | |||
| 06d253228a | |||
| c8b1b4d394 | |||
| 0320fe6dcc | |||
| 1f36978bb9 | |||
| 6a01d44600 | |||
| 35f9798bed | |||
| 897af4661b | |||
| 3df2915f5f | |||
| 02caf896ff | |||
| 7dd56bfb9b | |||
| fe59011882 | |||
| c854fd0a5b | |||
| 645b4fe332 | |||
| e5f24b3160 | |||
| fff1315fa5 | |||
| ba0543f861 | |||
| 30c4d5baf1 | |||
| e8f7ae637f | |||
| b5fa6f0baa | |||
| c4fb9a2e72 | |||
| 2cefc092ce | |||
| a7ea54cf4b | |||
| deb4e34895 | |||
| 33ecf9faa6 | |||
| cf6325d0ba | |||
| c12c1bad73 | |||
| cf9ed31dfd | |||
| c296927bbb | |||
| 32ebe6b739 | |||
| c85a7079eb | |||
| 822060b82c | |||
| 547fe7be38 | |||
| ccf5588435 | |||
| 4cb54bc9da | |||
| ce72ff5e8d | |||
| 71b9cace53 | |||
| 5637327e8a | |||
| a1072b461f | |||
| 3fb24d5f64 | |||
| e45252d16c | |||
| 48ef7a987f | |||
| 58d912065b | |||
| d8130f48e2 | |||
| 89afa9b836 | |||
| 684ba13175 | |||
| 2399105f6c | |||
| d42f4dbe4f | |||
| cf32a7ff21 | |||
| 5eea3d7e01 | |||
| e2e3a50f1f | |||
| 4c98afb613 | |||
| d7f24262fd | |||
| 6056504f00 | |||
| cef92243f5 | |||
| 8d5c82b0c6 | |||
| 003fb26c60 | |||
| 4eb90d20a2 | |||
| cf489d3934 | |||
| 416476cc66 | |||
| bdc3daf6da | |||
| 129515d57a | |||
| 76ca03d8e3 | |||
| e49fe6c452 | |||
| ec7a053a74 | |||
| 9e4e6172c3 | |||
| eca26e912f | |||
| f9e410a1f5 | |||
| 87abd0c6f5 | |||
| e3665e6407 | |||
| c87905f6c2 | |||
| 2100c1495d | |||
| b5da8aeb55 | |||
| 5eeded6c72 | |||
| 346b8be122 | |||
| a19673d3c2 | |||
| 3efeaa7359 | |||
| 63be8c8fb8 | |||
| 975c31635a | |||
| 9b5bce34a0 | |||
| bb27758310 | |||
| 2d7c52a6b6 | |||
| cbb15ac7ee | |||
| b2db2b27da | |||
| 3dfeed1432 | |||
| 2101f1e9a7 | |||
| 8a0a8e4d54 | |||
| a9ca3f9083 | |||
| 6d5e10a31c | |||
| 5b616d5928 | |||
| 62670964c0 | |||
| 314bd766df | |||
| bdd023fde3 | |||
| 40aabd2217 | |||
| b9d5447b4f | |||
| 68a1cb9aaa | |||
| bf3024939a | |||
| df9464f762 | |||
| 17cf624f6a | |||
| 8f042ad448 | |||
| 1cbd61888f | |||
| 2ce49fc54e | |||
| bec328f1f4 | |||
| ea8c63b71b | |||
| 52049ce163 | |||
| 70c62c8b52 | |||
| fa79b4cbe0 | |||
| 438085633b | |||
| fe043d1823 | |||
| 9bd12df8f6 | |||
| 637d420e1c | |||
| c593b7bc46 | |||
| 5e90139b17 | |||
| ed86d8ffd2 | |||
| bcaaaac586 |
@@ -1 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
ko_fi: jeffvli
|
||||
|
||||
@@ -39,6 +39,7 @@ labels: 'bug'
|
||||
|
||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||
|
||||
- Application version :
|
||||
- Operating System and version :
|
||||
- Application version (e.g. v0.1.0) :
|
||||
- Operating System and version (e.g. Windows 10) :
|
||||
- Server and version (e.g. Navidrome v0.48.0) :
|
||||
- Node version (if developing locally) :
|
||||
|
||||
@@ -7,3 +7,5 @@ labels: 'enhancement'
|
||||
## What do you want to be added?
|
||||
|
||||
## Additional context
|
||||
|
||||
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
|
||||
|
||||
Regular → Executable
@@ -34,16 +34,27 @@ Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
- [x] Modern UI
|
||||
- [x] Scrobble playback to your server
|
||||
- [x] Smart playlist editor (Navidrome)
|
||||
- [x] Synchronized and unsynchronized lyrics support
|
||||
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
|
||||
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
|
||||
|
||||
## Getting Started
|
||||
|
||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
|
||||
|
||||
If you're using an M1 macOS device, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
|
||||
|
||||
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
|
||||
|
||||
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
|
||||
|
||||
## FAQ
|
||||
|
||||
### What music servers does Feishin support?
|
||||
@@ -52,9 +63,8 @@ Feishin supports any music server that implements a [Navidrome](https://www.navi
|
||||
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- ~~[Gonic](https://github.com/sentriz/gonic)~~
|
||||
- ~~[Astiga](https://asti.ga/)~~
|
||||
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
|
||||
- [Funkwhale](https://funkwhale.audio/) - TBD
|
||||
- Subsonic-compatible servers - TBD
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 465 KiB |
Generated
+1040
-781
File diff suppressed because it is too large
Load Diff
+27
-24
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.0.1-alpha6",
|
||||
"version": "0.2.0",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
@@ -254,51 +254,54 @@
|
||||
"@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",
|
||||
"@tanstack/react-query": "^4.24.4",
|
||||
"@tanstack/react-query-devtools": "^4.24.4",
|
||||
"@mantine/core": "^6.0.13",
|
||||
"@mantine/dates": "^6.0.13",
|
||||
"@mantine/form": "^6.0.13",
|
||||
"@mantine/hooks": "^6.0.13",
|
||||
"@mantine/modals": "^6.0.13",
|
||||
"@mantine/notifications": "^6.0.13",
|
||||
"@mantine/utils": "^6.0.13",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"@tanstack/react-query-devtools": "^4.29.6",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"axios": "^1.4.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-log": "^4.4.6",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"fast-average-color": "^9.3.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"framer-motion": "^8.1.3",
|
||||
"framer-motion": "^9.1.7",
|
||||
"fuse.js": "^6.6.2",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"immer": "^9.0.15",
|
||||
"is-electron": "^2.2.1",
|
||||
"ky": "^0.33.0",
|
||||
"immer": "^9.0.21",
|
||||
"is-electron": "^2.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"nanoid": "^3.3.3",
|
||||
"net": "^1.0.2",
|
||||
"node-mpv": "^2.0.0-beta.2",
|
||||
"node-mpv": "github:jeffvli/Node-MPV",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-i18next": "^11.16.7",
|
||||
"react-icons": "^4.7.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-player": "^2.11.0",
|
||||
"react-router": "^6.5.0",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-simple-img": "^3.0.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.8",
|
||||
"react-window-infinite-loader": "^1.0.8",
|
||||
"styled-components": "^5.3.6",
|
||||
"zod": "^3.19.1",
|
||||
"zustand": "^4.1.4"
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
"react-window-infinite-loader": "^1.0.9",
|
||||
"styled-components": "^5.3.11",
|
||||
"swiper": "^9.3.1",
|
||||
"zod": "^3.21.4",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
"resolutions": {
|
||||
"styled-components": "^5"
|
||||
|
||||
Generated
+307
-2
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha6",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha6",
|
||||
"version": "0.2.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -147,6 +148,11 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
@@ -207,6 +213,42 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"parse5": "^7.0.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
@@ -219,6 +261,32 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
|
||||
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
|
||||
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/dbus-next": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||
@@ -327,6 +395,57 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
@@ -359,6 +478,17 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -633,6 +763,24 @@
|
||||
"hexy": "bin/hexy_cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
@@ -820,6 +968,17 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||
@@ -861,6 +1020,29 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
|
||||
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
|
||||
"dependencies": {
|
||||
"entities": "^4.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
|
||||
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.2",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
@@ -1248,6 +1430,11 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||
},
|
||||
"boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
@@ -1296,6 +1483,33 @@
|
||||
"get-intrinsic": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"cheerio": {
|
||||
"version": "1.0.0-rc.12",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz",
|
||||
"integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==",
|
||||
"requires": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"parse5": "^7.0.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
@@ -1305,6 +1519,23 @@
|
||||
"mimic-response": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"css-select": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
|
||||
"integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"css-what": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
|
||||
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="
|
||||
},
|
||||
"dbus-next": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/dbus-next/-/dbus-next-0.9.2.tgz",
|
||||
@@ -1381,6 +1612,39 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
|
||||
},
|
||||
"domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
|
||||
"requires": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
}
|
||||
},
|
||||
"duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
@@ -1406,6 +1670,11 @@
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
|
||||
},
|
||||
"env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
@@ -1608,6 +1877,17 @@
|
||||
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
|
||||
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A=="
|
||||
},
|
||||
"htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"http-cache-semantics": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
@@ -1756,6 +2036,14 @@
|
||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
||||
"dev": true
|
||||
},
|
||||
"nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"object-is": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz",
|
||||
@@ -1785,6 +2073,23 @@
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||
"dev": true
|
||||
},
|
||||
"parse5": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
|
||||
"integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==",
|
||||
"requires": {
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz",
|
||||
"integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==",
|
||||
"requires": {
|
||||
"domhandler": "^5.0.2",
|
||||
"parse5": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha6",
|
||||
"version": "0.2.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -13,6 +13,7 @@
|
||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import './lyrics';
|
||||
import './player';
|
||||
import './settings';
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { load } from 'cheerio';
|
||||
import {
|
||||
LyricSource,
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const SEARCH_URL = 'https://genius.com/api/search/song';
|
||||
|
||||
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/genius.ts
|
||||
|
||||
export interface GeniusResponse {
|
||||
meta: Meta;
|
||||
response: Response;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
next_page: number;
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
hits: Hit[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Hit {
|
||||
highlights: any[];
|
||||
index: string;
|
||||
result: Result;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
_type: string;
|
||||
annotation_count: number;
|
||||
api_path: string;
|
||||
artist_names: string;
|
||||
featured_artists: any[];
|
||||
full_title: string;
|
||||
header_image_thumbnail_url: string;
|
||||
header_image_url: string;
|
||||
id: number;
|
||||
instrumental: boolean;
|
||||
language: string;
|
||||
lyrics_owner_id: number;
|
||||
lyrics_state: string;
|
||||
lyrics_updated_at: number;
|
||||
path: string;
|
||||
primary_artist: PrimaryArtist;
|
||||
pyongs_count: null;
|
||||
relationships_index_url: string;
|
||||
release_date_components: ReleaseDateComponents;
|
||||
release_date_for_display: string;
|
||||
release_date_with_abbreviated_month_for_display: string;
|
||||
song_art_image_thumbnail_url: string;
|
||||
song_art_image_url: string;
|
||||
stats: Stats;
|
||||
title: string;
|
||||
title_with_featured: string;
|
||||
updated_by_human_at: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PrimaryArtist {
|
||||
_type: string;
|
||||
api_path: string;
|
||||
header_image_url: string;
|
||||
id: number;
|
||||
image_url: string;
|
||||
index_character: string;
|
||||
is_meme_verified: boolean;
|
||||
is_verified: boolean;
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ReleaseDateComponents {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
hot: boolean;
|
||||
unreviewed_annotations: number;
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||
let result: AxiosResponse<GeniusResponse>;
|
||||
|
||||
const searchQuery = [params.artist, params.name].join(' ');
|
||||
|
||||
if (!searchQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
result = await axios.get(SEARCH_URL, {
|
||||
params: {
|
||||
per_page: '5',
|
||||
q: searchQuery,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Genius search request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSongsResult = result.data.response?.sections?.[0]?.hits?.map((hit) => hit.result);
|
||||
|
||||
if (!rawSongsResult) return null;
|
||||
|
||||
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
|
||||
return {
|
||||
artist: song.artist_names,
|
||||
id: song.url,
|
||||
name: song.full_title,
|
||||
source: LyricSource.GENIUS,
|
||||
};
|
||||
});
|
||||
|
||||
return orderSearchResults({ params, results: songResults });
|
||||
}
|
||||
|
||||
async function getSongId(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
|
||||
let result: AxiosResponse<GeniusResponse>;
|
||||
try {
|
||||
result = await axios.get(SEARCH_URL, {
|
||||
params: {
|
||||
per_page: '1',
|
||||
q: `${params.artist} ${params.name}`,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Genius search request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hit = result.data.response?.sections?.[0]?.hits?.[0]?.result;
|
||||
|
||||
if (!hit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: hit.artist_names,
|
||||
id: hit.url,
|
||||
name: hit.full_title,
|
||||
source: LyricSource.GENIUS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(url: string): Promise<string | null> {
|
||||
let result: AxiosResponse<string, any>;
|
||||
try {
|
||||
result = await axios.get<string>(url, { responseType: 'text' });
|
||||
} catch (e) {
|
||||
console.error('Genius lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const $ = load(result.data.split('<br/>').join('\n'));
|
||||
const lyricsDiv = $('div.lyrics');
|
||||
|
||||
if (lyricsDiv.length > 0) return lyricsDiv.text().trim();
|
||||
|
||||
const lyricSections = $('div[class^=Lyrics__Container]')
|
||||
.map((_, e) => $(e).text())
|
||||
.toArray()
|
||||
.join('\n');
|
||||
return lyricSections;
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
const response = await getSongId(params);
|
||||
if (!response) {
|
||||
console.error('Could not find the song on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyricsBySongId(response.id);
|
||||
if (!lyrics) {
|
||||
console.error('Could not get lyrics on Genius!');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: response.artist,
|
||||
id: response.id,
|
||||
lyrics,
|
||||
name: response.name,
|
||||
source: LyricSource.GENIUS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
QueueSong,
|
||||
LyricGetQuery,
|
||||
LyricSource,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { store } from '../settings/index';
|
||||
import {
|
||||
query as queryGenius,
|
||||
getSearchResults as searchGenius,
|
||||
getLyricsBySongId as getGenius,
|
||||
} from './genius';
|
||||
import {
|
||||
query as queryLrclib,
|
||||
getSearchResults as searchLrcLib,
|
||||
getLyricsBySongId as getLrcLib,
|
||||
} from './lrclib';
|
||||
import {
|
||||
query as queryNetease,
|
||||
getSearchResults as searchNetease,
|
||||
getLyricsBySongId as getNetease,
|
||||
} from './netease';
|
||||
|
||||
type SongFetcher = (params: LyricSearchQuery) => Promise<InternetProviderLyricResponse | null>;
|
||||
type SearchFetcher = (
|
||||
params: LyricSearchQuery,
|
||||
) => Promise<InternetProviderLyricSearchResponse[] | null>;
|
||||
type GetFetcher = (id: string) => Promise<string | null>;
|
||||
|
||||
type CachedLyrics = Record<LyricSource, InternetProviderLyricResponse>;
|
||||
|
||||
const FETCHERS: Record<LyricSource, SongFetcher> = {
|
||||
[LyricSource.GENIUS]: queryGenius,
|
||||
[LyricSource.LRCLIB]: queryLrclib,
|
||||
[LyricSource.NETEASE]: queryNetease,
|
||||
};
|
||||
|
||||
const SEARCH_FETCHERS: Record<LyricSource, SearchFetcher> = {
|
||||
[LyricSource.GENIUS]: searchGenius,
|
||||
[LyricSource.LRCLIB]: searchLrcLib,
|
||||
[LyricSource.NETEASE]: searchNetease,
|
||||
};
|
||||
|
||||
const GET_FETCHERS: Record<LyricSource, GetFetcher> = {
|
||||
[LyricSource.GENIUS]: getGenius,
|
||||
[LyricSource.LRCLIB]: getLrcLib,
|
||||
[LyricSource.NETEASE]: getNetease,
|
||||
};
|
||||
|
||||
const MAX_CACHED_ITEMS = 10;
|
||||
|
||||
const lyricCache = new Map<string, CachedLyrics>();
|
||||
|
||||
const getRemoteLyrics = async (song: QueueSong) => {
|
||||
const sources = store.get('lyrics', []) as LyricSource[];
|
||||
|
||||
const cached = lyricCache.get(song.id);
|
||||
|
||||
if (cached) {
|
||||
for (const source of sources) {
|
||||
const data = cached[source];
|
||||
if (data) return data;
|
||||
}
|
||||
}
|
||||
|
||||
let lyricsFromSource = null;
|
||||
|
||||
for (const source of sources) {
|
||||
const params = {
|
||||
album: song.album || song.name,
|
||||
artist: song.artistName,
|
||||
duration: song.duration,
|
||||
name: song.name,
|
||||
};
|
||||
const response = await FETCHERS[source](params);
|
||||
|
||||
if (response) {
|
||||
const newResult = cached
|
||||
? {
|
||||
...cached,
|
||||
[source]: response,
|
||||
}
|
||||
: ({ [source]: response } as CachedLyrics);
|
||||
|
||||
if (lyricCache.size === MAX_CACHED_ITEMS && cached === undefined) {
|
||||
const toRemove = lyricCache.keys().next().value;
|
||||
lyricCache.delete(toRemove);
|
||||
}
|
||||
|
||||
lyricCache.set(song.id, newResult);
|
||||
|
||||
lyricsFromSource = response;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lyricsFromSource;
|
||||
};
|
||||
|
||||
const searchRemoteLyrics = async (params: LyricSearchQuery) => {
|
||||
const sources = store.get('lyrics', []) as LyricSource[];
|
||||
|
||||
const results: Record<LyricSource, InternetProviderLyricSearchResponse[]> = {
|
||||
[LyricSource.GENIUS]: [],
|
||||
[LyricSource.LRCLIB]: [],
|
||||
[LyricSource.NETEASE]: [],
|
||||
};
|
||||
|
||||
for (const source of sources) {
|
||||
const response = await SEARCH_FETCHERS[source](params);
|
||||
|
||||
if (response) {
|
||||
response.forEach((result) => {
|
||||
results[source].push(result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const getRemoteLyricsById = async (params: LyricGetQuery): Promise<string | null> => {
|
||||
const { remoteSongId, remoteSource } = params;
|
||||
const response = await GET_FETCHERS[remoteSource](remoteSongId);
|
||||
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
ipcMain.handle('lyric-by-song', async (_event, song: QueueSong) => {
|
||||
const lyric = await getRemoteLyrics(song);
|
||||
return lyric;
|
||||
});
|
||||
|
||||
ipcMain.handle('lyric-search', async (_event, params: LyricSearchQuery) => {
|
||||
const lyricResults = await searchRemoteLyrics(params);
|
||||
return lyricResults;
|
||||
});
|
||||
|
||||
ipcMain.handle('lyric-by-remote-id', async (_event, params: LyricGetQuery) => {
|
||||
const lyricResults = await getRemoteLyricsById(params);
|
||||
return lyricResults;
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// Credits to https://github.com/tranxuanthang/lrcget for API implementation
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
LyricSource,
|
||||
} from '../../../../renderer/api/types';
|
||||
import { orderSearchResults } from './shared';
|
||||
|
||||
const FETCH_URL = 'https://lrclib.net/api/get';
|
||||
const SEEARCH_URL = 'https://lrclib.net/api/search';
|
||||
|
||||
const TIMEOUT_MS = 5000;
|
||||
|
||||
export interface LrcLibSearchResponse {
|
||||
albumName: string;
|
||||
artistName: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LrcLibTrackResponse {
|
||||
albumName: string;
|
||||
artistName: string;
|
||||
duration: number;
|
||||
id: number;
|
||||
instrumental: boolean;
|
||||
isrc: string;
|
||||
lang: string;
|
||||
name: string;
|
||||
plainLyrics: string | null;
|
||||
releaseDate: string;
|
||||
spotifyId: string;
|
||||
syncedLyrics: string | null;
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||
let result: AxiosResponse<LrcLibSearchResponse[]>;
|
||||
|
||||
if (!params.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
result = await axios.get<LrcLibSearchResponse[]>(SEEARCH_URL, {
|
||||
params: {
|
||||
q: params.name,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('LrcLib search request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!result.data) return null;
|
||||
|
||||
const songResults: InternetProviderLyricSearchResponse[] = result.data.map((song) => {
|
||||
return {
|
||||
artist: song.artistName,
|
||||
id: String(song.id),
|
||||
name: song.name,
|
||||
source: LyricSource.LRCLIB,
|
||||
};
|
||||
});
|
||||
|
||||
return orderSearchResults({ params, results: songResults });
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(songId: string): Promise<string | null> {
|
||||
let result: AxiosResponse<LrcLibTrackResponse, any>;
|
||||
|
||||
try {
|
||||
result = await axios.get<LrcLibTrackResponse>(`${FETCH_URL}/${songId}`);
|
||||
} catch (e) {
|
||||
console.error('LrcLib lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.syncedLyrics || result.data.plainLyrics || null;
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
let result: AxiosResponse<LrcLibTrackResponse, any>;
|
||||
|
||||
try {
|
||||
result = await axios.get<LrcLibTrackResponse>(FETCH_URL, {
|
||||
params: {
|
||||
album_name: params.album,
|
||||
artist_name: params.artist,
|
||||
duration: params.duration,
|
||||
track_name: params.name,
|
||||
},
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('LrcLib search request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = result.data.syncedLyrics || result.data.plainLyrics || null;
|
||||
|
||||
if (!lyrics) {
|
||||
console.error(`Could not get lyrics on LrcLib!`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: result.data.artistName,
|
||||
id: String(result.data.id),
|
||||
lyrics,
|
||||
name: result.data.name,
|
||||
source: LyricSource.LRCLIB,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { LyricSource } from '../../../../renderer/api/types';
|
||||
import { orderSearchResults } from './shared';
|
||||
import type {
|
||||
InternetProviderLyricResponse,
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
} from '/@/renderer/api/types';
|
||||
|
||||
const SEARCH_URL = 'https://music.163.com/api/search/get';
|
||||
const LYRICS_URL = 'https://music.163.com/api/song/lyric';
|
||||
|
||||
// Adapted from https://github.com/NyaomiDEV/Sunamu/blob/master/src/main/lyricproviders/netease.ts
|
||||
|
||||
export interface NetEaseResponse {
|
||||
code: number;
|
||||
result: Result;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
hasMore: boolean;
|
||||
songCount: number;
|
||||
songs: Song[];
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
album: Album;
|
||||
alias: string[];
|
||||
artists: Artist[];
|
||||
copyrightId: number;
|
||||
duration: number;
|
||||
fee: number;
|
||||
ftype: number;
|
||||
id: number;
|
||||
mark: number;
|
||||
mvid: number;
|
||||
name: string;
|
||||
rUrl: null;
|
||||
rtype: number;
|
||||
status: number;
|
||||
transNames?: string[];
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
artist: Artist;
|
||||
copyrightId: number;
|
||||
id: number;
|
||||
mark: number;
|
||||
name: string;
|
||||
picId: number;
|
||||
publishTime: number;
|
||||
size: number;
|
||||
status: number;
|
||||
transNames?: string[];
|
||||
}
|
||||
|
||||
export interface Artist {
|
||||
albumSize: number;
|
||||
alias: any[];
|
||||
fansGroup: null;
|
||||
id: number;
|
||||
img1v1: number;
|
||||
img1v1Url: string;
|
||||
name: string;
|
||||
picId: number;
|
||||
picUrl: null;
|
||||
trans: null;
|
||||
}
|
||||
|
||||
export async function getSearchResults(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricSearchResponse[] | null> {
|
||||
let result: AxiosResponse<NetEaseResponse>;
|
||||
|
||||
const searchQuery = [params.artist, params.name].join(' ');
|
||||
|
||||
if (!searchQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
result = await axios.get(SEARCH_URL, {
|
||||
params: {
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
s: searchQuery,
|
||||
type: '1',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('NetEase search request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSongsResult = result?.data.result?.songs;
|
||||
|
||||
if (!rawSongsResult) return null;
|
||||
|
||||
const songResults: InternetProviderLyricSearchResponse[] = rawSongsResult.map((song) => {
|
||||
const artist = song.artists ? song.artists.map((artist) => artist.name).join(', ') : '';
|
||||
|
||||
return {
|
||||
artist,
|
||||
id: String(song.id),
|
||||
name: song.name,
|
||||
source: LyricSource.NETEASE,
|
||||
};
|
||||
});
|
||||
|
||||
return orderSearchResults({ params, results: songResults });
|
||||
}
|
||||
|
||||
async function getMatchedLyrics(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<Omit<InternetProviderLyricResponse, 'lyrics'> | null> {
|
||||
const results = await getSearchResults(params);
|
||||
|
||||
const firstMatch = results?.[0];
|
||||
|
||||
if (!firstMatch || (firstMatch?.score && firstMatch.score > 0.5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstMatch;
|
||||
}
|
||||
|
||||
export async function getLyricsBySongId(songId: string): Promise<string | null> {
|
||||
let result: AxiosResponse<any, any>;
|
||||
try {
|
||||
result = await axios.get(LYRICS_URL, {
|
||||
params: {
|
||||
id: songId,
|
||||
kv: '-1',
|
||||
lv: '-1',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('NetEase lyrics request got an error!', e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.data.klyric?.lyric || result.data.lrc?.lyric;
|
||||
}
|
||||
|
||||
export async function query(
|
||||
params: LyricSearchQuery,
|
||||
): Promise<InternetProviderLyricResponse | null> {
|
||||
const lyricsMatch = await getMatchedLyrics(params);
|
||||
if (!lyricsMatch) {
|
||||
console.error('Could not find the song on NetEase!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyricsBySongId(lyricsMatch.id);
|
||||
if (!lyrics) {
|
||||
console.error('Could not get lyrics on NetEase!');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
artist: lyricsMatch.artist,
|
||||
id: lyricsMatch.id,
|
||||
lyrics,
|
||||
name: lyricsMatch.name,
|
||||
source: LyricSource.NETEASE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import {
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricSearchQuery,
|
||||
} from '../../../../renderer/api/types';
|
||||
|
||||
export const orderSearchResults = (args: {
|
||||
params: LyricSearchQuery;
|
||||
results: InternetProviderLyricSearchResponse[];
|
||||
}) => {
|
||||
const { params, results } = args;
|
||||
|
||||
const options: Fuse.IFuseOptions<InternetProviderLyricSearchResponse> = {
|
||||
fieldNormWeight: 1,
|
||||
includeScore: true,
|
||||
keys: [
|
||||
{ getFn: (song) => song.name, name: 'name', weight: 3 },
|
||||
{ getFn: (song) => song.artist, name: 'artist' },
|
||||
],
|
||||
threshold: 1.0,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(results, options);
|
||||
|
||||
const searchResults = fuse.search<InternetProviderLyricSearchResponse>({
|
||||
...(params.artist && { artist: params.artist }),
|
||||
...(params.name && { name: params.name }),
|
||||
});
|
||||
|
||||
return searchResults.map((result) => ({
|
||||
...result.item,
|
||||
score: result.score,
|
||||
}));
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import console from 'console';
|
||||
import { ipcMain } from 'electron';
|
||||
import { getMpvInstance } from '../../../main';
|
||||
import { getMainWindow, getMpvInstance } from '../../../main';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
declare module 'node-mpv';
|
||||
@@ -12,87 +13,167 @@ function wait(timeout: number) {
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle('player-is-running', async () => {
|
||||
return getMpvInstance()?.isRunning();
|
||||
});
|
||||
|
||||
ipcMain.handle('player-clean-up', async () => {
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.clearPlaylist();
|
||||
});
|
||||
|
||||
ipcMain.on('player-start', async () => {
|
||||
await getMpvInstance()?.play();
|
||||
await getMpvInstance()
|
||||
?.play()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to play', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Starts the player
|
||||
ipcMain.on('player-play', async () => {
|
||||
await getMpvInstance()?.play();
|
||||
await getMpvInstance()
|
||||
?.play()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to play', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Pauses the player
|
||||
ipcMain.on('player-pause', async () => {
|
||||
await getMpvInstance()?.pause();
|
||||
await getMpvInstance()
|
||||
?.pause()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to pause', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Stops the player
|
||||
ipcMain.on('player-stop', async () => {
|
||||
await getMpvInstance()?.stop();
|
||||
await getMpvInstance()
|
||||
?.stop()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to stop', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Goes to the next track in the playlist
|
||||
ipcMain.on('player-next', async () => {
|
||||
await getMpvInstance()?.next();
|
||||
await getMpvInstance()
|
||||
?.next()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to go to next', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Goes to the previous track in the playlist
|
||||
ipcMain.on('player-previous', async () => {
|
||||
await getMpvInstance()?.prev();
|
||||
await getMpvInstance()
|
||||
?.prev()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to go to previous', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Seeks forward or backward by the given amount of seconds
|
||||
ipcMain.on('player-seek', async (_event, time: number) => {
|
||||
await getMpvInstance()?.seek(time);
|
||||
await getMpvInstance()
|
||||
?.seek(time)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to seek', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Seeks to the given time in seconds
|
||||
ipcMain.on('player-seek-to', async (_event, time: number) => {
|
||||
await getMpvInstance()?.goToPosition(time);
|
||||
await getMpvInstance()
|
||||
?.goToPosition(time)
|
||||
.catch((err) => {
|
||||
console.log(`MPV failed to seek to ${time}`, err);
|
||||
});
|
||||
});
|
||||
|
||||
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
||||
ipcMain.on('player-set-queue', async (_event, data: PlayerData) => {
|
||||
ipcMain.on('player-set-queue', async (_event, data: PlayerData, pause?: boolean) => {
|
||||
if (!data.queue.current && !data.queue.next) {
|
||||
await getMpvInstance()?.clearPlaylist();
|
||||
await getMpvInstance()?.pause();
|
||||
await getMpvInstance()
|
||||
?.clearPlaylist()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to clear playlist', err);
|
||||
});
|
||||
await getMpvInstance()
|
||||
?.pause()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to pause', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let complete = false;
|
||||
let tryAttempts = 0;
|
||||
|
||||
while (!complete) {
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
|
||||
}
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
}
|
||||
|
||||
if (tryAttempts > 3) {
|
||||
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
|
||||
complete = true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
await wait(500);
|
||||
} else {
|
||||
try {
|
||||
if (data.queue.current) {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.current.streamUrl, 'replace')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load song', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.next.streamUrl, 'append')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load next song', err);
|
||||
});
|
||||
}
|
||||
|
||||
complete = true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
tryAttempts += 1;
|
||||
await wait(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pause) {
|
||||
await getMpvInstance()?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
// Replaces the queue in position 1 to the given data
|
||||
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
||||
const size = await getMpvInstance()?.getPlaylistSize();
|
||||
const size = await getMpvInstance()
|
||||
?.getPlaylistSize()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to get playlist size', err);
|
||||
});
|
||||
|
||||
if (!size) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (size > 1) {
|
||||
await getMpvInstance()?.playlistRemove(1);
|
||||
await getMpvInstance()
|
||||
?.playlistRemove(1)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to remove song from playlist', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.next.streamUrl, 'append')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load next song', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -101,23 +182,39 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
||||
// Always keep the current song as position 0 in the mpv queue
|
||||
// This allows us to easily set update the next song in the queue without
|
||||
// disturbing the currently playing song
|
||||
await getMpvInstance()?.playlistRemove(0);
|
||||
await getMpvInstance()
|
||||
?.playlistRemove(0)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to remove song from playlist', err);
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||
await getMpvInstance()
|
||||
?.load(data.queue.next.streamUrl, 'append')
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to load next song', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sets the volume to the given value (0-100)
|
||||
ipcMain.on('player-volume', async (_event, value: number) => {
|
||||
await getMpvInstance()?.volume(value);
|
||||
await getMpvInstance()
|
||||
?.volume(value)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to set volume', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Toggles the mute status
|
||||
ipcMain.on('player-mute', async () => {
|
||||
await getMpvInstance()?.mute();
|
||||
await getMpvInstance()
|
||||
?.mute()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to toggle mute', err);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('player-quit', async () => {
|
||||
await getMpvInstance()?.stop();
|
||||
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||
return getMpvInstance()?.getTimePosition();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { ipcMain, safeStorage } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
export const store = new Store();
|
||||
@@ -10,3 +10,41 @@ ipcMain.handle('settings-get', (_event, data: { property: string }) => {
|
||||
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
|
||||
store.set(`${data.property}`, data.value);
|
||||
});
|
||||
|
||||
ipcMain.handle('password-get', (_event, server: string): string | null => {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const servers = store.get('server') as Record<string, string> | undefined;
|
||||
|
||||
if (!servers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encrypted = servers[server];
|
||||
if (!encrypted) return null;
|
||||
|
||||
const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
ipcMain.on('password-remove', (_event, server: string) => {
|
||||
const passwords = store.get('server', {}) as Record<string, string>;
|
||||
if (server in passwords) {
|
||||
delete passwords[server];
|
||||
}
|
||||
store.set({ server: passwords });
|
||||
});
|
||||
|
||||
ipcMain.handle('password-set', (_event, password: string, server: string) => {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const encrypted = safeStorage.encryptString(password);
|
||||
const passwords = store.get('server', {}) as Record<string, string>;
|
||||
passwords[server] = encrypted.toString('hex');
|
||||
store.set({ server: passwords });
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ ipcMain.on(
|
||||
}
|
||||
|
||||
if (shuffle) {
|
||||
mprisPlayer.shuffle = shuffle;
|
||||
mprisPlayer.shuffle = shuffle !== 'none';
|
||||
}
|
||||
|
||||
if (!song) return;
|
||||
|
||||
+240
-26
@@ -8,7 +8,9 @@
|
||||
* When running `npm run build` or `npm run build:main`, this file is compiled to
|
||||
* `./src/main.js` using webpack. This gives us some performance wins.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { access, constants, readFile, writeFile } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import { deflate, inflate } from 'zlib';
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
Tray,
|
||||
Menu,
|
||||
nativeImage,
|
||||
BrowserWindowConstructorOptions,
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log';
|
||||
@@ -27,7 +30,7 @@ import MpvAPI from 'node-mpv';
|
||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||
import { store } from './features/core/settings/index';
|
||||
import MenuBuilder from './menu';
|
||||
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
||||
import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
||||
import './features';
|
||||
|
||||
declare module 'node-mpv';
|
||||
@@ -97,7 +100,6 @@ export const getMainWindow = () => {
|
||||
|
||||
const createWinThumbarButtons = () => {
|
||||
if (isWindows()) {
|
||||
console.log('setting buttons');
|
||||
getMainWindow()?.setThumbarButtons([
|
||||
{
|
||||
click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
|
||||
@@ -182,14 +184,36 @@ const createWindow = async () => {
|
||||
await installExtensions();
|
||||
}
|
||||
|
||||
const nativeFrame = store.get('window_window_bar_style') === 'linux';
|
||||
store.set('window_has_frame', nativeFrame);
|
||||
|
||||
const nativeFrameConfig: Record<string, BrowserWindowConstructorOptions> = {
|
||||
linux: {
|
||||
autoHideMenuBar: true,
|
||||
frame: true,
|
||||
},
|
||||
macOS: {
|
||||
autoHideMenuBar: true,
|
||||
frame: false,
|
||||
titleBarStyle: 'hidden',
|
||||
trafficLightPosition: { x: 10, y: 10 },
|
||||
},
|
||||
windows: {
|
||||
autoHideMenuBar: true,
|
||||
frame: true,
|
||||
},
|
||||
};
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
autoHideMenuBar: true,
|
||||
frame: false,
|
||||
height: 900,
|
||||
icon: getAssetPath('icon.png'),
|
||||
minHeight: 600,
|
||||
minWidth: 640,
|
||||
minHeight: 640,
|
||||
minWidth: 480,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
allowRunningInsecureContent: !!store.get('ignore_ssl'),
|
||||
backgroundThrottling: false,
|
||||
contextIsolation: true,
|
||||
devTools: true,
|
||||
@@ -197,9 +221,12 @@ const createWindow = async () => {
|
||||
preload: app.isPackaged
|
||||
? path.join(__dirname, 'preload.js')
|
||||
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
||||
webSecurity: store.get('ignore_cors') ? false : undefined,
|
||||
webSecurity: !store.get('ignore_cors'),
|
||||
},
|
||||
width: 1440,
|
||||
...(nativeFrame && isLinux() && nativeFrameConfig.linux),
|
||||
...(nativeFrame && isMacOS() && nativeFrameConfig.macOS),
|
||||
...(nativeFrame && isWindows() && nativeFrameConfig.windows),
|
||||
});
|
||||
|
||||
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
|
||||
@@ -227,8 +254,18 @@ const createWindow = async () => {
|
||||
});
|
||||
|
||||
ipcMain.on('app-restart', () => {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
// Fix for .AppImage
|
||||
if (process.env.APPIMAGE) {
|
||||
app.exit();
|
||||
app.relaunch({
|
||||
args: process.argv.slice(1).concat(['--appimage-extract-and-run']),
|
||||
execPath: process.env.APPIMAGE,
|
||||
});
|
||||
app.exit(0);
|
||||
} else {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('global-media-keys-enable', () => {
|
||||
@@ -239,9 +276,39 @@ const createWindow = async () => {
|
||||
disableMediaKeys();
|
||||
});
|
||||
|
||||
ipcMain.on('player-restore-queue', () => {
|
||||
if (store.get('resume')) {
|
||||
const queueLocation = join(app.getPath('userData'), 'queue');
|
||||
|
||||
access(queueLocation, constants.F_OK, (accessError) => {
|
||||
if (accessError) {
|
||||
console.error('unable to access saved queue: ', accessError);
|
||||
return;
|
||||
}
|
||||
|
||||
readFile(queueLocation, (readError, buffer) => {
|
||||
if (readError) {
|
||||
console.error('failed to read saved queue: ', readError);
|
||||
return;
|
||||
}
|
||||
|
||||
inflate(buffer, (decompressError, data) => {
|
||||
if (decompressError) {
|
||||
console.error('failed to decompress queue: ', decompressError);
|
||||
return;
|
||||
}
|
||||
|
||||
const queue = JSON.parse(data.toString());
|
||||
getMainWindow()?.webContents.send('renderer-player-restore-queue', queue);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
||||
|
||||
if (globalMediaKeysEnabled) {
|
||||
if (globalMediaKeysEnabled !== false) {
|
||||
enableMediaKeys(mainWindow);
|
||||
}
|
||||
|
||||
@@ -263,6 +330,8 @@ const createWindow = async () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
let saved = false;
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
||||
if (isMacOS() && !forceQuit) {
|
||||
@@ -271,6 +340,43 @@ const createWindow = async () => {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
|
||||
if (!saved && store.get('resume')) {
|
||||
event.preventDefault();
|
||||
saved = true;
|
||||
|
||||
getMainWindow()?.webContents.send('renderer-player-save-queue');
|
||||
|
||||
ipcMain.once('player-save-queue', async (_event, data: Record<string, any>) => {
|
||||
const queueLocation = join(app.getPath('userData'), 'queue');
|
||||
const serialized = JSON.stringify(data);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
deflate(serialized, { level: 1 }, (error, deflated) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
writeFile(queueLocation, deflated, (writeError) => {
|
||||
if (writeError) {
|
||||
reject(writeError);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('error saving queue state: ', error);
|
||||
} finally {
|
||||
mainWindow?.close();
|
||||
if (forceQuit) {
|
||||
app.exit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('minimize', (event: any) => {
|
||||
@@ -308,7 +414,6 @@ const createWindow = async () => {
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||
|
||||
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
|
||||
|
||||
const prefetchPlaylistParams = [
|
||||
'--prefetch-playlist=no',
|
||||
@@ -316,10 +421,10 @@ const prefetchPlaylistParams = [
|
||||
'--prefetch-playlist',
|
||||
];
|
||||
|
||||
const DEFAULT_MPV_PARAMETERS = () => {
|
||||
const parameters = [];
|
||||
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
|
||||
const parameters = ['--idle=yes'];
|
||||
|
||||
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
}
|
||||
|
||||
@@ -331,26 +436,33 @@ let mpvInstance: MpvAPI | null = null;
|
||||
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
const { extraParameters, properties } = data;
|
||||
|
||||
mpvInstance = new MpvAPI(
|
||||
const params = uniq([...DEFAULT_MPV_PARAMETERS(extraParameters), ...(extraParameters || [])]);
|
||||
console.log('Setting mpv params: ', params);
|
||||
|
||||
const mpv = new MpvAPI(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: false,
|
||||
binary: MPV_BINARY_PATH || '',
|
||||
time_update: 1,
|
||||
},
|
||||
MPV_PARAMETERS || extraParameters
|
||||
? uniq([...DEFAULT_MPV_PARAMETERS(), ...(MPV_PARAMETERS || []), ...(extraParameters || [])])
|
||||
: DEFAULT_MPV_PARAMETERS(),
|
||||
params,
|
||||
);
|
||||
|
||||
mpvInstance.setMultipleProperties(properties || {});
|
||||
console.log('Setting MPV properties: ', properties);
|
||||
mpv.setMultipleProperties(properties || {});
|
||||
|
||||
mpvInstance.start().catch((error) => {
|
||||
console.log('error starting mpv', error);
|
||||
mpv.start().catch((error) => {
|
||||
console.log('MPV failed to start', error);
|
||||
});
|
||||
|
||||
mpvInstance.on('status', (status) => {
|
||||
mpv.on('status', (status, ...rest) => {
|
||||
console.log('MPV Event: status', status.property, status.value, rest);
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value === -1) {
|
||||
mpv?.stop();
|
||||
}
|
||||
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||
}
|
||||
@@ -358,24 +470,33 @@ const createMpv = (data: { extraParameters?: string[]; properties?: Record<strin
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpvInstance.on('resumed', () => {
|
||||
mpv.on('resumed', () => {
|
||||
console.log('MPV Event: resumed');
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is stopped
|
||||
mpvInstance.on('stopped', () => {
|
||||
mpv.on('stopped', () => {
|
||||
console.log('MPV Event: stopped');
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is paused
|
||||
mpvInstance.on('paused', () => {
|
||||
mpv.on('paused', () => {
|
||||
console.log('MPV Event: paused');
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
});
|
||||
|
||||
// Event output every interval set by time_update, used to update the current time
|
||||
mpvInstance.on('timeposition', (time: number) => {
|
||||
mpv.on('timeposition', (time: number) => {
|
||||
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||
});
|
||||
|
||||
mpv.on('quit', () => {
|
||||
console.log('MPV Event: quit');
|
||||
});
|
||||
|
||||
return mpv;
|
||||
};
|
||||
|
||||
export const getMpvInstance = () => {
|
||||
@@ -398,12 +519,105 @@ ipcMain.on(
|
||||
'player-restart',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
mpvInstance?.quit();
|
||||
createMpv(data);
|
||||
mpvInstance = createMpv(data);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
'player-initialize',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
console.log('Initializing MPV with data: ', data);
|
||||
mpvInstance = createMpv(data);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.on('player-quit', async () => {
|
||||
mpvInstance?.stop();
|
||||
mpvInstance?.quit();
|
||||
mpvInstance = null;
|
||||
});
|
||||
|
||||
// Must duplicate with the one in renderer process settings.store.ts
|
||||
enum BindingActions {
|
||||
GLOBAL_SEARCH = 'globalSearch',
|
||||
LOCAL_SEARCH = 'localSearch',
|
||||
MUTE = 'volumeMute',
|
||||
NEXT = 'next',
|
||||
PAUSE = 'pause',
|
||||
PLAY = 'play',
|
||||
PLAY_PAUSE = 'playPause',
|
||||
PREVIOUS = 'previous',
|
||||
SHUFFLE = 'toggleShuffle',
|
||||
SKIP_BACKWARD = 'skipBackward',
|
||||
SKIP_FORWARD = 'skipForward',
|
||||
STOP = 'stop',
|
||||
TOGGLE_FULLSCREEN_PLAYER = 'toggleFullscreenPlayer',
|
||||
TOGGLE_QUEUE = 'toggleQueue',
|
||||
TOGGLE_REPEAT = 'toggleRepeat',
|
||||
VOLUME_DOWN = 'volumeDown',
|
||||
VOLUME_UP = 'volumeUp',
|
||||
}
|
||||
|
||||
const HOTKEY_ACTIONS: Record<BindingActions, () => void> = {
|
||||
[BindingActions.MUTE]: () => getMainWindow()?.webContents.send('renderer-player-volume-mute'),
|
||||
[BindingActions.NEXT]: () => getMainWindow()?.webContents.send('renderer-player-next'),
|
||||
[BindingActions.PAUSE]: () => getMainWindow()?.webContents.send('renderer-player-pause'),
|
||||
[BindingActions.PLAY]: () => getMainWindow()?.webContents.send('renderer-player-play'),
|
||||
[BindingActions.PLAY_PAUSE]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-play-pause'),
|
||||
[BindingActions.PREVIOUS]: () => getMainWindow()?.webContents.send('renderer-player-previous'),
|
||||
[BindingActions.SHUFFLE]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-toggle-shuffle'),
|
||||
[BindingActions.SKIP_BACKWARD]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-skip-backward'),
|
||||
[BindingActions.SKIP_FORWARD]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-skip-forward'),
|
||||
[BindingActions.STOP]: () => getMainWindow()?.webContents.send('renderer-player-stop'),
|
||||
[BindingActions.TOGGLE_REPEAT]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-toggle-repeat'),
|
||||
[BindingActions.VOLUME_UP]: () => getMainWindow()?.webContents.send('renderer-player-volume-up'),
|
||||
[BindingActions.VOLUME_DOWN]: () =>
|
||||
getMainWindow()?.webContents.send('renderer-player-volume-down'),
|
||||
[BindingActions.GLOBAL_SEARCH]: () => {},
|
||||
[BindingActions.LOCAL_SEARCH]: () => {},
|
||||
[BindingActions.TOGGLE_QUEUE]: () => {},
|
||||
[BindingActions.TOGGLE_FULLSCREEN_PLAYER]: () => {},
|
||||
};
|
||||
|
||||
ipcMain.on(
|
||||
'set-global-shortcuts',
|
||||
(
|
||||
_event,
|
||||
data: Record<BindingActions, { allowGlobal: boolean; hotkey: string; isGlobal: boolean }>,
|
||||
) => {
|
||||
// Since we're not tracking the previous shortcuts, we need to unregister all of them
|
||||
globalShortcut.unregisterAll();
|
||||
|
||||
for (const shortcut of Object.keys(data)) {
|
||||
const isGlobalHotkey = data[shortcut as BindingActions].isGlobal;
|
||||
const isValidHotkey =
|
||||
data[shortcut as BindingActions].hotkey && data[shortcut as BindingActions].hotkey !== '';
|
||||
|
||||
if (isGlobalHotkey && isValidHotkey) {
|
||||
const accelerator = hotkeyToElectronAccelerator(data[shortcut as BindingActions].hotkey);
|
||||
|
||||
globalShortcut.register(accelerator, () => {
|
||||
HOTKEY_ACTIONS[shortcut as BindingActions]();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const globalMediaKeysEnabled = store.get('global_media_hotkeys') as boolean;
|
||||
|
||||
if (globalMediaKeysEnabled) {
|
||||
enableMediaKeys(mainWindow);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.on('before-quit', () => {
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { contextBridge } from 'electron';
|
||||
import { browser } from './preload/browser';
|
||||
import { ipc } from './preload/ipc';
|
||||
import { localSettings } from './preload/local-settings';
|
||||
import { lyrics } from './preload/lyrics';
|
||||
import { mpris } from './preload/mpris';
|
||||
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
||||
import { utils } from './preload/utils';
|
||||
@@ -10,6 +11,7 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
browser,
|
||||
ipc,
|
||||
localSettings,
|
||||
lyrics,
|
||||
mpris,
|
||||
mpvPlayer,
|
||||
mpvPlayerListener,
|
||||
|
||||
@@ -4,6 +4,11 @@ const removeAllListeners = (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
};
|
||||
|
||||
const send = (channel: string, ...args: any[]) => {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
};
|
||||
|
||||
export const ipc = {
|
||||
removeAllListeners,
|
||||
send,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { ipcRenderer, webFrame } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
|
||||
const store = new Store();
|
||||
@@ -23,10 +23,30 @@ const disableMediaKeys = () => {
|
||||
ipcRenderer.send('global-media-keys-disable');
|
||||
};
|
||||
|
||||
const passwordGet = async (server: string): Promise<string | null> => {
|
||||
return ipcRenderer.invoke('password-get', server);
|
||||
};
|
||||
|
||||
const passwordRemove = (server: string) => {
|
||||
ipcRenderer.send('password-remove', server);
|
||||
};
|
||||
|
||||
const passwordSet = async (password: string, server: string): Promise<boolean> => {
|
||||
return ipcRenderer.invoke('password-set', password, server);
|
||||
};
|
||||
|
||||
const setZoomFactor = (zoomFactor: number) => {
|
||||
webFrame.setZoomFactor(zoomFactor / 100);
|
||||
};
|
||||
|
||||
export const localSettings = {
|
||||
disableMediaKeys,
|
||||
enableMediaKeys,
|
||||
get,
|
||||
passwordGet,
|
||||
passwordRemove,
|
||||
passwordSet,
|
||||
restart,
|
||||
set,
|
||||
setZoomFactor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { LyricSearchQuery, QueueSong } from '/@/renderer/api/types';
|
||||
|
||||
const getRemoteLyricsBySong = (song: QueueSong) => {
|
||||
const result = ipcRenderer.invoke('lyric-by-song', song);
|
||||
return result;
|
||||
};
|
||||
|
||||
const searchRemoteLyrics = (params: LyricSearchQuery) => {
|
||||
const result = ipcRenderer.invoke('lyric-search', params);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getRemoteLyricsByRemoteId = (id: string) => {
|
||||
const result = ipcRenderer.invoke('lyric-by-remote-id', id);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const lyrics = {
|
||||
getRemoteLyricsByRemoteId,
|
||||
getRemoteLyricsBySong,
|
||||
searchRemoteLyrics,
|
||||
};
|
||||
@@ -1,10 +1,22 @@
|
||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
const initialize = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
ipcRenderer.send('player-initialize', data);
|
||||
};
|
||||
|
||||
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
ipcRenderer.send('player-restart', data);
|
||||
};
|
||||
|
||||
const isRunning = () => {
|
||||
return ipcRenderer.invoke('player-is-running');
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
return ipcRenderer.invoke('player-clean-up');
|
||||
};
|
||||
|
||||
const setProperties = (data: Record<string, any>) => {
|
||||
console.log('Setting property :>>', data);
|
||||
ipcRenderer.send('player-set-properties', data);
|
||||
@@ -38,6 +50,14 @@ const previous = () => {
|
||||
ipcRenderer.send('player-previous');
|
||||
};
|
||||
|
||||
const restoreQueue = () => {
|
||||
ipcRenderer.send('player-restore-queue');
|
||||
};
|
||||
|
||||
const saveQueue = (data: Record<string, any>) => {
|
||||
ipcRenderer.send('player-save-queue', data);
|
||||
};
|
||||
|
||||
const seek = (seconds: number) => {
|
||||
ipcRenderer.send('player-seek', seconds);
|
||||
};
|
||||
@@ -46,8 +66,8 @@ const seekTo = (seconds: number) => {
|
||||
ipcRenderer.send('player-seek-to', seconds);
|
||||
};
|
||||
|
||||
const setQueue = (data: PlayerData) => {
|
||||
ipcRenderer.send('player-set-queue', data);
|
||||
const setQueue = (data: PlayerData, pause?: boolean) => {
|
||||
ipcRenderer.send('player-set-queue', data, pause);
|
||||
};
|
||||
|
||||
const setQueueNext = (data: PlayerData) => {
|
||||
@@ -66,6 +86,10 @@ const quit = () => {
|
||||
ipcRenderer.send('player-quit');
|
||||
};
|
||||
|
||||
const getCurrentTime = async () => {
|
||||
return ipcRenderer.invoke('player-get-time');
|
||||
};
|
||||
|
||||
const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-auto-next', cb);
|
||||
};
|
||||
@@ -98,13 +122,57 @@ const rendererStop = (cb: (event: IpcRendererEvent, data: PlayerData) => void) =
|
||||
ipcRenderer.on('renderer-player-stop', cb);
|
||||
};
|
||||
|
||||
const rendererSkipForward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-skip-forward', cb);
|
||||
};
|
||||
|
||||
const rendererSkipBackward = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-skip-backward', cb);
|
||||
};
|
||||
|
||||
const rendererVolumeUp = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-up', cb);
|
||||
};
|
||||
|
||||
const rendererVolumeDown = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-down', cb);
|
||||
};
|
||||
|
||||
const rendererVolumeMute = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-volume-mute', cb);
|
||||
};
|
||||
|
||||
const rendererToggleRepeat = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-toggle-repeat', cb);
|
||||
};
|
||||
|
||||
const rendererToggleShuffle = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => {
|
||||
ipcRenderer.on('renderer-player-toggle-shuffle', cb);
|
||||
};
|
||||
|
||||
const rendererQuit = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-player-quit', cb);
|
||||
};
|
||||
|
||||
const rendererSaveQueue = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-player-save-queue', cb);
|
||||
};
|
||||
|
||||
const rendererRestoreQueue = (cb: (event: IpcRendererEvent) => void) => {
|
||||
ipcRenderer.on('renderer-player-restore-queue', cb);
|
||||
};
|
||||
|
||||
const rendererError = (cb: (event: IpcRendererEvent, data: string) => void) => {
|
||||
ipcRenderer.on('renderer-player-error', cb);
|
||||
};
|
||||
|
||||
export const mpvPlayer = {
|
||||
autoNext,
|
||||
cleanup,
|
||||
currentTime,
|
||||
getCurrentTime,
|
||||
initialize,
|
||||
isRunning,
|
||||
mute,
|
||||
next,
|
||||
pause,
|
||||
@@ -112,6 +180,8 @@ export const mpvPlayer = {
|
||||
previous,
|
||||
quit,
|
||||
restart,
|
||||
restoreQueue,
|
||||
saveQueue,
|
||||
seek,
|
||||
seekTo,
|
||||
setProperties,
|
||||
@@ -124,11 +194,21 @@ export const mpvPlayer = {
|
||||
export const mpvPlayerListener = {
|
||||
rendererAutoNext,
|
||||
rendererCurrentTime,
|
||||
rendererError,
|
||||
rendererNext,
|
||||
rendererPause,
|
||||
rendererPlay,
|
||||
rendererPlayPause,
|
||||
rendererPrevious,
|
||||
rendererQuit,
|
||||
rendererRestoreQueue,
|
||||
rendererSaveQueue,
|
||||
rendererSkipBackward,
|
||||
rendererSkipForward,
|
||||
rendererStop,
|
||||
rendererToggleRepeat,
|
||||
rendererToggleShuffle,
|
||||
rendererVolumeDown,
|
||||
rendererVolumeMute,
|
||||
rendererVolumeUp,
|
||||
};
|
||||
|
||||
@@ -29,3 +29,24 @@ export const isWindows = () => {
|
||||
export const isLinux = () => {
|
||||
return process.platform === 'linux';
|
||||
};
|
||||
|
||||
export const hotkeyToElectronAccelerator = (hotkey: string) => {
|
||||
let accelerator = hotkey;
|
||||
|
||||
const replacements = {
|
||||
mod: 'CmdOrCtrl',
|
||||
numpad: 'num',
|
||||
numpadadd: 'numadd',
|
||||
numpaddecimal: 'numdec',
|
||||
numpaddivide: 'numdiv',
|
||||
numpadenter: 'numenter',
|
||||
numpadmultiply: 'nummult',
|
||||
numpadsubtract: 'numsub',
|
||||
};
|
||||
|
||||
Object.keys(replacements).forEach((key) => {
|
||||
accelerator = accelerator.replace(key, replacements[key as keyof typeof replacements]);
|
||||
});
|
||||
|
||||
return accelerator;
|
||||
};
|
||||
|
||||
+313
-146
@@ -1,86 +1,98 @@
|
||||
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,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
} 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, RandomSongListArgs } 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>;
|
||||
getLyrics: (args: LyricsArgs) => Promise<LyricsResponse>;
|
||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => 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>;
|
||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
}>;
|
||||
|
||||
type ApiController = {
|
||||
@@ -91,48 +103,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 +121,70 @@ 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,
|
||||
getLyrics: jfController.getLyrics,
|
||||
getMusicFolderList: jfController.getMusicFolderList,
|
||||
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||
getPlaylistList: jfController.getPlaylistList,
|
||||
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||
getRandomSongList: jfController.getRandomSongList,
|
||||
getSongDetail: undefined,
|
||||
getSongList: jfController.getSongList,
|
||||
getTopSongs: jfController.getTopSongList,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||
scrobble: jfController.scrobble,
|
||||
search: jfController.search,
|
||||
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,
|
||||
getLyrics: undefined,
|
||||
getMusicFolderList: ssController.getMusicFolderList,
|
||||
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||
getPlaylistList: ndController.getPlaylistList,
|
||||
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||
getRandomSongList: ssController.getRandomSongList,
|
||||
getSongDetail: ndController.getSongDetail,
|
||||
getSongList: ndController.getSongList,
|
||||
getTopSongs: ssController.getTopSongList,
|
||||
getUserList: ndController.getUserList,
|
||||
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||
scrobble: ssController.scrobble,
|
||||
search: ssController.search3,
|
||||
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,134 +193,276 @@ const endpoints: ApiController = {
|
||||
getFolderList: undefined,
|
||||
getFolderSongs: undefined,
|
||||
getGenreList: undefined,
|
||||
getMusicFolderList: subsonicApi.getMusicFolderList,
|
||||
getLyrics: undefined,
|
||||
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,
|
||||
search: ssController.search3,
|
||||
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' });
|
||||
return () => undefined;
|
||||
throw new Error(`No server selected`);
|
||||
}
|
||||
|
||||
const controllerFn = endpoints[serverType][endpoint];
|
||||
const controllerFn = endpoints?.[serverType]?.[endpoint];
|
||||
|
||||
if (typeof controllerFn !== 'function') {
|
||||
toast.error({
|
||||
message: `Endpoint ${endpoint} is not implemented for ${serverType}`,
|
||||
title: 'Unable to route request',
|
||||
});
|
||||
return () => undefined;
|
||||
|
||||
throw new Error(`Endpoint ${endpoint} is not implemented for ${serverType}`);
|
||||
}
|
||||
|
||||
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 getSongDetail = async (args: SongDetailArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getSongDetail',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getSongDetail']
|
||||
)?.(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);
|
||||
};
|
||||
|
||||
const search = async (args: SearchArgs) => {
|
||||
return (
|
||||
apiController('search', args.apiClientProps.server?.type) as ControllerEndpoint['search']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getRandomSongList = async (args: RandomSongListArgs) => {
|
||||
return (
|
||||
apiController(
|
||||
'getRandomSongList',
|
||||
args.apiClientProps.server?.type,
|
||||
) as ControllerEndpoint['getRandomSongList']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
const getLyrics = async (args: LyricsArgs) => {
|
||||
return (
|
||||
apiController('getLyrics', args.apiClientProps.server?.type) as ControllerEndpoint['getLyrics']
|
||||
)?.(args);
|
||||
};
|
||||
|
||||
export const controller = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
@@ -310,15 +473,19 @@ export const controller = {
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getLyrics,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
updatePlaylist,
|
||||
updateRating,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { normalize } from '/@/renderer/api/normalize';
|
||||
|
||||
export const api = {
|
||||
controller,
|
||||
normalize,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
||||
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 { ServerListItem } from '/@/renderer/types';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import { z } from 'zod';
|
||||
import { authenticationFailure } from '/@/renderer/api/utils';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const contract = c.router({
|
||||
addToPlaylist: {
|
||||
body: z.null(),
|
||||
method: 'POST',
|
||||
path: 'playlists/:id/items',
|
||||
query: jfType._parameters.addToPlaylist,
|
||||
responses: {
|
||||
204: jfType._response.addToPlaylist,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
authenticate: {
|
||||
body: jfType._parameters.authenticate,
|
||||
headers: z.object({
|
||||
'X-Emby-Authorization': z.string(),
|
||||
}),
|
||||
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,
|
||||
},
|
||||
},
|
||||
getSongLyrics: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/Items/:id/Lyrics',
|
||||
responses: {
|
||||
200: jfType._response.lyrics,
|
||||
404: 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: 'playlists/:id/items',
|
||||
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,
|
||||
},
|
||||
},
|
||||
search: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items',
|
||||
query: jfType._parameters.search,
|
||||
responses: {
|
||||
200: jfType._response.search,
|
||||
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) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
authenticationFailure(currentServer);
|
||||
}
|
||||
|
||||
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,
|
||||
headers: result.headers as any,
|
||||
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,
|
||||
headers: response?.headers as any,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,902 @@
|
||||
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,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
RandomSongListResponse,
|
||||
RandomSongListArgs,
|
||||
LyricsArgs,
|
||||
LyricsResponse,
|
||||
} 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';
|
||||
import { z } from 'zod';
|
||||
import { JFSongListSort, JFSortOrder } from '/@/renderer/api/jellyfin.types';
|
||||
|
||||
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: {
|
||||
AlbumArtistIds: query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined,
|
||||
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: null,
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
query: {
|
||||
Ids: body.songId,
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
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 !== 204) {
|
||||
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;
|
||||
};
|
||||
|
||||
const search = async (args: SearchArgs): Promise<SearchResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
let albums: z.infer<typeof jfType._response.albumList>['Items'] = [];
|
||||
let albumArtists: z.infer<typeof jfType._response.albumArtistList>['Items'] = [];
|
||||
let songs: z.infer<typeof jfType._response.songList>['Items'] = [];
|
||||
|
||||
if (query.albumLimit) {
|
||||
const res = await jfApiClient(apiClientProps).getAlbumList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
ImageTypeLimit: 1,
|
||||
IncludeItemTypes: 'MusicAlbum',
|
||||
Limit: query.albumLimit,
|
||||
Recursive: true,
|
||||
SearchTerm: query.query,
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
StartIndex: query.albumStartIndex || 0,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album list');
|
||||
}
|
||||
|
||||
albums = res.body.Items;
|
||||
}
|
||||
|
||||
if (query.albumArtistLimit) {
|
||||
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||
ImageTypeLimit: 1,
|
||||
IncludeArtists: true,
|
||||
Limit: query.albumArtistLimit,
|
||||
Recursive: true,
|
||||
SearchTerm: query.query,
|
||||
StartIndex: query.albumArtistStartIndex || 0,
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get album artist list');
|
||||
}
|
||||
|
||||
albumArtists = res.body.Items;
|
||||
}
|
||||
|
||||
if (query.songLimit) {
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
EnableTotalRecordCount: true,
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.songLimit,
|
||||
Recursive: true,
|
||||
SearchTerm: query.query,
|
||||
SortBy: 'Album,SortName',
|
||||
SortOrder: 'Ascending',
|
||||
StartIndex: query.songStartIndex || 0,
|
||||
UserId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get song list');
|
||||
}
|
||||
|
||||
songs = res.body.Items;
|
||||
}
|
||||
|
||||
return {
|
||||
albumArtists: albumArtists.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||
albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
||||
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
};
|
||||
};
|
||||
|
||||
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const yearsGroup = [];
|
||||
if (query.minYear && query.maxYear) {
|
||||
for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
|
||||
yearsGroup.push(String(i));
|
||||
}
|
||||
}
|
||||
|
||||
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongList({
|
||||
params: {
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
query: {
|
||||
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||
GenreIds: query.genre ? query.genre : undefined,
|
||||
IncludeItemTypes: 'Audio',
|
||||
Limit: query.limit,
|
||||
ParentId: query.musicFolderId,
|
||||
Recursive: true,
|
||||
SortBy: JFSongListSort.RANDOM,
|
||||
SortOrder: JFSortOrder.ASC,
|
||||
StartIndex: 0,
|
||||
Years: yearsFilter,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get random songs');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.Items.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getLyrics = async (args: LyricsArgs): Promise<LyricsResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
if (!apiClientProps.server?.userId) {
|
||||
throw new Error('No userId found');
|
||||
}
|
||||
|
||||
const res = await jfApiClient(apiClientProps).getSongLyrics({
|
||||
params: {
|
||||
id: query.songId,
|
||||
userId: apiClientProps.server?.userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get lyrics');
|
||||
}
|
||||
|
||||
if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {
|
||||
return res.body.Lyrics[0].Text;
|
||||
}
|
||||
|
||||
return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);
|
||||
};
|
||||
|
||||
export const jfController = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createFavorite,
|
||||
createPlaylist,
|
||||
deleteFavorite,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistList,
|
||||
getGenreList,
|
||||
getLyrics,
|
||||
getMusicFolderList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getRandomSongList,
|
||||
getSongList,
|
||||
getTopSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
updatePlaylist,
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
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 (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item.Id}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
|
||||
if (args.item?.AlbumPrimaryImageTag) {
|
||||
// Fall back to album art if no image embedded
|
||||
return (
|
||||
`${args.baseUrl}/Items` +
|
||||
`/${args.item?.AlbumId}` +
|
||||
'/Images/Primary' +
|
||||
`?width=${size}&height=${size}` +
|
||||
'&quality=96'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
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,
|
||||
lyrics: null,
|
||||
name: item.Name,
|
||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||
releaseDate: null,
|
||||
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: item.MediaSources && item.MediaSources[0]?.Size,
|
||||
streamUrl: getStreamUrl({
|
||||
container: item.MediaSources?.[0]?.Container,
|
||||
deviceId,
|
||||
eTag: item.MediaSources?.[0]?.ETag,
|
||||
id: item.Id,
|
||||
mediaSourceId: item.MediaSources?.[0]?.Id,
|
||||
server,
|
||||
}),
|
||||
trackNumber: item.IndexNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.DateCreated,
|
||||
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof jfType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Album => {
|
||||
return {
|
||||
albumArtists:
|
||||
item.AlbumArtists.map((entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: null,
|
||||
name: entry.Name,
|
||||
})) || [],
|
||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.DateCreated,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl: getAlbumCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof jfType._response.albumArtist> & {
|
||||
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
|
||||
},
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): AlbumArtist => {
|
||||
const similarArtists =
|
||||
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
|
||||
(entry) => ({
|
||||
id: entry.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item: entry,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
name: entry.Name,
|
||||
}),
|
||||
) || [];
|
||||
|
||||
return {
|
||||
albumCount: null,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imageUrl: getAlbumArtistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
}),
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.Name,
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
similarArtists,
|
||||
songCount: null,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: z.infer<typeof jfType._response.playlist>,
|
||||
server: ServerListItem | null,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getPlaylistCoverArtUrl({
|
||||
baseUrl: server?.url || '',
|
||||
item,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.Overview || null,
|
||||
duration: item.RunTimeTicks / 10000,
|
||||
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||
id: item.Id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.Name,
|
||||
owner: null,
|
||||
ownerId: null,
|
||||
public: null,
|
||||
rules: null,
|
||||
serverId: server?.id || '',
|
||||
serverType: ServerType.JELLYFIN,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
|
||||
return {
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeArtist = (item: any) => {
|
||||
// return {
|
||||
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
|
||||
// albumCount: item.AlbumCount,
|
||||
// duration: item.RunTimeTicks / 10000000,
|
||||
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
|
||||
// id: item.Id,
|
||||
// image: getCoverArtUrl(item),
|
||||
// info: {
|
||||
// biography: item.Overview,
|
||||
// externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)),
|
||||
// imageUrl: undefined,
|
||||
// similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)),
|
||||
// },
|
||||
// starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined,
|
||||
// title: item.Name,
|
||||
// uniqueId: nanoid(),
|
||||
// };
|
||||
// };
|
||||
|
||||
const normalizeGenre = (item: JFGenre): Genre => {
|
||||
return {
|
||||
albumCount: undefined,
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
songCount: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// const normalizeFolder = (item: any) => {
|
||||
// return {
|
||||
// created: item.DateCreated,
|
||||
// id: item.Id,
|
||||
// image: getCoverArtUrl(item, 150),
|
||||
// isDir: true,
|
||||
// title: item.Name,
|
||||
// type: Item.Folder,
|
||||
// uniqueId: nanoid(),
|
||||
// };
|
||||
// };
|
||||
|
||||
// const normalizeScanStatus = () => {
|
||||
// return {
|
||||
// count: 'N/a',
|
||||
// scanning: false,
|
||||
// };
|
||||
// };
|
||||
|
||||
export const jfNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
genre: normalizeGenre,
|
||||
musicFolder: normalizeMusicFolder,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
@@ -0,0 +1,696 @@
|
||||
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(),
|
||||
ContributingArtistIds: z.string().optional(),
|
||||
EnableImageTypes: z.string().optional(),
|
||||
EnableTotalRecordCount: z.boolean().optional(),
|
||||
EnableUserData: z.boolean().optional(),
|
||||
EnableUserDataTypes: z.boolean().optional(),
|
||||
ExcludeArtistIds: z.string().optional(),
|
||||
ExcludeItemIds: z.string().optional(),
|
||||
ExcludeItemTypes: z.string().optional(),
|
||||
Fields: z.string().optional(),
|
||||
ImageTypeLimit: z.number().optional(),
|
||||
IncludeArtists: z.boolean().optional(),
|
||||
IncludeGenres: z.boolean().optional(),
|
||||
IncludeItemTypes: z.string().optional(),
|
||||
IncludeMedia: z.boolean().optional(),
|
||||
IncludePeople: z.boolean().optional(),
|
||||
IncludeStudios: z.boolean().optional(),
|
||||
IsFavorite: z.boolean().optional(),
|
||||
Limit: z.number().optional(),
|
||||
MediaTypes: z.string().optional(),
|
||||
NameStartsWith: 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(),
|
||||
Tags: z.string().optional(),
|
||||
UserId: z.string().optional(),
|
||||
Years: z.string().optional(),
|
||||
});
|
||||
|
||||
const paginationParameters = z.object({
|
||||
Limit: z.number().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.record(z.string(), z.string()).optional(),
|
||||
Logo: z.record(z.string(), z.string()).optional(),
|
||||
Primary: z.record(z.string(), 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 = paginationParameters.merge(
|
||||
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({});
|
||||
|
||||
const searchParameters = paginationParameters.merge(baseParameters);
|
||||
|
||||
const search = z.any();
|
||||
|
||||
const lyricText = z.object({
|
||||
Start: z.number().optional(),
|
||||
Text: z.string(),
|
||||
});
|
||||
|
||||
const lyrics = z.object({
|
||||
Lyrics: z.array(lyricText),
|
||||
});
|
||||
|
||||
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,
|
||||
search: searchParameters,
|
||||
similarArtistList: similarArtistListParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
},
|
||||
_response: {
|
||||
addToPlaylist,
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
error,
|
||||
favorite,
|
||||
genre,
|
||||
genreList,
|
||||
lyrics,
|
||||
musicFolderList,
|
||||
playlist,
|
||||
playlistList,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
song,
|
||||
songList,
|
||||
topSongsList,
|
||||
updatePlaylist,
|
||||
user,
|
||||
},
|
||||
};
|
||||
@@ -1,756 +0,0 @@
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import ky from 'ky';
|
||||
import type {
|
||||
NDGenreListResponse,
|
||||
NDArtistListResponse,
|
||||
NDAlbumDetail,
|
||||
NDAlbumListParams,
|
||||
NDAlbumList,
|
||||
NDSongDetailResponse,
|
||||
NDAlbum,
|
||||
NDSong,
|
||||
NDAuthenticationResponse,
|
||||
NDAlbumDetailResponse,
|
||||
NDSongDetail,
|
||||
NDGenreList,
|
||||
NDAlbumArtistListParams,
|
||||
NDAlbumArtistDetail,
|
||||
NDAlbumListResponse,
|
||||
NDAlbumArtistDetailResponse,
|
||||
NDAlbumArtistList,
|
||||
NDSongListParams,
|
||||
NDCreatePlaylistParams,
|
||||
NDCreatePlaylistResponse,
|
||||
NDDeletePlaylist,
|
||||
NDDeletePlaylistResponse,
|
||||
NDPlaylistListParams,
|
||||
NDPlaylistDetail,
|
||||
NDPlaylistList,
|
||||
NDPlaylistListResponse,
|
||||
NDPlaylistDetailResponse,
|
||||
NDSongList,
|
||||
NDSongListResponse,
|
||||
NDAlbumArtist,
|
||||
NDPlaylist,
|
||||
NDUpdatePlaylistParams,
|
||||
NDUpdatePlaylistResponse,
|
||||
NDPlaylistSongListResponse,
|
||||
NDPlaylistSongList,
|
||||
NDPlaylistSong,
|
||||
NDUserList,
|
||||
NDUserListResponse,
|
||||
NDUserListParams,
|
||||
NDUser,
|
||||
NDAddToPlaylist,
|
||||
NDAddToPlaylistBody,
|
||||
NDAddToPlaylistResponse,
|
||||
NDRemoveFromPlaylistParams,
|
||||
NDRemoveFromPlaylistResponse,
|
||||
NDRemoveFromPlaylist,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
||||
import {
|
||||
Album,
|
||||
Song,
|
||||
AuthenticationResponse,
|
||||
AlbumDetailArgs,
|
||||
GenreListArgs,
|
||||
AlbumListArgs,
|
||||
AlbumArtistListArgs,
|
||||
AlbumArtistDetailArgs,
|
||||
SongListArgs,
|
||||
SongDetailArgs,
|
||||
CreatePlaylistArgs,
|
||||
DeletePlaylistArgs,
|
||||
PlaylistListArgs,
|
||||
PlaylistDetailArgs,
|
||||
CreatePlaylistResponse,
|
||||
PlaylistSongListArgs,
|
||||
AlbumArtist,
|
||||
Playlist,
|
||||
UpdatePlaylistResponse,
|
||||
UpdatePlaylistArgs,
|
||||
UserListArgs,
|
||||
userListSortMap,
|
||||
playlistListSortMap,
|
||||
albumArtistListSortMap,
|
||||
songListSortMap,
|
||||
albumListSortMap,
|
||||
sortOrderMap,
|
||||
User,
|
||||
LibraryItem,
|
||||
AddToPlaylistArgs,
|
||||
RemoveFromPlaylistArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import { parseSearchParams } from '/@/renderer/utils';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
|
||||
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
|
||||
|
||||
const api = ky.create({
|
||||
hooks: {
|
||||
afterResponse: [
|
||||
async (_request, _options, response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: response.headers.get('x-nd-authorization') as string,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
],
|
||||
beforeError: [
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return error;
|
||||
},
|
||||
],
|
||||
},
|
||||
mode: IGNORE_CORS ? 'cors' : undefined,
|
||||
});
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: { password: string; username: string },
|
||||
): Promise<AuthenticationResponse> => {
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const data = await ky
|
||||
.post(`${cleanServerUrl}/auth/login`, {
|
||||
json: {
|
||||
password: body.password,
|
||||
username: body.username,
|
||||
},
|
||||
})
|
||||
.json<NDAuthenticationResponse>();
|
||||
|
||||
return {
|
||||
credential: `u=${body.username}&s=${data.subsonicSalt}&t=${data.subsonicToken}`,
|
||||
ndCredential: data.token,
|
||||
userId: data.id,
|
||||
username: data.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getUserList = async (args: UserListArgs): Promise<NDUserList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDUserListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: userListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/user', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDUserListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<NDGenreList> => {
|
||||
const { server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get('api/genre', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDGenreListResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<NDAlbumArtistDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const artistInfo = await subsonicApi.getArtistInfo({
|
||||
query: {
|
||||
artistId: query.id,
|
||||
limit: 15,
|
||||
},
|
||||
server,
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await api
|
||||
.get(`api/artist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDAlbumArtistDetailResponse>();
|
||||
|
||||
return { ...data, similarArtists: artistInfo.similarArtist };
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<NDAlbumArtistList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDAlbumArtistListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
name: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/artist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDArtistListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<NDAlbumDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get(`api/album/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDAlbumDetailResponse>();
|
||||
|
||||
const songsData = await api
|
||||
.get('api/song', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: {
|
||||
_end: 0,
|
||||
_order: NDSortOrder.ASC,
|
||||
_sort: 'album',
|
||||
_start: 0,
|
||||
album_id: query.id,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
.json<NDSongListResponse>();
|
||||
|
||||
return { ...data, songs: songsData };
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<NDAlbumList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDAlbumListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
artist_id: query.artistIds?.[0],
|
||||
name: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/album', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDAlbumListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongList = async (args: SongListArgs): Promise<NDSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams = {
|
||||
_end: query.startIndex + (query.limit || -1),
|
||||
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||
_sort: songListSortMap.navidrome[query.sortBy],
|
||||
_start: query.startIndex,
|
||||
album_id: query.albumIds,
|
||||
artist_id: query.artistIds,
|
||||
title: query.searchTerm,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/song', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDSongListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getSongDetail = async (args: SongDetailArgs): Promise<NDSongDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get(`api/song/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDSongDetailResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||
const { body, server } = args;
|
||||
|
||||
const json: NDCreatePlaylistParams = {
|
||||
comment: body.comment,
|
||||
name: body.name,
|
||||
...body.ndParams,
|
||||
public: body.ndParams?.public || false,
|
||||
rules: body.ndParams?.rules ? body.ndParams.rules : undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.post('api/playlist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
})
|
||||
.json<NDCreatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
name: body.name,
|
||||
};
|
||||
};
|
||||
|
||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const json: NDUpdatePlaylistParams = {
|
||||
comment: body.comment || '',
|
||||
name: body.name,
|
||||
ownerId: body.ndParams?.ownerId || undefined,
|
||||
ownerName: body.ndParams?.owner || undefined,
|
||||
public: body.ndParams?.public || false,
|
||||
rules: body.ndParams?.rules ? body.ndParams?.rules : undefined,
|
||||
sync: body.ndParams?.sync || undefined,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.put(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDUpdatePlaylistResponse>();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
};
|
||||
};
|
||||
|
||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<NDDeletePlaylist> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.delete(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDDeletePlaylistResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<NDPlaylistList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDPlaylistListParams = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : undefined,
|
||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||
_start: query.startIndex,
|
||||
...query.ndParams,
|
||||
};
|
||||
|
||||
const res = await api.get('api/playlist', {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDPlaylistListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<NDPlaylistDetail> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const data = await api
|
||||
.get(`api/playlist/${query.id}`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDPlaylistDetailResponse>();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<NDPlaylistSongList> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDSongListParams & { playlist_id: string } = {
|
||||
_end: query.startIndex + (query.limit || 0),
|
||||
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : NDSortOrder.ASC,
|
||||
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : NDSongListSort.ID,
|
||||
_start: query.startIndex,
|
||||
playlist_id: query.id,
|
||||
};
|
||||
|
||||
const res = await api.get(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
|
||||
const data = await res.json<NDPlaylistSongListResponse>();
|
||||
const itemCount = res.headers.get('x-total-count');
|
||||
|
||||
return {
|
||||
items: data,
|
||||
startIndex: query?.startIndex || 0,
|
||||
totalRecordCount: Number(itemCount),
|
||||
};
|
||||
};
|
||||
|
||||
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<NDAddToPlaylist> => {
|
||||
const { query, body, server, signal } = args;
|
||||
|
||||
const json: NDAddToPlaylistBody = {
|
||||
ids: body.songId,
|
||||
};
|
||||
|
||||
await api
|
||||
.post(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
json,
|
||||
prefixUrl: server?.url,
|
||||
signal,
|
||||
})
|
||||
.json<NDAddToPlaylistResponse>();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs): Promise<NDRemoveFromPlaylist> => {
|
||||
const { query, server, signal } = args;
|
||||
|
||||
const searchParams: NDRemoveFromPlaylistParams = {
|
||||
id: query.songId,
|
||||
};
|
||||
|
||||
await api
|
||||
.delete(`api/playlist/${query.id}/tracks`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<NDRemoveFromPlaylistResponse>();
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
coverArtId: string;
|
||||
credential: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 250;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeSong = (
|
||||
item: NDSong | NDPlaylistSong,
|
||||
server: ServerListItem,
|
||||
deviceId: string,
|
||||
imageSize?: number,
|
||||
): Song => {
|
||||
let id;
|
||||
let playlistItemId;
|
||||
|
||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||
if ('mediaFileId' in item) {
|
||||
id = item.mediaFileId;
|
||||
playlistItemId = item.id;
|
||||
} else {
|
||||
id = item.id;
|
||||
}
|
||||
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 100,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
bitRate: item.bitRate,
|
||||
bpm: item.bpm ? item.bpm : null,
|
||||
channels: item.channels ? item.channels : null,
|
||||
comment: item.comment ? item.comment : null,
|
||||
compilation: item.compilation,
|
||||
container: item.suffix,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: item.genres,
|
||||
id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item.playCount,
|
||||
playlistItemId,
|
||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||
releaseYear: String(item.year),
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
|
||||
trackNumber: item.trackNumber,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (item: NDAlbum, server: ServerListItem, imageSize?: number): Album => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.coverArtId || item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||
|
||||
return {
|
||||
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||
backdropImageUrl: imageBackdropUrl,
|
||||
createdAt: item.createdAt.split('T')[0],
|
||||
duration: item.duration * 1000 || null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
isCompilation: item.compilation,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||
releaseYear: item.minYear,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.updatedAt,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (item: NDAlbumArtist, server: ServerListItem): AlbumArtist => {
|
||||
const imageUrl =
|
||||
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount,
|
||||
backgroundImageUrl: null,
|
||||
biography: item.biography || null,
|
||||
duration: null,
|
||||
genres: item.genres,
|
||||
id: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||
name: item.name,
|
||||
playCount: item.playCount,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
similarArtists:
|
||||
item.similarArtists?.map((artist) => ({
|
||||
id: artist.id,
|
||||
imageUrl: artist?.artistImageUrl || null,
|
||||
name: artist.name,
|
||||
})) || null,
|
||||
songCount: item.songCount,
|
||||
userFavorite: item.starred,
|
||||
userRating: item.rating,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePlaylist = (
|
||||
item: NDPlaylist,
|
||||
server: ServerListItem,
|
||||
imageSize?: number,
|
||||
): Playlist => {
|
||||
const imageUrl = getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.id,
|
||||
credential: server.credential,
|
||||
size: imageSize || 300,
|
||||
});
|
||||
|
||||
const imagePlaceholderUrl = null;
|
||||
|
||||
return {
|
||||
description: item.comment,
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
name: item.name,
|
||||
owner: item.ownerName,
|
||||
ownerId: item.ownerId,
|
||||
public: item.public,
|
||||
rules: item?.rules || null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.NAVIDROME,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
sync: item.sync,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeUser = (item: NDUser): User => {
|
||||
return {
|
||||
createdAt: item.createdAt,
|
||||
email: item.email,
|
||||
id: item.id,
|
||||
isAdmin: item.isAdmin,
|
||||
lastLoginAt: item.lastLoginAt,
|
||||
name: item.userName,
|
||||
updatedAt: item.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const navidromeApi = {
|
||||
addToPlaylist,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getGenreList,
|
||||
getPlaylistDetail,
|
||||
getPlaylistList,
|
||||
getPlaylistSongList,
|
||||
getSongDetail,
|
||||
getSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
export const ndNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
playlist: normalizePlaylist,
|
||||
song: normalizeSong,
|
||||
user: normalizeUser,
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
||||
import isElectron from 'is-electron';
|
||||
import { debounce } from 'lodash';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import qs from 'qs';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import { toast } from '/@/renderer/components';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
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' });
|
||||
};
|
||||
|
||||
const parsePath = (fullPath: string) => {
|
||||
const [path, params] = fullPath.split('?');
|
||||
|
||||
const parsedParams = qs.parse(params);
|
||||
|
||||
// Convert indexed object to array
|
||||
const newParams: Record<string, any> = {};
|
||||
Object.keys(parsedParams).forEach((key) => {
|
||||
const isIndexedArrayObject =
|
||||
typeof parsedParams[key] === 'object' && Object.keys(parsedParams[key] || {}).includes('0');
|
||||
|
||||
if (!isIndexedArrayObject) {
|
||||
newParams[key] = parsedParams[key];
|
||||
} else {
|
||||
newParams[key] = Object.values(parsedParams[key] || {});
|
||||
}
|
||||
});
|
||||
|
||||
const notNilParams = omitBy(newParams, (value) => value === 'undefined' || value === 'null');
|
||||
|
||||
return {
|
||||
params: notNilParams,
|
||||
path,
|
||||
};
|
||||
};
|
||||
|
||||
let authSuccess = true;
|
||||
let shouldDelay = false;
|
||||
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
const waitForResult = async (count = 0): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (count === MAX_RETRIES || !shouldDelay) resolve();
|
||||
|
||||
setTimeout(() => {
|
||||
waitForResult(count + 1)
|
||||
.then(resolve)
|
||||
.catch(resolve);
|
||||
}, RETRY_DELAY_MS);
|
||||
});
|
||||
};
|
||||
|
||||
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
|
||||
const TIMEOUT_ERROR = Error();
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
|
||||
|
||||
if (headerCredential) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: headerCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
authSuccess = true;
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (localSettings && currentServer?.savePassword) {
|
||||
// eslint-disable-next-line promise/no-promise-in-callback
|
||||
return localSettings
|
||||
.passwordGet(currentServer.id)
|
||||
.then(async (password: string | null) => {
|
||||
authSuccess = false;
|
||||
|
||||
if (password === null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (shouldDelay) {
|
||||
await waitForResult();
|
||||
|
||||
// Hopefully the delay was sufficient for authentication.
|
||||
// Otherwise, it will require manual intervention
|
||||
if (authSuccess) {
|
||||
return axiosClient.request(error.config);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
shouldDelay = true;
|
||||
|
||||
// Do not use axiosClient. Instead, manually make a post
|
||||
const res = await axios.post(`${currentServer.url}/auth/login`, {
|
||||
password,
|
||||
username: currentServer.username,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
toast.error({
|
||||
message:
|
||||
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
|
||||
title: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const serverId = currentServer.id;
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
|
||||
// special error to prevent sending a second message, and stop other messages that could be enqueued
|
||||
limitedFail.cancel();
|
||||
throw TIMEOUT_ERROR;
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
|
||||
const newCredential = res.data.token;
|
||||
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
|
||||
|
||||
useAuthStore.getState().actions.updateServer(currentServer.id, {
|
||||
credential: subsonicCredential,
|
||||
ndCredential: newCredential,
|
||||
});
|
||||
|
||||
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
|
||||
|
||||
authSuccess = true;
|
||||
|
||||
return axiosClient.request(error.config);
|
||||
})
|
||||
.catch((newError: any) => {
|
||||
if (newError !== TIMEOUT_ERROR) {
|
||||
console.error('Error when trying to reauthenticate: ', newError);
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
// make sure to pass the error so axios will error later on
|
||||
throw newError;
|
||||
})
|
||||
.finally(() => {
|
||||
shouldDelay = false;
|
||||
});
|
||||
}
|
||||
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
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 {
|
||||
if (shouldDelay) await waitForResult();
|
||||
|
||||
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 },
|
||||
headers: result.headers as any,
|
||||
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 },
|
||||
headers: response.headers as any,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
jsonQuery: false,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,472 @@
|
||||
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';
|
||||
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
|
||||
query: {
|
||||
count: 10,
|
||||
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,
|
||||
...(artistInfoRes.status === 200 && {
|
||||
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
|
||||
...(!res.body.data.largeImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.largeImageUrl,
|
||||
}),
|
||||
...(!res.body.data.mediumImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.mediumImageUrl,
|
||||
}),
|
||||
...(!res.body.data.smallImageUrl && {
|
||||
largeImageUrl: artistInfoRes.body.artistInfo.smallImageUrl,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
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_artist_id: query.artistIds,
|
||||
album_id: query.albumIds,
|
||||
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: {
|
||||
id: 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,231 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Song, LibraryItem, Album, Playlist, User, AlbumArtist } from '/@/renderer/api/types';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import z from 'zod';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-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,
|
||||
lyrics: item.lyrics ? item.lyrics : null,
|
||||
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> & {
|
||||
similarArtists?: z.infer<typeof ssType._response.artistInfo>['artistInfo']['similarArtist'];
|
||||
},
|
||||
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:
|
||||
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,363 @@
|
||||
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(),
|
||||
recently_played: 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_artist_id: z.array(z.string()).optional(),
|
||||
album_id: z.array(z.string()).optional(),
|
||||
artist_id: z.array(z.string()).optional(),
|
||||
genre_id: z.string().optional(),
|
||||
starred: z.boolean().optional(),
|
||||
title: z.string().optional(),
|
||||
year: z.number().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(),
|
||||
smart: z.boolean().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({
|
||||
id: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ndType = {
|
||||
_enum: {
|
||||
albumArtistList: ndAlbumArtistListSort,
|
||||
albumList: ndAlbumListSort,
|
||||
playlistList: ndPlaylistListSort,
|
||||
songList: ndSongListSort,
|
||||
userList: ndUserListSort,
|
||||
},
|
||||
_parameters: {
|
||||
addToPlaylist: addToPlaylistParameters,
|
||||
albumArtistList: albumArtistListParameters,
|
||||
albumList: albumListParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createPlaylist: createPlaylistParameters,
|
||||
playlistList: playlistListParameters,
|
||||
removeFromPlaylist: removeFromPlaylistParameters,
|
||||
songList: songListParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
userList: userListParameters,
|
||||
},
|
||||
_response: {
|
||||
addToPlaylist,
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deletePlaylist,
|
||||
error,
|
||||
genre,
|
||||
genreList,
|
||||
playlist,
|
||||
playlistList,
|
||||
playlistSong,
|
||||
playlistSongList,
|
||||
removeFromPlaylist,
|
||||
song,
|
||||
songList,
|
||||
updatePlaylist,
|
||||
user,
|
||||
userList,
|
||||
},
|
||||
};
|
||||
@@ -1,292 +0,0 @@
|
||||
import { jfNormalize } from '/@/renderer/api/jellyfin.api';
|
||||
import type {
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFGenreList,
|
||||
JFMusicFolderList,
|
||||
JFPlaylist,
|
||||
JFSong,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
import { ndNormalize } from '/@/renderer/api/navidrome.api';
|
||||
import type {
|
||||
NDAlbum,
|
||||
NDAlbumArtist,
|
||||
NDGenreList,
|
||||
NDPlaylist,
|
||||
NDSong,
|
||||
NDUser,
|
||||
} from '/@/renderer/api/navidrome.types';
|
||||
import { ssNormalize } from '/@/renderer/api/subsonic.api';
|
||||
import { SSGenreList, SSMusicFolderList, SSSong } from '/@/renderer/api/subsonic.types';
|
||||
import type {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
RawAlbumArtistDetailResponse,
|
||||
RawAlbumArtistListResponse,
|
||||
RawAlbumDetailResponse,
|
||||
RawAlbumListResponse,
|
||||
RawGenreListResponse,
|
||||
RawMusicFolderListResponse,
|
||||
RawPlaylistDetailResponse,
|
||||
RawPlaylistListResponse,
|
||||
RawSongListResponse,
|
||||
RawTopSongListResponse,
|
||||
RawUserListResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
const albumList = (data: RawAlbumListResponse | undefined, server: ServerListItem | null) => {
|
||||
let albums;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albums = data?.items.map((item) => jfNormalize.album(item as JFAlbum, server));
|
||||
break;
|
||||
case 'navidrome':
|
||||
albums = data?.items.map((item) => ndNormalize.album(item as NDAlbum, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: albums,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const albumDetail = (
|
||||
data: RawAlbumDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
): Album | undefined => {
|
||||
let album: Album | undefined;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
album = jfNormalize.album(data as JFAlbum, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
album = ndNormalize.album(data as NDAlbum, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return album;
|
||||
};
|
||||
|
||||
const songList = (data: RawSongListResponse | undefined, server: ServerListItem | null) => {
|
||||
let songs;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
|
||||
break;
|
||||
case 'navidrome':
|
||||
songs = data?.items.map((item) => ndNormalize.song(item as NDSong, server, ''));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: songs,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const topSongList = (data: RawTopSongListResponse | undefined, server: ServerListItem | null) => {
|
||||
let songs;
|
||||
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
songs = data?.items.map((item) => jfNormalize.song(item as JFSong, server, ''));
|
||||
break;
|
||||
case 'navidrome':
|
||||
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||
break;
|
||||
case 'subsonic':
|
||||
songs = data?.items?.map((item) => ssNormalize.song(item as SSSong, server, ''));
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: songs,
|
||||
};
|
||||
};
|
||||
|
||||
const musicFolderList = (
|
||||
data: RawMusicFolderListResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let musicFolders;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
musicFolders = (data as JFMusicFolderList)?.map((item) => ({
|
||||
id: String(item.Id),
|
||||
name: item.Name,
|
||||
}));
|
||||
break;
|
||||
case 'navidrome':
|
||||
musicFolders = (data as SSMusicFolderList)?.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
}));
|
||||
break;
|
||||
case 'subsonic':
|
||||
musicFolders = (data as SSMusicFolderList)?.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
return musicFolders;
|
||||
};
|
||||
|
||||
const genreList = (data: RawGenreListResponse | undefined, server: ServerListItem | null) => {
|
||||
let genres;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
genres = (data as JFGenreList)?.Items.map((item) => ({
|
||||
id: String(item.Id),
|
||||
name: item.Name,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'navidrome':
|
||||
genres = (data as NDGenreList)
|
||||
?.map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'subsonic':
|
||||
genres = (data as SSGenreList)
|
||||
?.map((item) => ({
|
||||
id: item.value,
|
||||
name: item.value,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
}
|
||||
|
||||
return genres;
|
||||
};
|
||||
|
||||
const albumArtistDetail = (
|
||||
data: RawAlbumArtistDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist | undefined => {
|
||||
let albumArtist: AlbumArtist | undefined;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albumArtist = jfNormalize.albumArtist(data as JFAlbumArtist, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
albumArtist = ndNormalize.albumArtist(data as NDAlbumArtist, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return albumArtist;
|
||||
};
|
||||
|
||||
const albumArtistList = (
|
||||
data: RawAlbumArtistListResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let albumArtists;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
albumArtists = data?.items.map((item) =>
|
||||
jfNormalize.albumArtist(item as JFAlbumArtist, server),
|
||||
);
|
||||
break;
|
||||
case 'navidrome':
|
||||
albumArtists = data?.items.map((item) =>
|
||||
ndNormalize.albumArtist(item as NDAlbumArtist, server),
|
||||
);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: albumArtists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const playlistList = (data: RawPlaylistListResponse | undefined, server: ServerListItem | null) => {
|
||||
let playlists;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
playlists = data?.items.map((item) => jfNormalize.playlist(item as JFPlaylist, server));
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlists = data?.items.map((item) => ndNormalize.playlist(item as NDPlaylist, server));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: playlists,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
const playlistDetail = (
|
||||
data: RawPlaylistDetailResponse | undefined,
|
||||
server: ServerListItem | null,
|
||||
) => {
|
||||
let playlist;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
playlist = jfNormalize.playlist(data as JFPlaylist, server);
|
||||
break;
|
||||
case 'navidrome':
|
||||
playlist = ndNormalize.playlist(data as NDPlaylist, server);
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return playlist;
|
||||
};
|
||||
|
||||
const userList = (data: RawUserListResponse | undefined, server: ServerListItem | null) => {
|
||||
let users;
|
||||
switch (server?.type) {
|
||||
case 'jellyfin':
|
||||
break;
|
||||
case 'navidrome':
|
||||
users = data?.items.map((item) => ndNormalize.user(item as NDUser));
|
||||
break;
|
||||
case 'subsonic':
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: users,
|
||||
startIndex: data?.startIndex,
|
||||
totalRecordCount: data?.totalRecordCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalize = {
|
||||
albumArtistDetail,
|
||||
albumArtistList,
|
||||
albumDetail,
|
||||
albumList,
|
||||
genreList,
|
||||
musicFolderList,
|
||||
playlistDetail,
|
||||
playlistList,
|
||||
songList,
|
||||
topSongList,
|
||||
userList,
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { QueryFunctionContext } from '@tanstack/react-query';
|
||||
import { LyricSource } from './types';
|
||||
import type {
|
||||
AlbumListQuery,
|
||||
SongListQuery,
|
||||
@@ -10,9 +12,17 @@ import type {
|
||||
UserListQuery,
|
||||
AlbumArtistDetailQuery,
|
||||
TopSongListQuery,
|
||||
SearchQuery,
|
||||
SongDetailQuery,
|
||||
RandomSongListQuery,
|
||||
LyricsQuery,
|
||||
LyricSearchQuery,
|
||||
} from './types';
|
||||
|
||||
export const queryKeys = {
|
||||
export const queryKeys: Record<
|
||||
string,
|
||||
Record<string, (...props: any) => QueryFunctionContext['queryKey']>
|
||||
> = {
|
||||
albumArtists: {
|
||||
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||
@@ -70,20 +80,46 @@ export const queryKeys = {
|
||||
return [serverId, 'playlists', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'playlists'] as const,
|
||||
songList: (serverId: string, id: string, query?: PlaylistSongListQuery) => {
|
||||
if (query) return [serverId, 'playlists', id, 'songList', query] as const;
|
||||
songList: (serverId: string, id?: string, query?: PlaylistSongListQuery) => {
|
||||
if (query && id) return [serverId, 'playlists', id, 'songList', query] as const;
|
||||
if (id) return [serverId, 'playlists', id, 'songList'] as const;
|
||||
return [serverId, 'playlists', 'songList'] as const;
|
||||
},
|
||||
},
|
||||
search: {
|
||||
list: (serverId: string, query?: SearchQuery) => {
|
||||
if (query) return [serverId, 'search', 'list', query] as const;
|
||||
return [serverId, 'search', 'list'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'search'] as const,
|
||||
},
|
||||
server: {
|
||||
root: (serverId: string) => [serverId] as const,
|
||||
},
|
||||
songs: {
|
||||
detail: (serverId: string, query?: SongDetailQuery) => {
|
||||
if (query) return [serverId, 'songs', 'detail', query] as const;
|
||||
return [serverId, 'songs', 'detail'] as const;
|
||||
},
|
||||
list: (serverId: string, query?: SongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'list', query] as const;
|
||||
return [serverId, 'songs', 'list'] as const;
|
||||
},
|
||||
lyrics: (serverId: string, query?: LyricsQuery) => {
|
||||
if (query) return [serverId, 'song', 'lyrics', query] as const;
|
||||
return [serverId, 'song', 'lyrics'] as const;
|
||||
},
|
||||
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
|
||||
return ['song', 'lyrics', 'remote', searchQuery] as const;
|
||||
},
|
||||
lyricsSearch: (query?: LyricSearchQuery) => {
|
||||
if (query) return ['lyrics', 'search', query] as const;
|
||||
return ['lyrics', 'search'] as const;
|
||||
},
|
||||
randomSongList: (serverId: string, query?: RandomSongListQuery) => {
|
||||
if (query) return [serverId, 'songs', 'randomSongList', query] as const;
|
||||
return [serverId, 'songs', 'randomSongList'] as const;
|
||||
},
|
||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||
},
|
||||
users: {
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
import ky from 'ky';
|
||||
import md5 from 'md5';
|
||||
import { parseSearchParams, randomString } from '/@/renderer/utils';
|
||||
import type {
|
||||
SSAlbumListResponse,
|
||||
SSAlbumDetailResponse,
|
||||
SSArtistIndex,
|
||||
SSAlbumArtistList,
|
||||
SSAlbumArtistListResponse,
|
||||
SSGenreListResponse,
|
||||
SSMusicFolderList,
|
||||
SSMusicFolderListResponse,
|
||||
SSGenreList,
|
||||
SSAlbumDetail,
|
||||
SSAlbumList,
|
||||
SSAlbumArtistDetail,
|
||||
SSAlbumArtistDetailResponse,
|
||||
SSFavoriteParams,
|
||||
SSRatingParams,
|
||||
SSAlbumArtistDetailParams,
|
||||
SSAlbumArtistListParams,
|
||||
SSTopSongListParams,
|
||||
SSTopSongListResponse,
|
||||
SSArtistInfoParams,
|
||||
SSArtistInfoResponse,
|
||||
SSArtistInfo,
|
||||
SSSong,
|
||||
SSTopSongList,
|
||||
SSScrobbleParams,
|
||||
} from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
AlbumArtistListArgs,
|
||||
AlbumDetailArgs,
|
||||
AlbumListArgs,
|
||||
ArtistInfoArgs,
|
||||
AuthenticationResponse,
|
||||
FavoriteArgs,
|
||||
FavoriteResponse,
|
||||
GenreListArgs,
|
||||
LibraryItem,
|
||||
MusicFolderListArgs,
|
||||
QueueSong,
|
||||
RatingArgs,
|
||||
RatingResponse,
|
||||
RawScrobbleResponse,
|
||||
ScrobbleArgs,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
TopSongListArgs,
|
||||
} from '/@/renderer/api/types';
|
||||
import { toast } from '/@/renderer/components/toast';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
|
||||
const IGNORE_CORS = localStorage.getItem('IGNORE_CORS') === 'true';
|
||||
|
||||
const getCoverArtUrl = (args: {
|
||||
baseUrl: string;
|
||||
coverArtId: string;
|
||||
credential: string;
|
||||
size: number;
|
||||
}) => {
|
||||
const size = args.size ? args.size : 150;
|
||||
|
||||
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${args.coverArtId}` +
|
||||
`&${args.credential}` +
|
||||
'&v=1.13.0' +
|
||||
'&c=feishin' +
|
||||
`&size=${size}`
|
||||
);
|
||||
};
|
||||
|
||||
const api = ky.create({
|
||||
hooks: {
|
||||
afterResponse: [
|
||||
async (_request, _options, response) => {
|
||||
const data = await response.json();
|
||||
if (data['subsonic-response'].status !== 'ok') {
|
||||
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||
if (data['subsonic-response'].error.code !== 0) {
|
||||
toast.error({
|
||||
message: data['subsonic-response'].error.message,
|
||||
title: 'Issue from Subsonic API',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
||||
},
|
||||
],
|
||||
},
|
||||
mode: IGNORE_CORS ? 'cors' : undefined,
|
||||
});
|
||||
|
||||
const getDefaultParams = (server: ServerListItem | null) => {
|
||||
if (!server) return {};
|
||||
|
||||
const authParams = server.credential.split(/&?\w=/gm);
|
||||
|
||||
const params: Record<string, string> = {
|
||||
c: 'Feishin',
|
||||
f: 'json',
|
||||
u: server.username,
|
||||
v: '1.13.0',
|
||||
};
|
||||
|
||||
if (authParams?.length === 4) {
|
||||
params.s = authParams[2];
|
||||
params.t = authParams[3];
|
||||
} else if (authParams?.length === 3) {
|
||||
params.p = authParams[2];
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
const authenticate = async (
|
||||
url: string,
|
||||
body: {
|
||||
legacy?: boolean;
|
||||
password: string;
|
||||
username: string;
|
||||
},
|
||||
): Promise<AuthenticationResponse> => {
|
||||
let credential;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
if (body.legacy) {
|
||||
credential = `u=${body.username}&p=${body.password}`;
|
||||
} else {
|
||||
const salt = randomString(12);
|
||||
const hash = md5(body.password + salt);
|
||||
credential = `u=${body.username}&s=${salt}&t=${hash}`;
|
||||
}
|
||||
|
||||
await ky.get(`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${credential}`);
|
||||
|
||||
return {
|
||||
credential,
|
||||
userId: null,
|
||||
username: body.username,
|
||||
};
|
||||
};
|
||||
|
||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<SSMusicFolderList> => {
|
||||
const { signal, server } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const data = await api
|
||||
.get('rest/getMusicFolders.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: defaultParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSMusicFolderListResponse>();
|
||||
|
||||
return data.musicFolders.musicFolder;
|
||||
};
|
||||
|
||||
export const getAlbumArtistDetail = async (
|
||||
args: AlbumArtistDetailArgs,
|
||||
): Promise<SSAlbumArtistDetail> => {
|
||||
const { server, signal, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSAlbumArtistDetailParams = {
|
||||
id: query.id,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('/getArtist.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumArtistDetailResponse>();
|
||||
|
||||
return data.artist;
|
||||
};
|
||||
|
||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSAlbumArtistListParams = {
|
||||
musicFolderId: query.musicFolderId,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getArtists.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumArtistListResponse>();
|
||||
|
||||
const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
|
||||
|
||||
return {
|
||||
items: artists,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
};
|
||||
};
|
||||
|
||||
const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||
const { server, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const data = await api
|
||||
.get('rest/getGenres.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: defaultParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSGenreListResponse>();
|
||||
|
||||
return data.genres.genre;
|
||||
};
|
||||
|
||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams = {
|
||||
id: query.id,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getAlbum.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumDetailResponse>();
|
||||
|
||||
const { song: songs, ...dataWithoutSong } = data.album;
|
||||
return { ...dataWithoutSong, songs };
|
||||
};
|
||||
|
||||
const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams = {
|
||||
...defaultParams,
|
||||
};
|
||||
const data = await api
|
||||
.get('rest/getAlbumList2.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSAlbumListResponse>();
|
||||
|
||||
return {
|
||||
items: data.albumList2.album,
|
||||
startIndex: query.startIndex,
|
||||
totalRecordCount: null,
|
||||
};
|
||||
};
|
||||
|
||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
for (const id of query.id) {
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/star.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
// .json<SSFavoriteResponse>();
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
for (const id of query.id) {
|
||||
const searchParams: SSFavoriteParams = {
|
||||
albumId: query.type === LibraryItem.ALBUM ? id : undefined,
|
||||
artistId: query.type === LibraryItem.ALBUM_ARTIST ? id : undefined,
|
||||
id: query.type === LibraryItem.SONG ? id : undefined,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/unstar.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
// .json<SSFavoriteResponse>();
|
||||
}
|
||||
|
||||
return {
|
||||
id: query.id,
|
||||
type: query.type,
|
||||
};
|
||||
};
|
||||
|
||||
const updateRating = async (args: RatingArgs): Promise<RatingResponse> => {
|
||||
const { server, query, signal } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const itemIds = query.item.map((item) => item.id);
|
||||
|
||||
for (const id of itemIds) {
|
||||
const searchParams: SSRatingParams = {
|
||||
id,
|
||||
rating: query.rating,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/setRating.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTopSongList = async (args: TopSongListArgs): Promise<SSTopSongList> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSTopSongListParams = {
|
||||
artist: query.artist,
|
||||
count: query.limit,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getTopSongs.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams: parseSearchParams(searchParams),
|
||||
signal,
|
||||
})
|
||||
.json<SSTopSongListResponse>();
|
||||
|
||||
return {
|
||||
items: data?.topSongs?.song,
|
||||
startIndex: 0,
|
||||
totalRecordCount: data?.topSongs?.song?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSArtistInfoParams = {
|
||||
count: query.limit,
|
||||
id: query.artistId,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
const data = await api
|
||||
.get('rest/getArtistInfo2.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
})
|
||||
.json<SSArtistInfoResponse>();
|
||||
|
||||
return data.artistInfo2;
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSScrobbleParams = {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/scrobble.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
|
||||
const streamUrl = `${server.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`;
|
||||
|
||||
return {
|
||||
album: item.album,
|
||||
albumArtists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist,
|
||||
},
|
||||
],
|
||||
albumId: item.albumId,
|
||||
artistName: item.artist,
|
||||
artists: [
|
||||
{
|
||||
id: item.artistId || '',
|
||||
imageUrl: null,
|
||||
name: item.artist,
|
||||
},
|
||||
],
|
||||
bitRate: item.bitRate,
|
||||
bpm: null,
|
||||
channels: null,
|
||||
comment: null,
|
||||
compilation: null,
|
||||
container: item.contentType,
|
||||
createdAt: item.created,
|
||||
discNumber: item.discNumber || 1,
|
||||
duration: item.duration,
|
||||
genres: [
|
||||
{
|
||||
id: item.genre,
|
||||
name: item.genre,
|
||||
},
|
||||
],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.SONG,
|
||||
lastPlayedAt: null,
|
||||
name: item.title,
|
||||
path: item.path,
|
||||
playCount: item?.playCount || 0,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year ? String(item.year) : null,
|
||||
serverId: server.id,
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: item.size,
|
||||
streamUrl,
|
||||
trackNumber: item.track,
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: '',
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const subsonicApi = {
|
||||
authenticate,
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
getAlbumArtistDetail,
|
||||
getAlbumArtistList,
|
||||
getAlbumDetail,
|
||||
getAlbumList,
|
||||
getArtistInfo,
|
||||
getCoverArtUrl,
|
||||
getGenreList,
|
||||
getMusicFolderList,
|
||||
getTopSongList,
|
||||
scrobble,
|
||||
updateRating,
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
song: normalizeSong,
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
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,
|
||||
},
|
||||
},
|
||||
getRandomSongList: {
|
||||
method: 'GET',
|
||||
path: 'getRandomSongs.view',
|
||||
query: ssType._parameters.randomSongList,
|
||||
responses: {
|
||||
200: ssType._response.randomSongList,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
search3: {
|
||||
method: 'GET',
|
||||
path: 'search3.view',
|
||||
query: ssType._parameters.search3,
|
||||
responses: {
|
||||
200: ssType._response.search3,
|
||||
},
|
||||
},
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
||||
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 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> = {};
|
||||
|
||||
const { params, path: api } = parsePath(path);
|
||||
|
||||
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,
|
||||
...params,
|
||||
},
|
||||
signal,
|
||||
url: `${baseUrl}/${api}`,
|
||||
});
|
||||
|
||||
return {
|
||||
body: result.data['subsonic-response'],
|
||||
headers: result.headers as any,
|
||||
status: result.status,
|
||||
};
|
||||
} catch (e: Error | AxiosError | any) {
|
||||
console.log('CATCH ERR');
|
||||
|
||||
if (isAxiosError(e)) {
|
||||
const error = e as AxiosError;
|
||||
const response = error.response as AxiosResponse;
|
||||
|
||||
return {
|
||||
body: response?.data,
|
||||
headers: response.headers as any,
|
||||
status: response?.status,
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
baseUrl: '',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,381 @@
|
||||
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,
|
||||
SearchArgs,
|
||||
SearchResponse,
|
||||
RandomSongListResponse,
|
||||
RandomSongListArgs,
|
||||
} 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;
|
||||
};
|
||||
|
||||
const search3 = async (args: SearchArgs): Promise<SearchResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).search3({
|
||||
query: {
|
||||
albumCount: query.albumLimit,
|
||||
albumOffset: query.albumStartIndex,
|
||||
artistCount: query.albumArtistLimit,
|
||||
artistOffset: query.albumArtistStartIndex,
|
||||
query: query.query,
|
||||
songCount: query.songLimit,
|
||||
songOffset: query.songStartIndex,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to search');
|
||||
}
|
||||
|
||||
return {
|
||||
albumArtists: res.body.searchResult3?.artist?.map((artist) =>
|
||||
ssNormalize.albumArtist(artist, apiClientProps.server),
|
||||
),
|
||||
albums: res.body.searchResult3?.album?.map((album) =>
|
||||
ssNormalize.album(album, apiClientProps.server),
|
||||
),
|
||||
songs: res.body.searchResult3?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongListResponse> => {
|
||||
const { query, apiClientProps } = args;
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getRandomSongList({
|
||||
query: {
|
||||
fromYear: query.minYear,
|
||||
genre: query.genre,
|
||||
musicFolderId: query.musicFolderId,
|
||||
size: query.limit,
|
||||
toYear: query.maxYear,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to get random songs');
|
||||
}
|
||||
|
||||
return {
|
||||
items: res.body.randomSongs?.song?.map((song) =>
|
||||
ssNormalize.song(song, apiClientProps.server, ''),
|
||||
),
|
||||
startIndex: 0,
|
||||
totalRecordCount: res.body.randomSongs?.song?.length || 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssController = {
|
||||
authenticate,
|
||||
createFavorite,
|
||||
getArtistInfo,
|
||||
getMusicFolderList,
|
||||
getRandomSongList,
|
||||
getTopSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
search3,
|
||||
setRating,
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||
import { QueueSong, LibraryItem, AlbumArtist, Album } 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 : 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 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,
|
||||
lyrics: 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,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbumArtist = (
|
||||
item: z.infer<typeof ssType._response.albumArtist>,
|
||||
server: ServerListItem | null,
|
||||
): AlbumArtist => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 100,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
albumCount: item.albumCount ? Number(item.albumCount) : 0,
|
||||
backgroundImageUrl: null,
|
||||
biography: null,
|
||||
duration: null,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imageUrl,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
lastPlayedAt: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
similarArtists: [],
|
||||
songCount: null,
|
||||
userFavorite: false,
|
||||
userRating: null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeAlbum = (
|
||||
item: z.infer<typeof ssType._response.album>,
|
||||
server: ServerListItem | null,
|
||||
): Album => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
baseUrl: server?.url,
|
||||
coverArtId: item.coverArt,
|
||||
credential: server?.credential,
|
||||
size: 300,
|
||||
}) || null;
|
||||
|
||||
return {
|
||||
albumArtists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
artists: item.artistId ? [{ id: item.artistId, imageUrl: null, name: item.artist }] : [],
|
||||
backdropImageUrl: null,
|
||||
createdAt: item.created,
|
||||
duration: item.duration,
|
||||
genres: item.genre ? [{ id: item.genre, name: item.genre }] : [],
|
||||
id: item.id,
|
||||
imagePlaceholderUrl: null,
|
||||
imageUrl,
|
||||
isCompilation: null,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
lastPlayedAt: null,
|
||||
name: item.name,
|
||||
playCount: null,
|
||||
releaseDate: item.year ? new Date(item.year, 0, 1).toISOString() : null,
|
||||
releaseYear: item.year ? Number(item.year) : null,
|
||||
serverId: server?.id || 'unknown',
|
||||
serverType: ServerType.SUBSONIC,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
songs: [],
|
||||
uniqueId: nanoid(),
|
||||
updatedAt: item.created,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.userRating || null,
|
||||
};
|
||||
};
|
||||
|
||||
export const ssNormalize = {
|
||||
album: normalizeAlbum,
|
||||
albumArtist: normalizeAlbumArtist,
|
||||
song: normalizeSong,
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
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({
|
||||
artistInfo: 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();
|
||||
|
||||
const search3 = z.object({
|
||||
searchResult3: z.object({
|
||||
album: z.array(album),
|
||||
artist: z.array(albumArtist),
|
||||
song: z.array(song),
|
||||
}),
|
||||
});
|
||||
|
||||
const search3Parameters = z.object({
|
||||
albumCount: z.number().optional(),
|
||||
albumOffset: z.number().optional(),
|
||||
artistCount: z.number().optional(),
|
||||
artistOffset: z.number().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
songCount: z.number().optional(),
|
||||
songOffset: z.number().optional(),
|
||||
});
|
||||
|
||||
const randomSongListParameters = z.object({
|
||||
fromYear: z.number().optional(),
|
||||
genre: z.string().optional(),
|
||||
musicFolderId: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
toYear: z.number().optional(),
|
||||
});
|
||||
|
||||
const randomSongList = z.object({
|
||||
randomSongs: z.object({
|
||||
song: z.array(song),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ssType = {
|
||||
_parameters: {
|
||||
albumList: albumListParameters,
|
||||
artistInfo: artistInfoParameters,
|
||||
authenticate: authenticateParameters,
|
||||
createFavorite: createFavoriteParameters,
|
||||
randomSongList: randomSongListParameters,
|
||||
removeFavorite: removeFavoriteParameters,
|
||||
scrobble: scrobbleParameters,
|
||||
search3: search3Parameters,
|
||||
setRating: setRatingParameters,
|
||||
topSongsList: topSongsListParameters,
|
||||
},
|
||||
_response: {
|
||||
album,
|
||||
albumArtist,
|
||||
albumArtistList,
|
||||
albumList,
|
||||
artistInfo,
|
||||
authenticate,
|
||||
baseResponse,
|
||||
createFavorite,
|
||||
musicFolderList,
|
||||
randomSongList,
|
||||
removeFavorite,
|
||||
scrobble,
|
||||
search3,
|
||||
setRating,
|
||||
song,
|
||||
topSongsList,
|
||||
},
|
||||
};
|
||||
+211
-173
@@ -1,50 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
JFSortOrder,
|
||||
JFGenreList,
|
||||
JFAlbumList,
|
||||
JFAlbumListSort,
|
||||
JFAlbumDetail,
|
||||
JFSongList,
|
||||
JFSongListSort,
|
||||
JFAlbumArtistList,
|
||||
JFAlbumArtistListSort,
|
||||
JFAlbumArtistDetail,
|
||||
JFArtistList,
|
||||
JFArtistListSort,
|
||||
JFPlaylistList,
|
||||
JFPlaylistDetail,
|
||||
JFMusicFolderList,
|
||||
JFPlaylistListSort,
|
||||
} from '/@/renderer/api/jellyfin.types';
|
||||
} from './jellyfin.types';
|
||||
import { jfType } from './jellyfin/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';
|
||||
} from './navidrome.types';
|
||||
import { ndType } from './navidrome/navidrome-types';
|
||||
|
||||
export enum LibraryItem {
|
||||
ALBUM = 'album',
|
||||
@@ -159,8 +132,10 @@ export type AuthenticationResponse = {
|
||||
};
|
||||
|
||||
export type Genre = {
|
||||
albumCount?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
songCount?: number;
|
||||
};
|
||||
|
||||
export type Album = {
|
||||
@@ -192,7 +167,7 @@ export type Album = {
|
||||
} & { songs?: Song[] };
|
||||
|
||||
export type Song = {
|
||||
album: string;
|
||||
album: string | null;
|
||||
albumArtists: RelatedArtist[];
|
||||
albumId: string;
|
||||
artistName: string;
|
||||
@@ -212,6 +187,7 @@ export type Song = {
|
||||
imageUrl: string | null;
|
||||
itemType: LibraryItem.SONG;
|
||||
lastPlayedAt: string | null;
|
||||
lyrics: string | null;
|
||||
name: string;
|
||||
path: string | null;
|
||||
playCount: number;
|
||||
@@ -305,14 +281,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 +295,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 +316,16 @@ 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?: Partial<z.infer<typeof jfType._parameters.albumList>> & {
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
};
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.albumList>>;
|
||||
};
|
||||
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 +395,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 +402,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 +426,17 @@ export enum SongListSort {
|
||||
}
|
||||
|
||||
export type SongListQuery = {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.songList>> & {
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
};
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.songList>>;
|
||||
};
|
||||
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 +515,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 +522,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 +539,12 @@ export enum AlbumArtistListSort {
|
||||
}
|
||||
|
||||
export type AlbumArtistListQuery = {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
genre_id?: string;
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
searchTerm?: string;
|
||||
sortBy: AlbumArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
@@ -673,21 +602,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 +627,12 @@ export enum ArtistListSort {
|
||||
}
|
||||
|
||||
export type ArtistListQuery = {
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.albumArtistList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.albumArtistList>>;
|
||||
};
|
||||
limit?: number;
|
||||
musicFolderId?: string;
|
||||
ndParams?: {
|
||||
genre_id?: string;
|
||||
name?: string;
|
||||
starred?: boolean;
|
||||
};
|
||||
sortBy: ArtistListSort;
|
||||
sortOrder: SortOrder;
|
||||
startIndex: number;
|
||||
@@ -770,31 +691,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 +724,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 +810,11 @@ export enum PlaylistListSort {
|
||||
}
|
||||
|
||||
export type PlaylistListQuery = {
|
||||
limit?: number;
|
||||
ndParams?: {
|
||||
owner_id?: string;
|
||||
smart?: boolean;
|
||||
_custom?: {
|
||||
jellyfin?: Partial<z.infer<typeof jfType._parameters.playlistList>>;
|
||||
navidrome?: Partial<z.infer<typeof ndType._parameters.playlistList>>;
|
||||
};
|
||||
limit?: number;
|
||||
searchTerm?: string;
|
||||
sortBy: PlaylistListSort;
|
||||
sortOrder: SortOrder;
|
||||
@@ -936,9 +857,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 +866,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 +879,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 +894,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 +927,6 @@ export const userListSortMap: UserListSortMap = {
|
||||
};
|
||||
|
||||
// Top Songs List
|
||||
export type RawTopSongListResponse = SSTopSongList | JFSongList | undefined;
|
||||
|
||||
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
||||
|
||||
export type TopSongListQuery = {
|
||||
@@ -1032,10 +947,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 = {
|
||||
@@ -1044,3 +960,125 @@ export type ScrobbleQuery = {
|
||||
position?: number;
|
||||
submission: boolean;
|
||||
};
|
||||
|
||||
export type SearchQuery = {
|
||||
albumArtistLimit?: number;
|
||||
albumArtistStartIndex?: number;
|
||||
albumLimit?: number;
|
||||
albumStartIndex?: number;
|
||||
musicFolderId?: string;
|
||||
query?: string;
|
||||
songLimit?: number;
|
||||
songStartIndex?: number;
|
||||
};
|
||||
|
||||
export type SearchSongsQuery = {
|
||||
musicFolderId?: string;
|
||||
query?: string;
|
||||
songLimit?: number;
|
||||
songStartIndex?: number;
|
||||
};
|
||||
|
||||
export type SearchAlbumsQuery = {
|
||||
albumLimit?: number;
|
||||
albumStartIndex?: number;
|
||||
musicFolderId?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type SearchAlbumArtistsQuery = {
|
||||
albumArtistLimit?: number;
|
||||
albumArtistStartIndex?: number;
|
||||
musicFolderId?: string;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export type SearchArgs = {
|
||||
query: SearchQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type SearchResponse = {
|
||||
albumArtists: AlbumArtist[];
|
||||
albums: Album[];
|
||||
songs: Song[];
|
||||
};
|
||||
|
||||
export type RandomSongListQuery = {
|
||||
genre?: string;
|
||||
limit?: number;
|
||||
maxYear?: number;
|
||||
minYear?: number;
|
||||
musicFolderId?: string;
|
||||
};
|
||||
|
||||
export type RandomSongListArgs = {
|
||||
query: RandomSongListQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type RandomSongListResponse = SongListResponse;
|
||||
|
||||
export type LyricsQuery = {
|
||||
songId: string;
|
||||
};
|
||||
|
||||
export type LyricsArgs = {
|
||||
query: LyricsQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type SynchronizedLyricsArray = Array<[number, string]>;
|
||||
|
||||
export type LyricsResponse = SynchronizedLyricsArray | string;
|
||||
|
||||
export type InternetProviderLyricResponse = {
|
||||
artist: string;
|
||||
id: string;
|
||||
lyrics: string;
|
||||
name: string;
|
||||
source: LyricSource;
|
||||
};
|
||||
|
||||
export type InternetProviderLyricSearchResponse = {
|
||||
artist: string;
|
||||
id: string;
|
||||
name: string;
|
||||
score?: number;
|
||||
source: LyricSource;
|
||||
};
|
||||
|
||||
export type SynchronizedLyricMetadata = {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
remote: boolean;
|
||||
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export type UnsynchronizedLyricMetadata = {
|
||||
lyrics: string;
|
||||
remote: boolean;
|
||||
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata;
|
||||
|
||||
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export const instanceOfCancellationError = (error: any) => {
|
||||
return 'revert' in error;
|
||||
};
|
||||
|
||||
export type LyricSearchQuery = {
|
||||
album?: string;
|
||||
artist?: string;
|
||||
duration?: number;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type LyricGetQuery = {
|
||||
remoteSongId: string;
|
||||
remoteSource: LyricSource;
|
||||
};
|
||||
|
||||
export enum LyricSource {
|
||||
GENIUS = 'Genius',
|
||||
LRCLIB = 'lrclib.net',
|
||||
NETEASE = 'NetEase',
|
||||
}
|
||||
|
||||
export type LyricsOverride = Omit<FullLyricsMetadata, 'lyrics'> & { id: string };
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AxiosHeaders } from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
// 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),
|
||||
});
|
||||
};
|
||||
|
||||
export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
+78
-25
@@ -8,7 +8,7 @@ import { initSimpleImg } from 'react-simple-img';
|
||||
import { BaseContextModal } from './components';
|
||||
import { useTheme } from './hooks';
|
||||
import { AppRouter } from './router/app-router';
|
||||
import { useSettingsStore } from './store/settings.store';
|
||||
import { useHotkeySettings, usePlaybackSettings, useSettingsStore } from './store/settings.store';
|
||||
import './styles/global.scss';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||
@@ -17,19 +17,24 @@ import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||
import isElectron from 'is-electron';
|
||||
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||
import { usePlayerStore } from '/@/renderer/store';
|
||||
import { PlayerState, usePlayerStore, useQueueControls } from '/@/renderer/store';
|
||||
import { PlaybackType, PlayerStatus } from '/@/renderer/types';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||
|
||||
initSimpleImg({ threshold: 0.05 }, true);
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
||||
const ipc = isElectron() ? window.electron.ipc : null;
|
||||
|
||||
export const App = () => {
|
||||
const theme = useTheme();
|
||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||
|
||||
const { type: playbackType } = usePlaybackSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const handlePlayQueueAdd = useHandlePlayQueueAdd();
|
||||
const { clearQueue, restoreQueue } = useQueueControls();
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -38,17 +43,70 @@ export const App = () => {
|
||||
|
||||
// Start the mpv instance on startup
|
||||
useEffect(() => {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties = {
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
volume: usePlayerStore.getState().volume,
|
||||
const initializeMpv = async () => {
|
||||
const isRunning: boolean | undefined = await mpvPlayer?.isRunning();
|
||||
|
||||
if (!isRunning) {
|
||||
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||
const properties = {
|
||||
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||
};
|
||||
|
||||
mpvPlayer?.initialize({
|
||||
extraParameters,
|
||||
properties,
|
||||
});
|
||||
|
||||
mpvPlayer?.volume(properties.volume);
|
||||
}
|
||||
};
|
||||
|
||||
mpvPlayer?.restart({
|
||||
extraParameters,
|
||||
properties,
|
||||
});
|
||||
}, []);
|
||||
if (isElectron() && playbackType === PlaybackType.LOCAL) {
|
||||
initializeMpv();
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearQueue();
|
||||
mpvPlayer?.stop();
|
||||
mpvPlayer?.cleanup();
|
||||
};
|
||||
}, [clearQueue, playbackType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
ipc?.send('set-global-shortcuts', bindings);
|
||||
}
|
||||
}, [bindings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) {
|
||||
mpvPlayer.restoreQueue();
|
||||
|
||||
mpvPlayerListener.rendererSaveQueue(() => {
|
||||
const { current, queue } = usePlayerStore.getState();
|
||||
const stateToSave: Partial<Pick<PlayerState, 'current' | 'queue'>> = {
|
||||
current: {
|
||||
...current,
|
||||
status: PlayerStatus.PAUSED,
|
||||
},
|
||||
queue,
|
||||
};
|
||||
mpvPlayer.saveQueue(stateToSave);
|
||||
});
|
||||
|
||||
mpvPlayerListener.rendererRestoreQueue((_event: any, data: Partial<PlayerState>) => {
|
||||
const playerData = restoreQueue(data);
|
||||
if (playbackType === PlaybackType.LOCAL) {
|
||||
mpvPlayer.setQueue(playerData, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
ipc?.removeAllListeners('renderer-player-restore-queue');
|
||||
ipc?.removeAllListeners('renderer-player-save-queue');
|
||||
};
|
||||
}, [playbackType, restoreQueue]);
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
@@ -61,13 +119,12 @@ export const App = () => {
|
||||
styles: {
|
||||
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
|
||||
close: { marginRight: '0.5rem' },
|
||||
content: { borderRadius: '10px' },
|
||||
content: { borderRadius: '5px' },
|
||||
header: {
|
||||
background: 'var(--modal-bg)',
|
||||
borderBottom: '1px solid var(--generic-border-color)',
|
||||
background: 'var(--modal-header-bg)',
|
||||
paddingBottom: '1rem',
|
||||
},
|
||||
title: { fontSize: 'medium', fontWeight: 'bold' },
|
||||
title: { fontSize: 'medium', fontWeight: 500 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -95,14 +152,6 @@ export const App = () => {
|
||||
headings: {
|
||||
fontFamily: 'var(--content-font-family)',
|
||||
fontWeight: 700,
|
||||
sizes: {
|
||||
h1: '6rem',
|
||||
h2: '4rem',
|
||||
h3: '3rem',
|
||||
h4: '1.5rem',
|
||||
h5: '1.2rem',
|
||||
h6: '1rem',
|
||||
},
|
||||
},
|
||||
other: {},
|
||||
spacing: {
|
||||
@@ -117,10 +166,14 @@ export const App = () => {
|
||||
<ModalsProvider
|
||||
modalProps={{
|
||||
centered: true,
|
||||
styles: {
|
||||
body: { position: 'relative' },
|
||||
content: { overflow: 'auto' },
|
||||
},
|
||||
transitionProps: {
|
||||
duration: 300,
|
||||
exitDuration: 300,
|
||||
transition: 'slide-down',
|
||||
transition: 'fade',
|
||||
},
|
||||
}}
|
||||
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||
|
||||
@@ -24,7 +24,7 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||
border: ${(props) => `var(--btn-${props.variant}-border)`};
|
||||
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border 0.2s ease-in-out;
|
||||
|
||||
svg {
|
||||
transition: fill 0.2s ease-in-out;
|
||||
@@ -41,6 +41,7 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
&:not([data-disabled])&:hover {
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
border: ${(props) => `var(--btn-${props.variant}-border-hover)`};
|
||||
|
||||
svg {
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
@@ -62,7 +63,9 @@ const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
|
||||
& .mantine-Button-leftIcon {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
margin-right: 0.5rem;
|
||||
transform: translateY(-0.1rem);
|
||||
}
|
||||
|
||||
.mantine-Button-rightIcon {
|
||||
|
||||
@@ -122,7 +122,7 @@ export const CardControls = ({
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const Row = styled.div<{ $secondary?: boolean }>`
|
||||
|
||||
interface CardRowsProps {
|
||||
data: any;
|
||||
rows: CardRow<Album | Artist | AlbumArtist>[];
|
||||
rows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
}
|
||||
|
||||
export const CardRows = ({ data, rows }: CardRowsProps) => {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Center, Stack } from '@mantine/core';
|
||||
import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import styled, { css } from 'styled-components';
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
import { CardRows } from '/@/renderer/components/card';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
||||
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
|
||||
|
||||
interface BaseGridCardProps {
|
||||
controls: {
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => void;
|
||||
handlePlayQueueAdd: ((options: PlayQueueAddOptions) => void) | undefined;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageContainerStyles = css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 100%) 35%, rgba(0, 0, 0, 0%) 100%);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageContainer = styled(Link)<{ $isFavorite?: boolean }>`
|
||||
${ImageContainerStyles}
|
||||
`;
|
||||
|
||||
const ImageContainerSkeleton = styled.div`
|
||||
${ImageContainerStyles}
|
||||
`;
|
||||
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const PosterCard = ({
|
||||
data,
|
||||
controls,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
}: BaseGridCardProps & { uniqueId: string }) => {
|
||||
if (!isLoading) {
|
||||
const path = generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
);
|
||||
|
||||
let Placeholder = RiAlbumFill;
|
||||
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`${uniqueId}-${data.id}`}>
|
||||
<ImageContainer
|
||||
$isFavorite={data?.userFavorite}
|
||||
to={path}
|
||||
>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`placeholder-${uniqueId}-${data.id}`}>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageContainerSkeleton />
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Checkbox as MantineCheckbox, CheckboxProps } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledCheckbox = styled(MantineCheckbox)`
|
||||
& .mantine-Checkbox-input {
|
||||
background-color: var(--input-bg);
|
||||
|
||||
&:checked {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
&:hover:not(:checked) {
|
||||
background-color: var(--primary-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
transition: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
({ ...props }: CheckboxProps, ref) => {
|
||||
return (
|
||||
<StyledCheckbox
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
MenuDropdownProps as MantineMenuDropdownProps,
|
||||
} from '@mantine/core';
|
||||
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
|
||||
import { RiArrowLeftSFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type MenuProps = MantineMenuProps;
|
||||
@@ -31,23 +32,6 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
||||
font-size: var(--dropdown-menu-item-font-size);
|
||||
font-family: var(--content-font-family);
|
||||
|
||||
${(props) =>
|
||||
props.$isActive &&
|
||||
`
|
||||
&::before {
|
||||
content: ''; // ::before and ::after both require content
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--dropdown-menu-bg-hover);
|
||||
opacity: 0.5;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
`}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -62,6 +46,10 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
||||
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
|
||||
}
|
||||
|
||||
& .mantine-Menu-itemRightSection {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
cursor: default;
|
||||
`;
|
||||
|
||||
@@ -114,7 +102,7 @@ const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) =>
|
||||
<StyledMenuItem
|
||||
$danger={$danger}
|
||||
$isActive={$isActive}
|
||||
// rightSection={$isActive && <RiArrowLeftSFill size={20} />}
|
||||
rightSection={$isActive && <RiArrowLeftSFill size={15} />}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { DropzoneProps as MantineDropzoneProps } from '@mantine/dropzone';
|
||||
import { Dropzone as MantineDropzone } from '@mantine/dropzone';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export type DropzoneProps = MantineDropzoneProps;
|
||||
|
||||
const StyledDropzone = styled(MantineDropzone)`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--input-bg);
|
||||
border-radius: 5px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--input-bg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& .mantine-Dropzone-inner {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Dropzone = ({ children, ...props }: DropzoneProps) => {
|
||||
return <StyledDropzone {...props}>{children}</StyledDropzone>;
|
||||
};
|
||||
|
||||
Dropzone.Accept = StyledDropzone.Accept;
|
||||
Dropzone.Idle = StyledDropzone.Idle;
|
||||
Dropzone.Reject = StyledDropzone.Reject;
|
||||
@@ -6,20 +6,22 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
import { Link, generatePath } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import type { Album } from '/@/renderer/api/types';
|
||||
import { Album, LibraryItem } from '/@/renderer/api/types';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { TextTitle } from '/@/renderer/components/text-title';
|
||||
import { Badge } from '/@/renderer/components/badge';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
import { Play } from '/@/renderer/types';
|
||||
|
||||
const Carousel = styled(motion.div)`
|
||||
position: relative;
|
||||
height: 30vh;
|
||||
height: 35vh;
|
||||
min-height: 250px;
|
||||
|
||||
padding: 2rem;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--main-bg), rgba(25, 26, 28, 60%));
|
||||
border-radius: 1rem;
|
||||
`;
|
||||
|
||||
const Grid = styled.div`
|
||||
@@ -27,7 +29,7 @@ const Grid = styled.div`
|
||||
grid-auto-columns: 1fr;
|
||||
grid-template-areas: 'image info';
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
@@ -46,6 +48,7 @@ const InfoColumn = styled.div`
|
||||
grid-area: info;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding-left: 1rem;
|
||||
`;
|
||||
|
||||
@@ -106,6 +109,7 @@ interface FeatureCarouselProps {
|
||||
}
|
||||
|
||||
export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const [itemIndex, setItemIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
@@ -114,12 +118,22 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
const handleNext = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(1);
|
||||
if (itemIndex === (data?.length || 0) - 1 || 0) {
|
||||
setItemIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setItemIndex((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handlePrevious = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setDirection(-1);
|
||||
if (itemIndex === 0) {
|
||||
setItemIndex((data?.length || 0) - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
setItemIndex((prev) => prev - 1);
|
||||
};
|
||||
|
||||
@@ -144,7 +158,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
<Image
|
||||
height={225}
|
||||
placeholder="var(--card-default-bg)"
|
||||
radius="sm"
|
||||
radius="md"
|
||||
src={data[itemIndex]?.imageUrl}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
width={225}
|
||||
@@ -157,17 +171,17 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
>
|
||||
<TitleWrapper>
|
||||
<TextTitle
|
||||
lh="4rem"
|
||||
lineClamp={2}
|
||||
lh="3.5rem"
|
||||
order={1}
|
||||
sx={{ fontSize: '4rem' }}
|
||||
overflow="hidden"
|
||||
sx={{ fontSize: '3.5rem' }}
|
||||
weight={900}
|
||||
>
|
||||
{currentItem?.name}
|
||||
</TextTitle>
|
||||
</TitleWrapper>
|
||||
<TitleWrapper>
|
||||
{currentItem?.albumArtists.map((artist) => (
|
||||
{currentItem?.albumArtists.slice(0, 1).map((artist) => (
|
||||
<TextTitle
|
||||
key={`carousel-artist-${artist.id}`}
|
||||
order={2}
|
||||
@@ -178,7 +192,7 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
))}
|
||||
</TitleWrapper>
|
||||
<Group>
|
||||
{currentItem?.genres?.map((genre) => (
|
||||
{currentItem?.genres?.slice(0, 1).map((genre) => (
|
||||
<Badge
|
||||
key={`carousel-genre-${genre.id}`}
|
||||
size="lg"
|
||||
@@ -189,6 +203,46 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Button
|
||||
size="lg"
|
||||
style={{ borderRadius: '5rem' }}
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!currentItem) return;
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: [currentItem.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
playType: Play.NOW,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Play
|
||||
</Button>
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<RiArrowLeftSLine size="2rem" />
|
||||
</Button>
|
||||
<Button
|
||||
radius="lg"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine size="2rem" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InfoColumn>
|
||||
</Grid>
|
||||
@@ -200,29 +254,6 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
||||
</Carousel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Group
|
||||
spacing="xs"
|
||||
sx={{ bottom: '1rem', position: 'absolute', right: 0, zIndex: 20 }}
|
||||
>
|
||||
<Button
|
||||
disabled={itemIndex === 0}
|
||||
radius={100}
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handlePrevious}
|
||||
>
|
||||
<RiArrowLeftSLine size="2rem" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={itemIndex === (data?.length || 1) - 1}
|
||||
radius={100}
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine size="2rem" />
|
||||
</Button>
|
||||
</Group>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,227 +1,278 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { isValidElement, ReactNode, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import type { Variants } from 'framer-motion';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import type { CardRow } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import styled from 'styled-components';
|
||||
import { AlbumCard } from '/@/renderer/components/card';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { SwiperOptions, Virtual } from 'swiper';
|
||||
import 'swiper/css';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Swiper as SwiperCore } from 'swiper/types';
|
||||
import { Album, AlbumArtist, Artist, LibraryItem, RelatedArtist } from '/@/renderer/api/types';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { PosterCard } from '/@/renderer/components/card/poster-card';
|
||||
import { TextTitle } from '/@/renderer/components/text-title';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store';
|
||||
import { CardRoute, CardRow } from '/@/renderer/types';
|
||||
|
||||
interface GridCarouselProps {
|
||||
cardRows: CardRow<any>[];
|
||||
children: React.ReactElement;
|
||||
containerWidth: number;
|
||||
data: any[] | undefined;
|
||||
itemType: LibraryItem;
|
||||
loading?: boolean;
|
||||
pagination?: {
|
||||
handleNextPage?: () => void;
|
||||
handlePreviousPage?: () => void;
|
||||
hasNextPage?: boolean;
|
||||
hasPreviousPage?: boolean;
|
||||
itemsPerPage?: number;
|
||||
};
|
||||
uniqueId: string;
|
||||
}
|
||||
const getSlidesPerView = (windowWidth: number) => {
|
||||
if (windowWidth < 400) return 2;
|
||||
if (windowWidth < 700) return 3;
|
||||
if (windowWidth < 900) return 4;
|
||||
if (windowWidth < 1100) return 5;
|
||||
if (windowWidth < 1300) return 6;
|
||||
if (windowWidth < 1500) return 7;
|
||||
if (windowWidth < 1920) return 8;
|
||||
return 10;
|
||||
};
|
||||
|
||||
const GridCarouselContext = createContext<any>({});
|
||||
|
||||
const GridContainer = styled(motion.div)<{ height: number; itemsPerPage: number }>`
|
||||
display: grid;
|
||||
grid-auto-rows: 0;
|
||||
grid-gap: 18px;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: repeat(${(props) => props.itemsPerPage || 4}, minmax(0, 1fr));
|
||||
height: ${(props) => props.height}px;
|
||||
overflow: hidden;
|
||||
const CarouselContainer = styled(Stack)`
|
||||
container-type: inline-size;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const variants: Variants = {
|
||||
animate: (custom: { direction: number; loading: boolean }) => {
|
||||
return {
|
||||
opacity: custom.loading ? 0.5 : 1,
|
||||
scale: custom.loading ? 0.95 : 1,
|
||||
transition: {
|
||||
opacity: { duration: 0.2 },
|
||||
x: { damping: 30, stiffness: 300, type: 'spring' },
|
||||
},
|
||||
x: 0,
|
||||
};
|
||||
},
|
||||
exit: (custom: { direction: number; loading: boolean }) => {
|
||||
return {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
opacity: { duration: 0.2 },
|
||||
x: { damping: 30, stiffness: 300, type: 'spring' },
|
||||
},
|
||||
x: custom.direction > 0 ? -1000 : 1000,
|
||||
};
|
||||
},
|
||||
initial: (custom: { direction: number; loading: boolean }) => {
|
||||
return {
|
||||
opacity: 0,
|
||||
x: custom.direction > 0 ? 1000 : -1000,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const Carousel = ({ data, cardRows }: any) => {
|
||||
const { loading, pagination, gridHeight, imageSize, direction, uniqueId, itemType } =
|
||||
useContext(GridCarouselContext);
|
||||
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<AnimatePresence
|
||||
custom={{ direction, loading }}
|
||||
initial={false}
|
||||
mode="popLayout"
|
||||
>
|
||||
<GridContainer
|
||||
key={`carousel-${uniqueId}-${data[0]?.id}`}
|
||||
animate="animate"
|
||||
custom={{ direction, loading }}
|
||||
exit="exit"
|
||||
height={gridHeight}
|
||||
initial="initial"
|
||||
itemsPerPage={pagination.itemsPerPage}
|
||||
variants={variants}
|
||||
>
|
||||
{data?.map((item: any, index: number) => (
|
||||
<AlbumCard
|
||||
key={`card-${uniqueId}-${index}`}
|
||||
controls={{
|
||||
cardRows,
|
||||
itemType: itemType || LibraryItem.ALBUM,
|
||||
playButtonBehavior: playButtonBehavior || Play.NOW,
|
||||
route: cardRows[0]?.route || {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
}}
|
||||
data={item}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
size={imageSize}
|
||||
/>
|
||||
))}
|
||||
</GridContainer>
|
||||
</AnimatePresence>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const GridCarousel = ({
|
||||
data,
|
||||
loading,
|
||||
cardRows,
|
||||
pagination,
|
||||
children,
|
||||
containerWidth,
|
||||
uniqueId,
|
||||
itemType,
|
||||
}: GridCarouselProps) => {
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
const gridHeight = useMemo(
|
||||
() => (containerWidth * 1.2 - 36) / (pagination?.itemsPerPage || 4),
|
||||
[containerWidth, pagination?.itemsPerPage],
|
||||
);
|
||||
|
||||
const imageSize = useMemo(() => gridHeight * 0.66, [gridHeight]);
|
||||
|
||||
const providerValue = useMemo(
|
||||
() => ({
|
||||
cardRows,
|
||||
data,
|
||||
direction,
|
||||
gridHeight,
|
||||
imageSize,
|
||||
itemType,
|
||||
loading,
|
||||
pagination,
|
||||
setDirection,
|
||||
uniqueId,
|
||||
}),
|
||||
[cardRows, data, direction, gridHeight, imageSize, itemType, loading, pagination, uniqueId],
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCarouselContext.Provider value={providerValue}>
|
||||
<Stack>
|
||||
{children}
|
||||
{data && (
|
||||
<Carousel
|
||||
cardRows={cardRows}
|
||||
data={data}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</GridCarouselContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface TitleProps {
|
||||
children?: React.ReactNode;
|
||||
handleNext?: () => void;
|
||||
handlePrev?: () => void;
|
||||
label?: string | ReactNode;
|
||||
pagination: {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const Title = ({ children }: TitleProps) => {
|
||||
const { pagination, setDirection } = useContext(GridCarouselContext);
|
||||
const showPaginationButtons = pagination?.handleNextPage && pagination?.handlePreviousPage;
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setDirection(1);
|
||||
pagination?.handleNextPage?.();
|
||||
}, [pagination, setDirection]);
|
||||
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
setDirection(-1);
|
||||
pagination?.handlePreviousPage?.();
|
||||
}, [pagination, setDirection]);
|
||||
|
||||
const Title = ({ label, handleNext, handlePrev, pagination }: TitleProps) => {
|
||||
return (
|
||||
<Group position="apart">
|
||||
{children}
|
||||
{showPaginationButtons && (
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination?.hasPreviousPage}
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handlePreviousPage}
|
||||
>
|
||||
<RiArrowLeftSLine size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
size="md"
|
||||
variant="default"
|
||||
onClick={handleNextPage}
|
||||
>
|
||||
<RiArrowRightSLine size={15} />
|
||||
</Button>
|
||||
</Group>
|
||||
{isValidElement(label) ? (
|
||||
label
|
||||
) : (
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
{label}
|
||||
</TextTitle>
|
||||
)}
|
||||
|
||||
<Group spacing="sm">
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasPreviousPage}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
<Button
|
||||
compact
|
||||
disabled={!pagination.hasNextPage}
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handleNext}
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
GridCarousel.Title = Title;
|
||||
GridCarousel.Carousel = Carousel;
|
||||
interface SwiperGridCarouselProps {
|
||||
cardRows: CardRow<Album>[] | CardRow<Artist>[] | CardRow<AlbumArtist>[];
|
||||
data: Album[] | AlbumArtist[] | Artist[] | RelatedArtist[] | undefined;
|
||||
isLoading?: boolean;
|
||||
itemType: LibraryItem;
|
||||
route: CardRoute;
|
||||
swiperProps?: SwiperOptions;
|
||||
title?: {
|
||||
children?: ReactNode;
|
||||
hasPagination?: boolean;
|
||||
icon?: ReactNode;
|
||||
label: string | ReactNode;
|
||||
};
|
||||
uniqueId: string;
|
||||
}
|
||||
|
||||
export const SwiperGridCarousel = ({
|
||||
cardRows,
|
||||
data,
|
||||
itemType,
|
||||
route,
|
||||
swiperProps,
|
||||
title,
|
||||
isLoading,
|
||||
uniqueId,
|
||||
}: SwiperGridCarouselProps) => {
|
||||
const swiperRef = useRef<SwiperCore | any>(null);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const [pagination, setPagination] = useState({
|
||||
hasNextPage: (data?.length || 0) > Math.round(3),
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = useCallback(
|
||||
(options: { id: string[]; isFavorite: boolean; itemType: LibraryItem; serverId: string }) => {
|
||||
const { id, itemType, isFavorite, serverId } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId,
|
||||
});
|
||||
}
|
||||
},
|
||||
[createFavoriteMutation, deleteFavoriteMutation],
|
||||
);
|
||||
|
||||
const slides = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.map((el) => (
|
||||
<PosterCard
|
||||
controls={{
|
||||
cardRows,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
}}
|
||||
data={el}
|
||||
isLoading={isLoading}
|
||||
uniqueId={uniqueId}
|
||||
/>
|
||||
));
|
||||
}, [
|
||||
cardRows,
|
||||
data,
|
||||
handleFavorite,
|
||||
handlePlayQueueAdd,
|
||||
isLoading,
|
||||
itemType,
|
||||
playButtonBehavior,
|
||||
route,
|
||||
uniqueId,
|
||||
]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
swiperRef?.current?.slideTo(activeIndex + slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
const activeIndex = swiperRef?.current?.activeIndex || 0;
|
||||
const slidesPerView = Math.round(Number(swiperProps?.slidesPerView || 4));
|
||||
swiperRef?.current?.slideTo(activeIndex - slidesPerView);
|
||||
}, [swiperProps?.slidesPerView]);
|
||||
|
||||
const handleOnSlideChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params?.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params?.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnZoomChange = useCallback((e: SwiperCore) => {
|
||||
const { slides, isEnd, isBeginning, params } = e;
|
||||
if (isEnd || isBeginning) return;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnReachEnd = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: (params.slidesPerView || 4) < slides.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnReachBeginning = useCallback((e: SwiperCore) => {
|
||||
const { slides, params } = e;
|
||||
|
||||
setPagination({
|
||||
hasNextPage: (params.slidesPerView || 4) < slides.length,
|
||||
hasPreviousPage: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleOnResize = useCallback((e: SwiperCore) => {
|
||||
if (!e) return;
|
||||
const { width } = e;
|
||||
const slidesPerView = getSlidesPerView(width);
|
||||
if (!e.params) return;
|
||||
e.params.slidesPerView = slidesPerView;
|
||||
}, []);
|
||||
|
||||
const throttledOnResize = throttle(handleOnResize, 200);
|
||||
|
||||
return (
|
||||
<CarouselContainer
|
||||
className="grid-carousel"
|
||||
spacing="md"
|
||||
>
|
||||
{title ? (
|
||||
<Title
|
||||
{...title}
|
||||
handleNext={handleNext}
|
||||
handlePrev={handlePrev}
|
||||
pagination={pagination}
|
||||
/>
|
||||
) : null}
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
resizeObserver
|
||||
modules={[Virtual]}
|
||||
slidesPerView={4}
|
||||
spaceBetween={20}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onBeforeInit={(swiper) => {
|
||||
swiperRef.current = swiper;
|
||||
}}
|
||||
onBeforeResize={handleOnResize}
|
||||
onReachBeginning={handleOnReachBeginning}
|
||||
onReachEnd={handleOnReachEnd}
|
||||
onResize={throttledOnResize}
|
||||
onSlideChange={handleOnSlideChange}
|
||||
onZoomChange={handleOnZoomChange}
|
||||
{...swiperProps}
|
||||
>
|
||||
{slides.map((slideContent, index) => {
|
||||
return (
|
||||
<SwiperSlide
|
||||
key={`${uniqueId}-${slideContent?.props?.data?.id}-${index}`}
|
||||
virtualIndex={index}
|
||||
>
|
||||
{slideContent}
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</CarouselContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,9 +5,7 @@ export * from './button';
|
||||
export * from './card';
|
||||
export * from './date-picker';
|
||||
export * from './dropdown-menu';
|
||||
export * from './dropzone';
|
||||
export * from './feature-carousel';
|
||||
export * from './grid-carousel';
|
||||
export * from './input';
|
||||
export * from './modal';
|
||||
export * from './page-header';
|
||||
@@ -27,11 +25,10 @@ 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';
|
||||
export * from './rating';
|
||||
export * from './hover-card';
|
||||
export * from './option';
|
||||
export * from './checkbox';
|
||||
|
||||
@@ -76,6 +76,7 @@ const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
|
||||
}
|
||||
|
||||
& .mantine-TextInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-faimly);
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
|
||||
}
|
||||
|
||||
& .mantine-NumberInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-faimly);
|
||||
}
|
||||
|
||||
@@ -160,6 +162,7 @@ const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
|
||||
}
|
||||
|
||||
& .mantine-PasswordInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-faimly);
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
|
||||
}
|
||||
|
||||
& .mantine-FileInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-faimly);
|
||||
}
|
||||
|
||||
@@ -226,6 +230,7 @@ const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
|
||||
}
|
||||
|
||||
& .mantine-JsonInput-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-faimly);
|
||||
}
|
||||
|
||||
@@ -255,6 +260,7 @@ const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
|
||||
}
|
||||
|
||||
& .mantine-Textarea-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--label-font-faimly);
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export const PageHeader = ({
|
||||
$isHidden={isHidden}
|
||||
$padRight={padRight}
|
||||
>
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{!isHidden && (
|
||||
<TitleWrapper
|
||||
animate="animate"
|
||||
|
||||
@@ -34,12 +34,20 @@ const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarS
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { TextInputProps } from '@mantine/core';
|
||||
import { ActionIcon, TextInputProps } from '@mantine/core';
|
||||
import { useFocusWithin, useHotkeys, useMergedRef } from '@mantine/hooks';
|
||||
import { RiSearchLine } from 'react-icons/ri';
|
||||
import { RiCloseFill, RiSearchLine } from 'react-icons/ri';
|
||||
import { TextInput } from '/@/renderer/components/input';
|
||||
import { useSettingsStore } from '/@/renderer/store';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
interface SearchInputProps extends TextInputProps {
|
||||
initialWidth?: number;
|
||||
@@ -18,18 +20,12 @@ export const SearchInput = ({
|
||||
}: SearchInputProps) => {
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const mergedRef = useMergedRef<HTMLInputElement>(ref);
|
||||
const binding = useSettingsStore((state) => state.hotkeys.bindings.localSearch, shallow);
|
||||
|
||||
const isOpened = focused || ref.current?.value;
|
||||
const showIcon = !isOpened || (openedWidth || 100) > 100;
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
'ctrl+F',
|
||||
() => {
|
||||
ref.current.select();
|
||||
},
|
||||
],
|
||||
]);
|
||||
useHotkeys([[binding.hotkey, () => ref.current.select()]]);
|
||||
|
||||
const handleEscape = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.code === 'Escape') {
|
||||
@@ -44,6 +40,18 @@ export const SearchInput = ({
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
icon={showIcon && <RiSearchLine />}
|
||||
rightSection={
|
||||
isOpened ? (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
ref.current.value = '';
|
||||
ref.current.focus();
|
||||
}}
|
||||
>
|
||||
<RiCloseFill />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
size="md"
|
||||
styles={{
|
||||
icon: { svg: { fill: 'var(--titlebar-fg)' } },
|
||||
|
||||
@@ -10,7 +10,7 @@ interface SelectProps extends MantineSelectProps {
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps extends MantineMultiSelectProps {
|
||||
export interface MultiSelectProps extends MantineMultiSelectProps {
|
||||
maxWidth?: number | string;
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
@@ -3,35 +3,8 @@ import { Skeleton as MantineSkeleton } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledSkeleton = styled(MantineSkeleton)`
|
||||
@keyframes run {
|
||||
0% {
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: var(--skeleton-bg);
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, transparent, var(--skeleton-bg), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation-name: run;
|
||||
animation-duration: 1.5s;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
content: '';
|
||||
inset: 0;
|
||||
background: var(--placeholder-bg);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Center } from '@mantine/core';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { RiLoader5Fill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
@@ -5,6 +6,7 @@ import { rotating } from '/@/renderer/styles';
|
||||
|
||||
interface SpinnerProps extends IconType {
|
||||
color?: string;
|
||||
container?: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
@@ -14,6 +16,20 @@ export const SpinnerIcon = styled(RiLoader5Fill)`
|
||||
`;
|
||||
|
||||
export const Spinner = ({ ...props }: SpinnerProps) => {
|
||||
if (props.container) {
|
||||
return (
|
||||
<Center
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
<SpinnerIcon
|
||||
color={props.color}
|
||||
size={props.size}
|
||||
/>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return <SpinnerIcon {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const StyledTabs = styled(MantineTabs)`
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
& .mantine-Tabs-tab {
|
||||
padding: 1rem;
|
||||
color: var(--btn-subtle-fg);
|
||||
border-radius: 0;
|
||||
|
||||
@@ -23,7 +23,6 @@ const StyledText = styled(MantineText)<TextProps>`
|
||||
color: ${(props) => (props.$secondary ? 'var(--main-fg-secondary)' : 'var(--main-fg)')};
|
||||
font-family: ${(props) => props.font};
|
||||
cursor: ${(props) => props.$link && 'cursor'};
|
||||
transition: color 0.2s ease-in-out;
|
||||
user-select: ${(props) => (props.$noSelect ? 'none' : 'auto')};
|
||||
${(props) => props.overflow === 'hidden' && !props.lineClamp && textEllipsis}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const showToast = ({ type, ...props }: NotificationProps) => {
|
||||
? 'Error'
|
||||
: 'Info';
|
||||
|
||||
const defaultDuration = type === 'error' ? 4000 : 2000;
|
||||
const defaultDuration = type === 'error' ? 2000 : 1000;
|
||||
|
||||
return showNotification({
|
||||
autoClose: defaultDuration,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Center, Stack } from '@mantine/core';
|
||||
import { RiAlbumFill, RiUserVoiceFill, RiPlayListFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import { ListChildComponentProps } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
@@ -63,7 +66,7 @@ const InnerCardContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageContainer = styled.div`
|
||||
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -86,14 +89,35 @@ const ImageContainer = styled.div`
|
||||
content: '';
|
||||
user-select: none;
|
||||
}
|
||||
${(props) =>
|
||||
props.$isFavorite &&
|
||||
`
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
|
||||
transform: rotate(-45deg);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
@@ -120,17 +144,55 @@ export const DefaultCard = ({
|
||||
}, {}),
|
||||
);
|
||||
|
||||
let Placeholder = RiAlbumFill;
|
||||
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
onClick={() => navigate(path)}
|
||||
>
|
||||
<InnerCardContainer>
|
||||
<ImageContainer>
|
||||
<Image
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
<ImageContainer $isFavorite={data?.userFavorite}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
@@ -161,6 +223,18 @@ export const DefaultCard = ({
|
||||
radius="sm"
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</InnerCardContainer>
|
||||
</DefaultCardContainer>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import React from 'react';
|
||||
import React, { MouseEvent, useState } from 'react';
|
||||
import type { UnstyledButtonProps } from '@mantine/core';
|
||||
import { RiPlayFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
@@ -62,7 +61,7 @@ const SecondaryButton = styled(_Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div`
|
||||
const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
@@ -73,6 +72,19 @@ const GridCardControlsContainer = styled.div`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const FavoriteBanner = styled.div`
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
|
||||
transform: rotate(-45deg);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const ControlsRow = styled.div`
|
||||
width: 100%;
|
||||
height: calc(100% / 3);
|
||||
@@ -100,11 +112,17 @@ export const GridCardControls = ({
|
||||
handlePlayQueueAdd,
|
||||
handleFavorite,
|
||||
}: {
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handleFavorite: (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
serverId: string;
|
||||
}) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
@@ -116,11 +134,11 @@ export const GridCardControls = ({
|
||||
id: [itemData.id],
|
||||
type: itemType,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>, serverId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -128,7 +146,10 @@ export const GridCardControls = ({
|
||||
id: [itemData.id],
|
||||
isFavorite: itemData.userFavorite,
|
||||
itemType,
|
||||
serverId,
|
||||
});
|
||||
|
||||
setIsFavorite(!isFavorite);
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
@@ -137,42 +158,48 @@ export const GridCardControls = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer className="card-controls">
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<BottomControls>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={handleFavorites}
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{itemData?.userFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
<>
|
||||
{isFavorite ? <FavoriteBanner /> : null}
|
||||
<GridCardControlsContainer
|
||||
$isFavorite
|
||||
className="card-controls"
|
||||
>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<BottomControls>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={(e) => handleFavorites(e, itemData?.serverId)}
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</FavoriteWrapper>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMoreFill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Stack } from '@mantine/core';
|
||||
import { Center, Stack } from '@mantine/core';
|
||||
import { RiAlbumFill, RiPlayListFill, RiUserVoiceFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router-dom';
|
||||
import { SimpleImg } from 'react-simple-img';
|
||||
import { ListChildComponentProps } from 'react-window';
|
||||
import styled from 'styled-components';
|
||||
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||
@@ -42,11 +44,10 @@ const LinkContainer = styled.div`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const ImageContainer = styled.div`
|
||||
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
@@ -66,6 +67,23 @@ const ImageContainer = styled.div`
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$isFavorite &&
|
||||
`
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: -50px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 0 0 10px 8px rgba(0, 0, 0, 80%);
|
||||
transform: rotate(-45deg);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
@@ -77,12 +95,17 @@ const ImageContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const Image = styled.img`
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailContainer = styled.div`
|
||||
@@ -109,14 +132,51 @@ export const PosterCard = ({
|
||||
}, {}),
|
||||
);
|
||||
|
||||
let Placeholder = RiAlbumFill;
|
||||
|
||||
switch (controls.itemType) {
|
||||
case LibraryItem.ALBUM:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
case LibraryItem.ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.ALBUM_ARTIST:
|
||||
Placeholder = RiUserVoiceFill;
|
||||
break;
|
||||
case LibraryItem.PLAYLIST:
|
||||
Placeholder = RiPlayListFill;
|
||||
break;
|
||||
default:
|
||||
Placeholder = RiAlbumFill;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
|
||||
<LinkContainer onClick={() => navigate(path)}>
|
||||
<ImageContainer>
|
||||
<Image
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
<ImageContainer $isFavorite={data?.userFavorite}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-default-radius)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
@@ -148,9 +208,9 @@ export const PosterCard = ({
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row) => (
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={row.arrayProperty}
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
|
||||
@@ -57,13 +57,16 @@ export const VirtualGridWrapper = ({
|
||||
itemData,
|
||||
route,
|
||||
onScroll,
|
||||
height,
|
||||
width,
|
||||
...rest
|
||||
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children'> & {
|
||||
}: Omit<FixedSizeListProps, 'ref' | 'itemSize' | 'children' | 'height' | 'width'> & {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
columnCount: number;
|
||||
display: ListDisplayType;
|
||||
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
height?: number;
|
||||
itemData: any[];
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
@@ -72,6 +75,7 @@ export const VirtualGridWrapper = ({
|
||||
refInstance: Ref<any>;
|
||||
route?: CardRoute;
|
||||
rowCount: number;
|
||||
width?: number;
|
||||
}) => {
|
||||
const memoizedItemData = createItemData(
|
||||
cardRows,
|
||||
@@ -94,11 +98,13 @@ export const VirtualGridWrapper = ({
|
||||
<FixedSizeList
|
||||
ref={refInstance}
|
||||
{...rest}
|
||||
height={(height && Number(height)) || 0}
|
||||
initialScrollOffset={initialScrollOffset}
|
||||
itemCount={rowCount}
|
||||
itemData={memoizedItemData}
|
||||
itemSize={itemHeight}
|
||||
overscanCount={5}
|
||||
width={(width && Number(width)) || 0}
|
||||
onScroll={memoizedOnScroll}
|
||||
>
|
||||
{GridCard}
|
||||
|
||||
@@ -21,18 +21,21 @@ export type VirtualInfiniteGridRef = {
|
||||
setItemData: (data: any[]) => void;
|
||||
};
|
||||
|
||||
interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSize'> {
|
||||
interface VirtualGridProps
|
||||
extends Omit<FixedSizeListProps, 'children' | 'itemSize' | 'height' | 'width'> {
|
||||
cardRows: CardRow<any>[];
|
||||
display?: ListDisplayType;
|
||||
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
||||
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||
height?: number;
|
||||
itemGap: number;
|
||||
itemSize: number;
|
||||
itemType: LibraryItem;
|
||||
loading?: boolean;
|
||||
minimumBatchSize?: number;
|
||||
route?: CardRoute;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const VirtualInfiniteGrid = forwardRef(
|
||||
|
||||
@@ -38,18 +38,28 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
@@ -38,18 +38,28 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
||||
,
|
||||
</Text>
|
||||
)}{' '}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
|
||||
artistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
{item.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: item.id,
|
||||
})}
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
>
|
||||
{item.name || '—'}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
@@ -116,19 +116,30 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
||||
artists.map((artist: Artist | AlbumArtist, index: number) => (
|
||||
<React.Fragment key={`queue-${rowIndex}-artist-${artist.id}`}>
|
||||
{index > 0 ? ', ' : null}
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
{artist.id ? (
|
||||
<Text
|
||||
$link
|
||||
$secondary
|
||||
component={Link}
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
$secondary
|
||||
overflow="hidden"
|
||||
size="md"
|
||||
sx={{ width: 'fit-content' }}
|
||||
>
|
||||
{artist.name}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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;
|
||||
@@ -56,6 +19,7 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
serverId: data.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -70,6 +34,7 @@ export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
|
||||
id: [data.id],
|
||||
type: data.itemType,
|
||||
},
|
||||
serverId: data.serverId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
import { ICellRendererParams } from '@ag-grid-community/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { Button } from '/@/renderer/components/button';
|
||||
import { Paper } from '/@/renderer/components/paper';
|
||||
import { getNodesByDiscNumber, setNodeSelection } from '../utils';
|
||||
|
||||
const Container = styled(Paper)`
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
`;
|
||||
|
||||
export const FullWidthDiscCell = ({ node, data, api }: ICellRendererParams) => {
|
||||
const [isSelected, setIsSelected] = useState(false);
|
||||
|
||||
const handleToggleDiscNodes = () => {
|
||||
if (!data) return;
|
||||
const discNumber = Number(node.data.id.split('-')[1]);
|
||||
const nodes = getNodesByDiscNumber({ api, discNumber });
|
||||
|
||||
setNodeSelection({ isSelected: !isSelected, nodes });
|
||||
setIsSelected((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Group
|
||||
position="apart"
|
||||
w="100%"
|
||||
>
|
||||
<Button
|
||||
compact
|
||||
leftIcon={isSelected ? <RiCheckboxLine /> : <RiCheckboxBlankLine />}
|
||||
size="md"
|
||||
variant="subtle"
|
||||
onClick={handleToggleDiscNodes}
|
||||
>
|
||||
{data.name}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -9,7 +9,10 @@ export const useFixedTableHeader = () => {
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
const isNotPastTableIntersection = useInView(intersectRef, {
|
||||
margin: windowBarStyle === Platform.WEB ? '-68px 0px 0px 0px' : '-98px 0px 0px 0px',
|
||||
margin:
|
||||
windowBarStyle === Platform.WEB || windowBarStyle === Platform.LINUX
|
||||
? '-68px 0px 0px 0px'
|
||||
: '-98px 0px 0px 0px',
|
||||
});
|
||||
|
||||
const tableInView = useInView(tableContainerRef, {
|
||||
@@ -21,13 +24,13 @@ export const useFixedTableHeader = () => {
|
||||
const root = document.querySelector('main .ag-root');
|
||||
|
||||
if (isNotPastTableIntersection || !tableInView) {
|
||||
if (windowBarStyle !== Platform.WEB) {
|
||||
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
|
||||
header?.classList.remove('window-frame');
|
||||
}
|
||||
header?.classList.remove('ag-header-fixed');
|
||||
root?.classList.remove('ag-header-fixed-margin');
|
||||
} else {
|
||||
if (windowBarStyle !== Platform.WEB) {
|
||||
if (windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS) {
|
||||
header?.classList.add('window-frame');
|
||||
}
|
||||
header?.classList.add('ag-header-fixed');
|
||||
|
||||
@@ -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,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
NewColumnsLoadedEvent,
|
||||
GridReadyEvent,
|
||||
GridSizeChangedEvent,
|
||||
ModelUpdatedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import type { AgGridReactProps } from '@ag-grid-community/react';
|
||||
import { AgGridReact } from '@ag-grid-community/react';
|
||||
@@ -28,13 +30,14 @@ 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';
|
||||
export * from './hooks/use-fixed-table-header';
|
||||
export * from './hooks/use-click-outside-deselect';
|
||||
export * from './utils';
|
||||
|
||||
const TableWrapper = styled.div`
|
||||
display: flex;
|
||||
@@ -318,6 +321,7 @@ export const getColumnDefs = (columns: PersistedTableColumn[]) => {
|
||||
columnDefs.push({
|
||||
...presetColumn,
|
||||
initialWidth: column.width,
|
||||
...column.extraProps,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -417,6 +421,14 @@ export const VirtualTable = forwardRef(
|
||||
[autoFitColumns, onGridSizeChanged],
|
||||
);
|
||||
|
||||
const handleModelUpdated = useCallback(
|
||||
(e: ModelUpdatedEvent) => {
|
||||
if (!e?.api) return;
|
||||
if (autoFitColumns) e.api?.sizeColumnsToFit?.();
|
||||
},
|
||||
[autoFitColumns],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableWrapper
|
||||
ref={deselectRef}
|
||||
@@ -446,6 +458,7 @@ export const VirtualTable = forwardRef(
|
||||
onColumnMoved={handleColumnMoved}
|
||||
onGridReady={handleGridReady}
|
||||
onGridSizeChanged={handleGridSizeChanged}
|
||||
onModelUpdated={handleModelUpdated}
|
||||
onNewColumnsLoaded={handleNewColumnsLoaded}
|
||||
/>
|
||||
</TableWrapper>
|
||||
|
||||
@@ -2,9 +2,14 @@ import type { ChangeEvent } from 'react';
|
||||
import { MultiSelect } from '/@/renderer/components/select';
|
||||
import { Slider } from '/@/renderer/components/slider';
|
||||
import { Switch } from '/@/renderer/components/switch';
|
||||
import { useSettingsStoreActions, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import {
|
||||
useSettingsStoreActions,
|
||||
useSettingsStore,
|
||||
useLyricsSettings,
|
||||
} from '/@/renderer/store/settings.store';
|
||||
import { TableColumn, TableType } from '/@/renderer/types';
|
||||
import { Option } from '/@/renderer/components/option';
|
||||
import { NumberInput } from '/@/renderer/components/input';
|
||||
|
||||
export const SONG_TABLE_COLUMNS = [
|
||||
{ label: 'Row Index', value: TableColumn.ROW_INDEX },
|
||||
@@ -82,6 +87,7 @@ interface TableConfigDropdownProps {
|
||||
export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
const { setSettings } = useSettingsStoreActions();
|
||||
const tableConfig = useSettingsStore((state) => state.tables);
|
||||
const lyricConfig = useLyricsSettings();
|
||||
|
||||
const handleAddOrRemoveColumns = (values: TableColumn[]) => {
|
||||
const existingColumns = tableConfig[type].columns;
|
||||
@@ -166,6 +172,24 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleLyricFollow = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
follow: e.currentTarget.checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleLyricOffset = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSettings({
|
||||
lyrics: {
|
||||
...useSettingsStore.getState().lyrics,
|
||||
delayMs: Number(e.currentTarget.value),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Option>
|
||||
@@ -186,6 +210,25 @@ export const TableConfigDropdown = ({ type }: TableConfigDropdownProps) => {
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Follow current lyrics</Option.Label>
|
||||
<Option.Control>
|
||||
<Switch
|
||||
defaultChecked={lyricConfig.follow}
|
||||
onChange={handleLyricFollow}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Label>Lyric offset (ms)</Option.Label>
|
||||
<Option.Control>
|
||||
<NumberInput
|
||||
defaultValue={lyricConfig.delayMs}
|
||||
step={10}
|
||||
onBlur={handleLyricOffset}
|
||||
/>
|
||||
</Option.Control>
|
||||
</Option>
|
||||
<Option>
|
||||
<Option.Control>
|
||||
<Slider
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { GridApi, RowNode } from '@ag-grid-community/core';
|
||||
|
||||
export const getNodesByDiscNumber = (args: { api: GridApi; discNumber: number }) => {
|
||||
const { api, discNumber } = args;
|
||||
|
||||
const nodes: RowNode<any>[] = [];
|
||||
api.forEachNode((node) => {
|
||||
if (node.data.discNumber === discNumber) nodes.push(node);
|
||||
});
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
export const setNodeSelection = (args: {
|
||||
deselectAll?: boolean;
|
||||
isSelected: boolean;
|
||||
nodes: RowNode<any>[];
|
||||
}) => {
|
||||
const { nodes, isSelected } = args;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.setSelected(isSelected);
|
||||
});
|
||||
};
|
||||
|
||||
export const toggleNodeSelection = (args: { nodes: RowNode<any>[] }) => {
|
||||
const { nodes } = args;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.isSelected()) {
|
||||
node.setSelected(false);
|
||||
} else {
|
||||
node.setSelected(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -53,8 +53,6 @@ const ActionRequiredRoute = () => {
|
||||
},
|
||||
];
|
||||
|
||||
console.log(checks);
|
||||
|
||||
const canReturnHome = checks.every((c) => c.valid);
|
||||
const displayedCheck = checks.find((c) => !c.valid);
|
||||
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
getColumnDefs,
|
||||
GridCarousel,
|
||||
Text,
|
||||
TextTitle,
|
||||
useFixedTableHeader,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { Button } 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';
|
||||
import { useSetState } from '@mantine/hooks';
|
||||
import { RiDiscFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { useAlbumDetail } from '/@/renderer/features/albums/queries/album-detail-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -33,9 +25,17 @@ 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';
|
||||
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
import { FullWidthDiscCell } from '/@/renderer/components/virtual-table/cells/full-width-disc-cell';
|
||||
|
||||
const isFullWidthRow = (node: RowNode) => {
|
||||
return node.id?.includes('disc-');
|
||||
return node.id?.startsWith('disc-');
|
||||
};
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
@@ -60,7 +60,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();
|
||||
|
||||
@@ -125,16 +126,11 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
}
|
||||
|
||||
const uniqueDiscNumbers = new Set(detailQuery.data?.songs.map((s) => s.discNumber));
|
||||
|
||||
if (uniqueDiscNumbers.size === 1) {
|
||||
return detailQuery.data?.songs;
|
||||
}
|
||||
|
||||
const rowData: (QueueSong | { id: string; name: string })[] = [];
|
||||
|
||||
for (const discNumber of uniqueDiscNumbers.values()) {
|
||||
const songsByDiscNumber = detailQuery.data?.songs.filter((s) => s.discNumber === discNumber);
|
||||
rowData.push({ id: `disc-${discNumber}`, name: `DISC ${discNumber}` });
|
||||
rowData.push({ id: `disc-${discNumber}`, name: `Disc ${discNumber}`.toLocaleUpperCase() });
|
||||
rowData.push(...songsByDiscNumber);
|
||||
}
|
||||
|
||||
@@ -165,26 +161,30 @@ 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,
|
||||
ExcludeItemIds: detailQuery?.data?.id,
|
||||
},
|
||||
navidrome: {
|
||||
artist_id: detailQuery?.data?.albumArtists[0]?.id,
|
||||
},
|
||||
},
|
||||
limit: 10,
|
||||
sortBy: AlbumListSort.YEAR,
|
||||
sortOrder: SortOrder.DESC,
|
||||
startIndex: pagination.artist * itemsPerPage,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
@@ -196,14 +196,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
hasPreviousPage: pagination.artist > 0,
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
>
|
||||
More from this artist
|
||||
</TextTitle>
|
||||
),
|
||||
title: 'More from this artist',
|
||||
uniqueId: 'mostPlayed',
|
||||
},
|
||||
];
|
||||
@@ -213,22 +206,30 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
const handlePlay = async (playType?: Play) => {
|
||||
handlePlayQueueAdd?.({
|
||||
byData: detailQuery?.data?.songs,
|
||||
play: playType || playButtonBehavior,
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
if (!e.data || e.node.isFullWidthCell()) return;
|
||||
|
||||
const rowData: QueueSong[] = [];
|
||||
e.api.forEachNode((node) => {
|
||||
if (!node.data || node.isFullWidthCell()) return;
|
||||
rowData.push(node.data);
|
||||
});
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
byData: rowData,
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
@@ -239,6 +240,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
@@ -246,6 +248,7 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -321,31 +324,21 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
<Box ref={tableContainerRef}>
|
||||
<Box
|
||||
ref={tableContainerRef}
|
||||
style={{ minHeight: '300px' }}
|
||||
>
|
||||
<VirtualTable
|
||||
ref={tableRef}
|
||||
autoFitColumns
|
||||
autoHeight
|
||||
deselectOnClickOutside
|
||||
suppressCellFocus
|
||||
suppressHorizontalScroll
|
||||
suppressLoadingOverlay
|
||||
suppressRowDrag
|
||||
columnDefs={columnDefs}
|
||||
enableCellChangeFlash={false}
|
||||
fullWidthCellRenderer={(data: any) => {
|
||||
if (!data.data) return null;
|
||||
return (
|
||||
<Group
|
||||
align="center"
|
||||
h="100%"
|
||||
spacing="sm"
|
||||
>
|
||||
<RiDiscFill />
|
||||
<Text>{data.data.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
fullWidthCellRenderer={FullWidthDiscCell}
|
||||
getRowHeight={getRowHeight}
|
||||
getRowId={(data) => data.data.id}
|
||||
isFullWidthRow={(data) => {
|
||||
@@ -365,36 +358,45 @@ export const AlbumDetailContent = ({ tableRef }: AlbumDetailContentProps) => {
|
||||
ref={cq.ref}
|
||||
mt="5rem"
|
||||
>
|
||||
{carousels.map((carousel, index) => (
|
||||
<GridCarousel
|
||||
key={`carousel-${carousel.uniqueId}-${index}`}
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
]}
|
||||
containerWidth={cq.width}
|
||||
data={carousel.data}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
loading={carousel.loading}
|
||||
pagination={carousel.pagination}
|
||||
uniqueId={carousel.uniqueId}
|
||||
>
|
||||
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
|
||||
</GridCarousel>
|
||||
))}
|
||||
<>
|
||||
{cq.height || cq.width ? (
|
||||
<>
|
||||
{carousels.map((carousel, index) => (
|
||||
<SwiperGridCarousel
|
||||
key={`carousel-${carousel.uniqueId}-${index}`}
|
||||
cardRows={[
|
||||
{
|
||||
property: 'name',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayProperty: 'name',
|
||||
property: 'albumArtists',
|
||||
route: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={carousel.data}
|
||||
isLoading={carousel.loading}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
title={{
|
||||
label: carousel.title,
|
||||
}}
|
||||
uniqueId={carousel.uniqueId}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
</Stack>
|
||||
</ContentContainer>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { forwardRef, Fragment, Ref } from 'react';
|
||||
import { generatePath, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LibraryItem, ServerType } from '/@/renderer/api/types';
|
||||
import { Button, Rating, Text } from '/@/renderer/components';
|
||||
import { 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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -96,26 +98,27 @@ export const AlbumDetailHeader = forwardRef(
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
spacing="sm"
|
||||
spacing="md"
|
||||
sx={{
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{detailQuery?.data?.albumArtists.map((artist) => (
|
||||
<Button
|
||||
<Text
|
||||
key={`artist-${artist.id}`}
|
||||
$link
|
||||
component={Link}
|
||||
size="sm"
|
||||
fw={600}
|
||||
size="md"
|
||||
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||
albumArtistId: artist.id,
|
||||
})}
|
||||
variant="outline"
|
||||
variant="subtle"
|
||||
>
|
||||
{artist.name}
|
||||
</Button>
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,45 +1,22 @@
|
||||
import {
|
||||
ALBUM_CARD_ROWS,
|
||||
getColumnDefs,
|
||||
TablePagination,
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
VirtualInfiniteGridRef,
|
||||
VirtualTable,
|
||||
} from '/@/renderer/components';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { ListDisplayType, CardRow } from '/@/renderer/types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { MutableRefObject, useCallback, useMemo } from 'react';
|
||||
import { ListOnScrollProps } from 'react-window';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Album, AlbumListQuery, AlbumListSort, LibraryItem } from '/@/renderer/api/types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useAlbumListStore,
|
||||
useListStoreActions,
|
||||
useAlbumListFilter,
|
||||
} from '/@/renderer/store';
|
||||
import type { AgGridReact as AgGridReactType } from '@ag-grid-community/react/lib/agGridReact';
|
||||
import {
|
||||
BodyScrollEvent,
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { lazy, MutableRefObject, Suspense } from 'react';
|
||||
import { Spinner } from '/@/renderer/components';
|
||||
import { VirtualInfiniteGridRef } from '/@/renderer/components/virtual-grid';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import { useAlbumListStore } from '/@/renderer/store';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
|
||||
const AlbumListGridView = lazy(() =>
|
||||
import('/@/renderer/features/albums/components/album-list-grid-view').then((module) => ({
|
||||
default: module.AlbumListGridView,
|
||||
})),
|
||||
);
|
||||
|
||||
const AlbumListTableView = lazy(() =>
|
||||
import('/@/renderer/features/albums/components/album-list-table-view').then((module) => ({
|
||||
default: module.AlbumListTableView,
|
||||
})),
|
||||
);
|
||||
|
||||
interface AlbumListContentProps {
|
||||
gridRef: MutableRefObject<VirtualInfiniteGridRef | null>;
|
||||
@@ -48,330 +25,22 @@ interface AlbumListContentProps {
|
||||
}
|
||||
|
||||
export const AlbumListContent = ({ itemCount, gridRef, tableRef }: AlbumListContentProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setTable, setTablePagination, setGrid } = useListStoreActions();
|
||||
const { table, grid, display } = useAlbumListStore({ id, key: pageKey });
|
||||
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
|
||||
|
||||
const onTableReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albumsRes = await queryClient.fetchQuery(
|
||||
queryKey,
|
||||
async ({ signal }) =>
|
||||
api.controller.getAlbumList({
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
const albums = api.normalize.albumList(albumsRes, server);
|
||||
params.successCallback(albums?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
const onTablePaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = table.pagination.currentPage * table.pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setTablePagination({
|
||||
data: {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
},
|
||||
key: pageKey,
|
||||
});
|
||||
},
|
||||
[
|
||||
isPaginationEnabled,
|
||||
setTablePagination,
|
||||
pageKey,
|
||||
table.pagination.currentPage,
|
||||
table.pagination.itemsPerPage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTableColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!table.autoFit && {
|
||||
width: column.getColDef().width,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ data: { columns: updatedColumns }, key: pageKey });
|
||||
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
|
||||
|
||||
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
|
||||
|
||||
const handleTableScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
|
||||
setTable({ data: { scrollOffset }, key: pageKey });
|
||||
};
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
jfParams: {
|
||||
...filter.jfParams,
|
||||
},
|
||||
ndParams: {
|
||||
...filter.ndParams,
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
query,
|
||||
server,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
|
||||
return api.normalize.albumList(albums, server);
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
|
||||
},
|
||||
[pageKey, setGrid],
|
||||
);
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case AlbumListSort.ALBUM_ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.artists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.COMMUNITY_RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
break;
|
||||
case AlbumListSort.DURATION:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.duration);
|
||||
break;
|
||||
case AlbumListSort.FAVORITED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.NAME:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.PLAY_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.playCount);
|
||||
break;
|
||||
case AlbumListSort.RANDOM:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.rating);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_ADDED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.createdAt);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_PLAYED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.lastPlayedAt);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.songCount);
|
||||
break;
|
||||
case AlbumListSort.YEAR:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RELEASE_DATE:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseDate);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.ALBUM, ALBUM_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
const { display } = useAlbumListStore({ id, key: pageKey });
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<>
|
||||
<VirtualInfiniteGrid
|
||||
key={`album-list-${server?.id}-${display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
itemCount={itemCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : (
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${display}-${table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
suppressRowDrag
|
||||
autoFitColumns={table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleTableColumnChange}
|
||||
onColumnResized={debouncedTableColumnChange}
|
||||
onGridReady={onTableReady}
|
||||
onPaginationChanged={onTablePaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
)}
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey={pageKey}
|
||||
pagination={table.pagination}
|
||||
setPagination={setTablePagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
{display === ListDisplayType.CARD || display === ListDisplayType.POSTER ? (
|
||||
<AlbumListGridView
|
||||
gridRef={gridRef}
|
||||
itemCount={itemCount}
|
||||
/>
|
||||
) : (
|
||||
<AlbumListTableView
|
||||
itemCount={itemCount}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { ListOnScrollProps } from 'react-window';
|
||||
import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { Album, AlbumListQuery, AlbumListSort, LibraryItem } from '/@/renderer/api/types';
|
||||
import { ALBUM_CARD_ROWS } from '/@/renderer/components';
|
||||
import {
|
||||
VirtualGridAutoSizerContainer,
|
||||
VirtualInfiniteGrid,
|
||||
} from '/@/renderer/components/virtual-grid';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import { usePlayQueueAdd } from '/@/renderer/features/player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useAlbumListFilter,
|
||||
useAlbumListStore,
|
||||
useCurrentServer,
|
||||
useListStoreActions,
|
||||
} from '/@/renderer/store';
|
||||
import { CardRow, ListDisplayType } from '/@/renderer/types';
|
||||
import { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
|
||||
export const AlbumListGridView = ({ gridRef, itemCount }: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
const server = useCurrentServer();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const { grid, display } = useAlbumListStore({ id, key: pageKey });
|
||||
const { setGrid } = useListStoreActions();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = (options: {
|
||||
id: string[];
|
||||
isFavorite: boolean;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const { id, itemType, isFavorite } = options;
|
||||
if (isFavorite) {
|
||||
deleteFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
query: {
|
||||
id,
|
||||
type: itemType,
|
||||
},
|
||||
serverId: server?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cardRows = useMemo(() => {
|
||||
const rows: CardRow<Album>[] = [ALBUM_CARD_ROWS.name];
|
||||
|
||||
switch (filter.sortBy) {
|
||||
case AlbumListSort.ALBUM_ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.ARTIST:
|
||||
rows.push(ALBUM_CARD_ROWS.artists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.COMMUNITY_RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
break;
|
||||
case AlbumListSort.DURATION:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.duration);
|
||||
break;
|
||||
case AlbumListSort.FAVORITED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.NAME:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.PLAY_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.playCount);
|
||||
break;
|
||||
case AlbumListSort.RANDOM:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RATING:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.rating);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_ADDED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.createdAt);
|
||||
break;
|
||||
case AlbumListSort.RECENTLY_PLAYED:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.lastPlayedAt);
|
||||
break;
|
||||
case AlbumListSort.SONG_COUNT:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.songCount);
|
||||
break;
|
||||
case AlbumListSort.YEAR:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseYear);
|
||||
break;
|
||||
case AlbumListSort.RELEASE_DATE:
|
||||
rows.push(ALBUM_CARD_ROWS.albumArtists);
|
||||
rows.push(ALBUM_CARD_ROWS.releaseDate);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [filter.sortBy]);
|
||||
|
||||
const handleGridScroll = useCallback(
|
||||
(e: ListOnScrollProps) => {
|
||||
setGrid({ data: { scrollOffset: e.scrollOffset }, key: pageKey });
|
||||
},
|
||||
[pageKey, setGrid],
|
||||
);
|
||||
|
||||
const fetch = useCallback(
|
||||
async ({ skip, take }: { skip: number; take: number }) => {
|
||||
if (!server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit: take,
|
||||
startIndex: skip,
|
||||
...filter,
|
||||
_custom: {
|
||||
jellyfin: {
|
||||
...filter._custom?.jellyfin,
|
||||
},
|
||||
navidrome: {
|
||||
...filter._custom?.navidrome,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const queryKey = queryKeys.albums.list(server?.id || '', query);
|
||||
|
||||
const albums = await queryClient.fetchQuery(queryKey, async ({ signal }) =>
|
||||
controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query,
|
||||
}),
|
||||
);
|
||||
|
||||
return albums;
|
||||
},
|
||||
[filter, queryClient, server],
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualInfiniteGrid
|
||||
key={`album-list-${server?.id}-${display}`}
|
||||
ref={gridRef}
|
||||
cardRows={cardRows}
|
||||
display={display || ListDisplayType.CARD}
|
||||
fetchFn={fetch}
|
||||
handleFavorite={handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
height={height}
|
||||
initialScrollOffset={grid?.scrollOffset || 0}
|
||||
itemCount={itemCount || 0}
|
||||
itemGap={20}
|
||||
itemSize={grid?.itemsPerRow || 5}
|
||||
itemType={LibraryItem.ALBUM}
|
||||
loading={itemCount === undefined || itemCount === null}
|
||||
minimumBatchSize={40}
|
||||
route={{
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
}}
|
||||
width={width}
|
||||
onScroll={handleGridScroll}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -251,6 +253,7 @@ export const AlbumListHeaderFilters = ({
|
||||
sortBy: e.currentTarget.value as AlbumListSort,
|
||||
sortOrder: sortOrder || SortOrder.ASC,
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
|
||||
@@ -267,11 +270,13 @@ export const AlbumListHeaderFilters = ({
|
||||
if (e.currentTarget.value === String(filter.musicFolderId)) {
|
||||
updatedFilters = setFilter({
|
||||
data: { musicFolderId: undefined },
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
} else {
|
||||
updatedFilters = setFilter({
|
||||
data: { musicFolderId: e.currentTarget.value },
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
}
|
||||
@@ -285,6 +290,7 @@ export const AlbumListHeaderFilters = ({
|
||||
const newSortOrder = filter.sortOrder === SortOrder.ASC ? SortOrder.DESC : SortOrder.ASC;
|
||||
const updatedFilters = setFilter({
|
||||
data: { sortOrder: newSortOrder },
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -292,38 +298,40 @@ export const AlbumListHeaderFilters = ({
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
if (!itemCount || itemCount === 0) return;
|
||||
const handlePlay = async (playType: Play) => {
|
||||
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: {
|
||||
id: albumIds,
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
play,
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -382,16 +390,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 +464,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,
|
||||
};
|
||||
@@ -150,45 +158,51 @@ export const AlbumListHeader = ({
|
||||
const handleSearch = debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const previousSearchTerm = filter.searchTerm;
|
||||
const searchTerm = e.target.value === '' ? undefined : e.target.value;
|
||||
const updatedFilters = setFilter({ data: { searchTerm }, key: 'album' }) as AlbumListFilter;
|
||||
const updatedFilters = setFilter({
|
||||
data: { searchTerm },
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
if (previousSearchTerm !== searchTerm) handleFilterChange(updatedFilters);
|
||||
}, 500);
|
||||
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (play: Play) => {
|
||||
const handlePlay = async (playType: Play) => {
|
||||
if (!itemCount || itemCount === 0) 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((item) => item.id) || [];
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byItemType: {
|
||||
id: albumIds,
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
play,
|
||||
playType,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import {
|
||||
ColDef,
|
||||
GridReadyEvent,
|
||||
IDatasource,
|
||||
PaginationChangedEvent,
|
||||
BodyScrollEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from '@ag-grid-community/core';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { AlbumListQuery, LibraryItem } from '/@/renderer/api/types';
|
||||
import { getColumnDefs, TablePagination, VirtualTable } from '/@/renderer/components/virtual-table';
|
||||
import { useAlbumListContext } from '/@/renderer/features/albums/context/album-list-context';
|
||||
import {
|
||||
useCurrentServer,
|
||||
useAlbumListFilter,
|
||||
useListStoreActions,
|
||||
useAlbumListStore,
|
||||
} from '/@/renderer/store';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import { useHandleTableContextMenu } from '/@/renderer/features/context-menu';
|
||||
import { ALBUM_CONTEXT_MENU_ITEMS } from '/@/renderer/features/context-menu/context-menu-items';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { VirtualGridAutoSizerContainer } from '/@/renderer/components/virtual-grid';
|
||||
|
||||
export const AlbumListTableView = ({ tableRef, itemCount }: any) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const server = useCurrentServer();
|
||||
const { id, pageKey } = useAlbumListContext();
|
||||
const filter = useAlbumListFilter({ id, key: pageKey });
|
||||
const { setTable, setTablePagination } = useListStoreActions();
|
||||
const { table, display } = useAlbumListStore({ id, key: pageKey });
|
||||
const columnDefs: ColDef[] = useMemo(() => getColumnDefs(table.columns), [table.columns]);
|
||||
const isPaginationEnabled = display === ListDisplayType.TABLE_PAGINATED;
|
||||
|
||||
const onTableReady = useCallback(
|
||||
(params: GridReadyEvent) => {
|
||||
const dataSource: IDatasource = {
|
||||
getRows: async (params) => {
|
||||
const limit = params.endRow - params.startRow;
|
||||
const startIndex = params.startRow;
|
||||
|
||||
const query: AlbumListQuery = {
|
||||
limit,
|
||||
startIndex,
|
||||
...filter,
|
||||
_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,
|
||||
}),
|
||||
{ cacheTime: 1000 * 60 * 1 },
|
||||
);
|
||||
|
||||
return params.successCallback(albumsRes?.items || [], albumsRes?.totalRecordCount || 0);
|
||||
},
|
||||
rowCount: undefined,
|
||||
};
|
||||
params.api.setDatasource(dataSource);
|
||||
params.api.ensureIndexVisible(table.scrollOffset || 0, 'top');
|
||||
},
|
||||
[filter, queryClient, server, table.scrollOffset],
|
||||
);
|
||||
|
||||
const onTablePaginationChanged = useCallback(
|
||||
(event: PaginationChangedEvent) => {
|
||||
if (!isPaginationEnabled || !event.api) return;
|
||||
|
||||
try {
|
||||
// Scroll to top of page on pagination change
|
||||
const currentPageStartIndex = table.pagination.currentPage * table.pagination.itemsPerPage;
|
||||
event.api?.ensureIndexVisible(currentPageStartIndex, 'top');
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
setTablePagination({
|
||||
data: {
|
||||
itemsPerPage: event.api.paginationGetPageSize(),
|
||||
totalItems: event.api.paginationGetRowCount(),
|
||||
totalPages: event.api.paginationGetTotalPages() + 1,
|
||||
},
|
||||
key: pageKey,
|
||||
});
|
||||
},
|
||||
[
|
||||
isPaginationEnabled,
|
||||
setTablePagination,
|
||||
pageKey,
|
||||
table.pagination.currentPage,
|
||||
table.pagination.itemsPerPage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTableColumnChange = useCallback(() => {
|
||||
const { columnApi } = tableRef?.current || {};
|
||||
const columnsOrder = columnApi?.getAllGridColumns();
|
||||
|
||||
if (!columnsOrder) return;
|
||||
|
||||
const columnsInSettings = table.columns;
|
||||
const updatedColumns = [];
|
||||
for (const column of columnsOrder) {
|
||||
const columnInSettings = columnsInSettings.find((c) => c.column === column.getColDef().colId);
|
||||
|
||||
if (columnInSettings) {
|
||||
updatedColumns.push({
|
||||
...columnInSettings,
|
||||
...(!table.autoFit && {
|
||||
width: column.getColDef().width,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTable({ data: { columns: updatedColumns }, key: pageKey });
|
||||
}, [tableRef, table.columns, table.autoFit, setTable, pageKey]);
|
||||
|
||||
const debouncedTableColumnChange = debounce(handleTableColumnChange, 200);
|
||||
|
||||
const handleTableScroll = (e: BodyScrollEvent) => {
|
||||
const scrollOffset = Number((e.top / table.rowHeight).toFixed(0));
|
||||
setTable({ data: { scrollOffset }, key: pageKey });
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.ALBUM, ALBUM_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent) => {
|
||||
navigate(generatePath(AppRoute.LIBRARY_ALBUMS_DETAIL, { albumId: e.data.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualGridAutoSizerContainer>
|
||||
<VirtualTable
|
||||
// https://github.com/ag-grid/ag-grid/issues/5284
|
||||
// Key is used to force remount of table when display, rowHeight, or server changes
|
||||
key={`table-${display}-${table.rowHeight}-${server?.id}`}
|
||||
ref={tableRef}
|
||||
alwaysShowHorizontalScroll
|
||||
suppressRowDrag
|
||||
autoFitColumns={table.autoFit}
|
||||
blockLoadDebounceMillis={200}
|
||||
columnDefs={columnDefs}
|
||||
getRowId={(data) => data.data.id}
|
||||
infiniteInitialRowCount={itemCount || 100}
|
||||
pagination={isPaginationEnabled}
|
||||
paginationAutoPageSize={isPaginationEnabled}
|
||||
paginationPageSize={table.pagination.itemsPerPage || 100}
|
||||
rowBuffer={20}
|
||||
rowHeight={table.rowHeight || 40}
|
||||
rowModelType="infinite"
|
||||
onBodyScrollEnd={handleTableScroll}
|
||||
onCellContextMenu={handleContextMenu}
|
||||
onColumnMoved={handleTableColumnChange}
|
||||
onColumnResized={debouncedTableColumnChange}
|
||||
onGridReady={onTableReady}
|
||||
onPaginationChanged={onTablePaginationChanged}
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</VirtualGridAutoSizerContainer>
|
||||
{isPaginationEnabled && (
|
||||
<AnimatePresence
|
||||
presenceAffectsLayout
|
||||
initial={false}
|
||||
mode="wait"
|
||||
>
|
||||
{display === ListDisplayType.TABLE_PAGINATED && (
|
||||
<TablePagination
|
||||
pageKey={pageKey}
|
||||
pagination={table.pagination}
|
||||
setPagination={setTablePagination}
|
||||
tableRef={tableRef}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { MultiSelect, NumberInput, SpinnerIcon, Switch, Text } from '/@/renderer
|
||||
import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/renderer/store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
|
||||
interface JellyfinAlbumFiltersProps {
|
||||
@@ -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,20 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.jfParams?.isFavorite,
|
||||
value: filter._custom?.jellyfin?.IsFavorite,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -62,11 +67,15 @@ 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -76,11 +85,15 @@ 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -90,11 +103,15 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -102,17 +119,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,11 +145,15 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -155,21 +177,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 +211,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}
|
||||
|
||||
@@ -5,13 +5,14 @@ import { AlbumListFilter, useAlbumListFilter, useListStoreActions } from '/@/ren
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useGenreList } from '/@/renderer/features/genres';
|
||||
import { useAlbumArtistList } from '/@/renderer/features/artists/queries/album-artist-list-query';
|
||||
import { AlbumArtistListSort, SortOrder } from '/@/renderer/api/types';
|
||||
import { AlbumArtistListSort, LibraryItem, SortOrder } from '/@/renderer/api/types';
|
||||
|
||||
interface NavidromeAlbumFiltersProps {
|
||||
disableArtistFilter?: boolean;
|
||||
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,11 +38,15 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: 'album',
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -52,72 +58,95 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
},
|
||||
value: filter.ndParams?.has_rating,
|
||||
value: filter._custom?.navidrome?.has_rating,
|
||||
},
|
||||
{
|
||||
label: 'Is favorited',
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
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,
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -125,18 +154,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,11 +180,15 @@ 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
itemType: LibraryItem.ALBUM,
|
||||
key: pageKey,
|
||||
}) as AlbumListFilter;
|
||||
handleFilterChange(updatedFilters);
|
||||
@@ -177,7 +211,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 +222,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 +232,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,40 +1,61 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '/@/renderer/api';
|
||||
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, AlbumListResponse } 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,
|
||||
});
|
||||
};
|
||||
|
||||
// export const useAlbumListInfinite = (params: AlbumListParams, options?: QueryOptions) => {
|
||||
// const serverId = useAuthStore((state) => state.currentServer?.id) || '';
|
||||
export const useAlbumListInfinite = (args: QueryHookArgs<AlbumListQuery>) => {
|
||||
const { options, query, serverId } = args;
|
||||
const server = getServerById(serverId);
|
||||
|
||||
// return useInfiniteQuery({
|
||||
// enabled: !!serverId,
|
||||
// getNextPageParam: (lastPage: AlbumListResponse) => {
|
||||
// return !!lastPage.pagination.nextPage;
|
||||
// },
|
||||
// getPreviousPageParam: (firstPage: AlbumListResponse) => {
|
||||
// return !!firstPage.pagination.prevPage;
|
||||
// },
|
||||
// queryFn: ({ pageParam }) => api.albums.getAlbumList({ serverId }, { ...(pageParam || params) }),
|
||||
// queryKey: queryKeys.albums.list(serverId, params),
|
||||
// ...options,
|
||||
// });
|
||||
// };
|
||||
return useInfiniteQuery({
|
||||
enabled: !!serverId,
|
||||
getNextPageParam: (lastPage: AlbumListResponse | undefined, pages) => {
|
||||
if (!lastPage?.items) return undefined;
|
||||
if (lastPage?.items?.length >= (query?.limit || 50)) {
|
||||
return pages?.length;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
queryFn: ({ pageParam = 0, signal }) => {
|
||||
if (!server) throw new Error('Server not found');
|
||||
return api.controller.getAlbumList({
|
||||
apiClientProps: {
|
||||
server,
|
||||
signal,
|
||||
},
|
||||
query: {
|
||||
...query,
|
||||
limit: query.limit || 50,
|
||||
startIndex: pageParam * (query.limit || 50),
|
||||
},
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.albums.list(server?.id || '', 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();
|
||||
@@ -28,7 +30,7 @@ const AlbumDetailRoute = () => {
|
||||
id: [albumId],
|
||||
type: LibraryItem.ALBUM,
|
||||
},
|
||||
play: playButtonBehavior,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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, 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';
|
||||
@@ -21,7 +14,7 @@ import {
|
||||
useHandleGeneralContextMenu,
|
||||
useHandleTableContextMenu,
|
||||
} from '/@/renderer/features/context-menu';
|
||||
import { Play, TableColumn } from '/@/renderer/types';
|
||||
import { CardRow, Play, TableColumn } from '/@/renderer/types';
|
||||
import {
|
||||
ARTIST_CONTEXT_MENU_ITEMS,
|
||||
SONG_CONTEXT_MENU_ITEMS,
|
||||
@@ -29,6 +22,8 @@ import {
|
||||
import { PlayButton, useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||
import { useAlbumList } from '/@/renderer/features/albums/queries/album-list-query';
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AlbumListSort,
|
||||
LibraryItem,
|
||||
QueueSong,
|
||||
@@ -38,6 +33,8 @@ 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';
|
||||
import { SwiperGridCarousel } from '/@/renderer/components/grid-carousel';
|
||||
|
||||
const ContentContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -61,9 +58,8 @@ export const AlbumArtistDetailContent = () => {
|
||||
const cq = useContainerQuery();
|
||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||
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 +76,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: 10,
|
||||
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: 10,
|
||||
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(
|
||||
() =>
|
||||
@@ -123,7 +142,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
[],
|
||||
);
|
||||
|
||||
const cardRows = {
|
||||
const cardRows: Record<string, CardRow<Album>[] | CardRow<AlbumArtist>[]> = {
|
||||
album: [
|
||||
{
|
||||
property: 'name',
|
||||
@@ -152,17 +171,25 @@ export const AlbumArtistDetailContent = () => {
|
||||
],
|
||||
};
|
||||
|
||||
const cardRoutes = {
|
||||
album: {
|
||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||
},
|
||||
albumArtist: {
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||
},
|
||||
};
|
||||
|
||||
const carousels = [
|
||||
{
|
||||
data: recentAlbumsQuery?.data?.items,
|
||||
isHidden: !recentAlbumsQuery?.data?.items?.length,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
loading: recentAlbumsQuery?.isLoading || recentAlbumsQuery.isFetching,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<>
|
||||
<Group align="flex-end">
|
||||
<TextTitle
|
||||
order={2}
|
||||
weight={700}
|
||||
@@ -178,7 +205,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
>
|
||||
View discography
|
||||
</Button>
|
||||
</>
|
||||
</Group>
|
||||
),
|
||||
uniqueId: 'recentReleases',
|
||||
},
|
||||
@@ -187,9 +214,6 @@ export const AlbumArtistDetailContent = () => {
|
||||
isHidden: !compilationAlbumsQuery?.data?.items?.length,
|
||||
itemType: LibraryItem.ALBUM,
|
||||
loading: compilationAlbumsQuery?.isLoading || compilationAlbumsQuery.isFetching,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
order={2}
|
||||
@@ -201,13 +225,10 @@ export const AlbumArtistDetailContent = () => {
|
||||
uniqueId: 'compilationAlbums',
|
||||
},
|
||||
{
|
||||
data: detailQuery?.data?.similarArtists?.slice(0, itemsPerPage),
|
||||
data: detailQuery?.data?.similarArtists || [],
|
||||
isHidden: !detailQuery?.data?.similarArtists,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
loading: detailQuery?.isLoading || detailQuery.isFetching,
|
||||
pagination: {
|
||||
itemsPerPage,
|
||||
},
|
||||
title: (
|
||||
<TextTitle
|
||||
order={2}
|
||||
@@ -228,22 +249,24 @@ export const AlbumArtistDetailContent = () => {
|
||||
id: [albumArtistId],
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
},
|
||||
play: playType || playButtonBehavior,
|
||||
playType: playType || playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleTableContextMenu(LibraryItem.SONG, SONG_CONTEXT_MENU_ITEMS);
|
||||
|
||||
const handleRowDoubleClick = (e: RowDoubleClickedEvent<QueueSong>) => {
|
||||
if (!e.data) return;
|
||||
if (!e.data || !topSongsQuery?.data) return;
|
||||
|
||||
handlePlayQueueAdd?.({
|
||||
byData: [e.data],
|
||||
play: playButtonBehavior,
|
||||
byData: topSongsQuery?.data?.items || [],
|
||||
initialSongId: e.data.id,
|
||||
playType: playButtonBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
const createFavoriteMutation = useCreateFavorite();
|
||||
const deleteFavoriteMutation = useDeleteFavorite();
|
||||
const createFavoriteMutation = useCreateFavorite({});
|
||||
const deleteFavoriteMutation = useDeleteFavorite({});
|
||||
|
||||
const handleFavorite = () => {
|
||||
if (!detailQuery?.data) return;
|
||||
@@ -254,6 +277,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
} else {
|
||||
createFavoriteMutation.mutate({
|
||||
@@ -261,6 +285,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
id: [detailQuery.data.id],
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
},
|
||||
serverId: detailQuery.data.serverId,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -334,7 +359,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
{showGenres && (
|
||||
{showGenres ? (
|
||||
<Box component="section">
|
||||
<Group spacing="sm">
|
||||
{detailQuery?.data?.genres?.map((genre) => (
|
||||
@@ -354,7 +379,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
{showBiography ? (
|
||||
<Box
|
||||
component="section"
|
||||
@@ -374,7 +399,7 @@ export const AlbumArtistDetailContent = () => {
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
{showTopSongs && (
|
||||
{showTopSongs ? (
|
||||
<Box component="section">
|
||||
<Group
|
||||
noWrap
|
||||
@@ -421,24 +446,29 @@ export const AlbumArtistDetailContent = () => {
|
||||
onRowDoubleClicked={handleRowDoubleClick}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
<Box component="section">
|
||||
<Stack spacing="xl">
|
||||
{carousels
|
||||
.filter((c) => !c.isHidden)
|
||||
.map((carousel) => (
|
||||
<GridCarousel
|
||||
<SwiperGridCarousel
|
||||
key={`carousel-${carousel.uniqueId}`}
|
||||
cardRows={cardRows[carousel.itemType as keyof typeof cardRows]}
|
||||
containerWidth={cq.width}
|
||||
data={carousel.data}
|
||||
isLoading={carousel.loading}
|
||||
itemType={carousel.itemType}
|
||||
loading={carousel.loading}
|
||||
pagination={carousel.pagination}
|
||||
route={cardRoutes[carousel.itemType as keyof typeof cardRoutes]}
|
||||
swiperProps={{
|
||||
grid: {
|
||||
rows: 2,
|
||||
},
|
||||
}}
|
||||
title={{
|
||||
label: carousel.title,
|
||||
}}
|
||||
uniqueId={carousel.uniqueId}
|
||||
>
|
||||
<GridCarousel.Title>{carousel.title}</GridCarousel.Title>
|
||||
</GridCarousel>
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user