Compare commits
355 Commits
v0.0.1-alpha5
...
v0.2.0
| 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 | |||
| a614a6ff9c | |||
| 08a1cb1ae9 | |||
| 75e93b8ea2 | |||
| 5d28abae91 | |||
| c483cdb871 | |||
| fe14ca25c6 | |||
| 0965efab21 | |||
| 598b627bb4 | |||
| b8614495f6 | |||
| e02643123c | |||
| 5d8cad06d7 | |||
| 2b8a97b8c2 | |||
| 44cd1b33bf | |||
| 90b503906f | |||
| 77bfb916ba | |||
| f35152a169 | |||
| 0d9224bc09 | |||
| cf4f80c82b | |||
| fa717b9bca | |||
| 8e2dce34f0 | |||
| dce6fc7e7d | |||
| 781e3c3c4d | |||
| 0d4d5b5de0 | |||
| 293d8ec584 | |||
| 6ccef6e515 | |||
| 5f7b005626 | |||
| b590636303 | |||
| a17e0adf44 | |||
| 2b1c1d5e59 | |||
| eecbcddea3 | |||
| 373441e4c6 | |||
| dbcda5b634 | |||
| bc5f1f13f0 | |||
| 0c13b09029 | |||
| 4ffc544e87 | |||
| cf00992d71 | |||
| 3848e9840d | |||
| 930bbb33fd | |||
| 4332a9ea3a | |||
| ccfe0bfd9d | |||
| f5fc56eee1 | |||
| cd6bf25011 | |||
| 335287d119 | |||
| 50af8f4d3a | |||
| 58c7370536 | |||
| ececc394e2 | |||
| 219a9ed613 | |||
| e47fcfc62e | |||
| 6cfdb8ff84 | |||
| ef4cdfa028 | |||
| 1eed26abab | |||
| a2851dd700 | |||
| 563db1138e | |||
| 84587da701 | |||
| f0a836fc1f | |||
| 5539e2cd4e | |||
| 30b013dfa5 | |||
| 8343f4f80b | |||
| e8dcba0456 | |||
| 27cbc23d87 | |||
| 275d68ec5b | |||
| 7f9de4b180 | |||
| 231f10cbe2 | |||
| b4664f45b4 | |||
| 3153cdd6c4 | |||
| 69292a083d | |||
| 123478a24f | |||
| 828cca9c19 | |||
| f7740407c3 | |||
| 157ac9f3a2 | |||
| f21c9010ac | |||
| 7c045e5b23 | |||
| ae292e3a5f | |||
| 918b77eebb | |||
| 661751f306 | |||
| 2260caba00 | |||
| 3fe0873dc1 | |||
| 7c6ec73617 | |||
| 76dcd1c28e | |||
| 4fb1f4d2cb | |||
| 92039b95c3 | |||
| c0a703be7a | |||
| f08538cbfb | |||
| 1fa975ccec | |||
| ac62c26099 | |||
| 7ae3d9d99a | |||
| a9cfcaeda6 | |||
| 3d8b25922e | |||
| a9089859ce | |||
| c878e36015 |
@@ -1 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
ko_fi: jeffvli
|
||||
|
||||
@@ -4,18 +4,6 @@ about: You're having technical issues. 🐞
|
||||
labels: 'bug'
|
||||
---
|
||||
|
||||
<!-- Please use the following issue template or your issue will be closed -->
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<!-- If the following boxes are not ALL checked, your issue is likely to be closed -->
|
||||
|
||||
- [ ] Using npm
|
||||
- [ ] Using an up-to-date [`main` branch](https://github.com/electron-react-boilerplate/electron-react-boilerplate/tree/main)
|
||||
- [ ] Using latest version of devtools. [Check the docs for how to update](https://electron-react-boilerplate.js.org/docs/dev-tools/)
|
||||
- [ ] Tried solutions mentioned in [#400](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/400)
|
||||
- [ ] For issue in production release, add devtools output of `DEBUG_PROD=true npm run build && npm start`
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!--- What should have happened? -->
|
||||
@@ -23,6 +11,8 @@ labels: 'bug'
|
||||
## Current Behavior
|
||||
|
||||
<!--- What went wrong? -->
|
||||
<!-- Add screenshots to help explain your problem -->
|
||||
<!-- (Open the browser dev tools in the menu or using CTRL + SHIFT + I) -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
@@ -44,24 +34,12 @@ labels: 'bug'
|
||||
## Context
|
||||
|
||||
<!--- How has this issue affected you? What are you trying to accomplish? -->
|
||||
<!--- Did you make any changes to the boilerplate after cloning it? -->
|
||||
<!--- Providing context helps us come up with a solution that is most useful in the real world -->
|
||||
|
||||
## Your Environment
|
||||
|
||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||
|
||||
- Node version :
|
||||
- electron-react-boilerplate version or branch :
|
||||
- Operating System and version :
|
||||
- Link to your project :
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
Donations will ensure the following:
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
- 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) :
|
||||
|
||||
@@ -4,16 +4,6 @@ about: Ask a question.❓
|
||||
labels: 'question'
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What do you need help with? -->
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
Donations will ensure the following:
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
<!-- Question issues will be closed. -->
|
||||
<!-- Ask questions in the discussions tab: Please use discussions https://github.com/jeffvli/feishin/discussions -->
|
||||
<!-- Or join the Discord/Matrix servers: https://discord.gg/FVKpcMDy5f https://matrix.to/#/#sonixd:matrix.org -->
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: You want something added to the boilerplate. 🎉
|
||||
about: Request a feature to be added to Feishin 🎉
|
||||
labels: 'enhancement'
|
||||
---
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
## What do you want to be added?
|
||||
|
||||
Donations will ensure the following:
|
||||
## Additional context
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Publish Linux (Manual)
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Install Node and NPM
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
- name: Publish releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v2.8.2
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_on: error
|
||||
command: |
|
||||
npm run postinstall
|
||||
npm run build
|
||||
npm exec electron-builder -- --publish always --linux
|
||||
on_retry_command: npm cache clean --force
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish (Manual)
|
||||
name: Publish Windows and macOS (Manual)
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
command: |
|
||||
npm run postinstall
|
||||
npm run build
|
||||
npm exec electron-builder -- --publish always --win --mac --linux
|
||||
npm exec electron-builder -- --publish always --win --mac
|
||||
on_retry_command: npm cache clean --force
|
||||
@@ -25,29 +25,46 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Repository for the rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
|
||||
## Features
|
||||
|
||||
- [x] MPV player backend
|
||||
- [x] Web player backend
|
||||
- [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_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).
|
||||
|
||||
### After installing the server and database
|
||||
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.
|
||||
|
||||
You can access the desktop client via the [latest release](https://github.com/jeffvli/feishin/releases), or you can visit the web client at your server URL (e.g http://192.168.0.1:8643).
|
||||
### 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?
|
||||
|
||||
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API.
|
||||
Feishin supports any music server that implements a [Navidrome](https://www.navidrome.org/) or [Jellyfin](https://jellyfin.org/) API. **Subsonic API is not currently supported**. This will likely be added in [later when the new Subsonic API is decided on](https://support.symfonium.app/t/subsonic-servers-participation/1233).
|
||||
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- ~~[Airsonic](https://github.com/airsonic/airsonic)~~
|
||||
- ~~[Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)~~
|
||||
- ~~[Gonic](https://github.com/sentriz/gonic)~~
|
||||
- ~~[Astiga](https://asti.ga/)~~
|
||||
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- [Funkwhale](https://funkwhale.audio/) - TBD
|
||||
- Subsonic-compatible servers - TBD
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 896 B |
|
After Width: | Height: | Size: 971 B |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 524 B |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 887 KiB |
|
After Width: | Height: | Size: 396 KiB |
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.0.1-alpha5",
|
||||
"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",
|
||||
@@ -15,7 +15,7 @@
|
||||
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
||||
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
|
||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
||||
"start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
|
||||
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
|
||||
"start:web": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.web.ts",
|
||||
@@ -51,6 +51,7 @@
|
||||
"package.json"
|
||||
],
|
||||
"afterSign": ".erb/scripts/notarize.js",
|
||||
"electronVersion": "22.3.1",
|
||||
"mac": {
|
||||
"target": {
|
||||
"target": "default",
|
||||
@@ -158,6 +159,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.2.10",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
|
||||
"@stylelint/postcss-css-in-js": "^0.38.0",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
||||
@@ -188,11 +190,10 @@
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"detect-port": "^1.3.0",
|
||||
"electron": "^21.2.0",
|
||||
"electron-builder": "^23.0.3",
|
||||
"electron": "^22.3.1",
|
||||
"electron-builder": "^24.0.0-alpha.13",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-notarize": "^1.2.1",
|
||||
"electron-rebuild": "^3.2.7",
|
||||
"electronmon": "^2.0.2",
|
||||
"eslint": "^8.30.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
@@ -253,52 +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-alpha.5",
|
||||
"@mantine/dates": "^6.0.0-alpha.5",
|
||||
"@mantine/dropzone": "^6.0.0-alpha.5",
|
||||
"@mantine/form": "^6.0.0-alpha.5",
|
||||
"@mantine/hooks": "^6.0.0-alpha.5",
|
||||
"@mantine/modals": "^6.0.0-alpha.5",
|
||||
"@mantine/notifications": "^6.0.0-alpha.5",
|
||||
"@mantine/utils": "^6.0.0-alpha.5",
|
||||
"@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",
|
||||
"mpris-service": "^2.1.2",
|
||||
"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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.0.1-alpha5",
|
||||
"version": "0.2.0",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -13,7 +13,11 @@
|
||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "22.3.1"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
}
|
||||
|
||||
@@ -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,73 +1,179 @@
|
||||
import console from 'console';
|
||||
import { ipcMain } from 'electron';
|
||||
import { mpv } from '../../../main';
|
||||
import { getMainWindow, getMpvInstance } from '../../../main';
|
||||
import { PlayerData } from '/@/renderer/store';
|
||||
|
||||
declare module 'node-mpv';
|
||||
|
||||
function wait(timeout: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve('resolved');
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
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()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to play', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Starts the player
|
||||
ipcMain.on('player-play', async () => {
|
||||
await mpv.play();
|
||||
await getMpvInstance()
|
||||
?.play()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to play', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Pauses the player
|
||||
ipcMain.on('player-pause', async () => {
|
||||
await mpv.pause();
|
||||
await getMpvInstance()
|
||||
?.pause()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to pause', err);
|
||||
});
|
||||
});
|
||||
|
||||
// Stops the player
|
||||
ipcMain.on('player-stop', async () => {
|
||||
await mpv.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 mpv.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 mpv.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 mpv.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 mpv.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 mpv.clearPlaylist();
|
||||
await mpv.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;
|
||||
}
|
||||
|
||||
if (data.queue.current) {
|
||||
await mpv.load(data.queue.current.streamUrl, 'replace');
|
||||
let complete = false;
|
||||
let tryAttempts = 0;
|
||||
|
||||
while (!complete) {
|
||||
if (tryAttempts > 3) {
|
||||
getMainWindow()?.webContents.send('renderer-player-error', 'Failed to load song');
|
||||
complete = true;
|
||||
} 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 (data.queue.next) {
|
||||
await mpv.load(data.queue.next.streamUrl, 'append');
|
||||
if (pause) {
|
||||
await getMpvInstance()?.pause();
|
||||
}
|
||||
|
||||
await mpv.play();
|
||||
});
|
||||
|
||||
// Replaces the queue in position 1 to the given data
|
||||
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
||||
const size = await mpv.getPlaylistSize();
|
||||
const size = await getMpvInstance()
|
||||
?.getPlaylistSize()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to get playlist size', err);
|
||||
});
|
||||
|
||||
if (!size) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (size > 1) {
|
||||
await mpv.playlistRemove(1);
|
||||
await getMpvInstance()
|
||||
?.playlistRemove(1)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to remove song from playlist', err);
|
||||
});
|
||||
}
|
||||
|
||||
if (data.queue.next) {
|
||||
await mpv.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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,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 mpv.playlistRemove(0);
|
||||
await getMpvInstance()
|
||||
?.playlistRemove(0)
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to remove song from playlist', err);
|
||||
});
|
||||
|
||||
if (data.queue.next) {
|
||||
await mpv.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 mpv.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 mpv.mute();
|
||||
await getMpvInstance()
|
||||
?.mute()
|
||||
.catch((err) => {
|
||||
console.log('MPV failed to toggle mute', err);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on('player-quit', async () => {
|
||||
await mpv.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;
|
||||
|
||||
@@ -8,8 +8,20 @@
|
||||
* 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 { app, BrowserWindow, shell, ipcMain, globalShortcut } from 'electron';
|
||||
import { access, constants, readFile, writeFile } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import { deflate, inflate } from 'zlib';
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
shell,
|
||||
ipcMain,
|
||||
globalShortcut,
|
||||
Tray,
|
||||
Menu,
|
||||
nativeImage,
|
||||
BrowserWindowConstructorOptions,
|
||||
} from 'electron';
|
||||
import electronLocalShortcut from 'electron-localshortcut';
|
||||
import log from 'electron-log';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
@@ -18,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 { resolveHtmlPath } from './utils';
|
||||
import { hotkeyToElectronAccelerator, isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
||||
import './features';
|
||||
|
||||
declare module 'node-mpv';
|
||||
@@ -31,7 +43,18 @@ export default class AppUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (error: any) => {
|
||||
console.log('Error in main process', error);
|
||||
});
|
||||
|
||||
if (store.get('ignore_ssl')) {
|
||||
app.commandLine.appendSwitch('ignore-certificate-errors');
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let exitFromTray = false;
|
||||
let forceQuit = false;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const sourceMapSupport = require('source-map-support');
|
||||
@@ -57,27 +80,140 @@ const installExtensions = async () => {
|
||||
.catch(console.log);
|
||||
};
|
||||
|
||||
const singleInstance = app.requestSingleInstanceLock();
|
||||
|
||||
if (!singleInstance) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
const RESOURCES_PATH = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'assets')
|
||||
: path.join(__dirname, '../../assets');
|
||||
|
||||
const getAssetPath = (...paths: string[]): string => {
|
||||
return path.join(RESOURCES_PATH, ...paths);
|
||||
};
|
||||
|
||||
export const getMainWindow = () => {
|
||||
return mainWindow;
|
||||
};
|
||||
|
||||
const createWinThumbarButtons = () => {
|
||||
if (isWindows()) {
|
||||
getMainWindow()?.setThumbarButtons([
|
||||
{
|
||||
click: () => getMainWindow()?.webContents.send('renderer-player-previous'),
|
||||
icon: nativeImage.createFromPath(getAssetPath('skip-previous.png')),
|
||||
tooltip: 'Previous Track',
|
||||
},
|
||||
{
|
||||
click: () => getMainWindow()?.webContents.send('renderer-player-play-pause'),
|
||||
icon: nativeImage.createFromPath(getAssetPath('play-circle.png')),
|
||||
tooltip: 'Play/Pause',
|
||||
},
|
||||
{
|
||||
click: () => getMainWindow()?.webContents.send('renderer-player-next'),
|
||||
icon: nativeImage.createFromPath(getAssetPath('skip-next.png')),
|
||||
tooltip: 'Next Track',
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const createTray = () => {
|
||||
if (isMacOS()) {
|
||||
return;
|
||||
}
|
||||
|
||||
tray = isLinux() ? new Tray(getAssetPath('icon.png')) : new Tray(getAssetPath('icon.ico'));
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
click: () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-play-pause');
|
||||
},
|
||||
label: 'Play/Pause',
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-next');
|
||||
},
|
||||
label: 'Next Track',
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||
},
|
||||
label: 'Previous Track',
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
},
|
||||
label: 'Stop',
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
mainWindow?.show();
|
||||
createWinThumbarButtons();
|
||||
},
|
||||
label: 'Open main window',
|
||||
},
|
||||
{
|
||||
click: () => {
|
||||
exitFromTray = true;
|
||||
app.quit();
|
||||
},
|
||||
label: 'Quit',
|
||||
},
|
||||
]);
|
||||
|
||||
tray.on('double-click', () => {
|
||||
mainWindow?.show();
|
||||
createWinThumbarButtons();
|
||||
});
|
||||
|
||||
tray.setToolTip('Feishin');
|
||||
tray.setContextMenu(contextMenu);
|
||||
};
|
||||
|
||||
const createWindow = async () => {
|
||||
if (isDevelopment) {
|
||||
await installExtensions();
|
||||
}
|
||||
|
||||
const RESOURCES_PATH = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'assets')
|
||||
: path.join(__dirname, '../../assets');
|
||||
const nativeFrame = store.get('window_window_bar_style') === 'linux';
|
||||
store.set('window_has_frame', nativeFrame);
|
||||
|
||||
const getAssetPath = (...paths: string[]): string => {
|
||||
return path.join(RESOURCES_PATH, ...paths);
|
||||
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,
|
||||
@@ -85,8 +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'),
|
||||
},
|
||||
width: 1440,
|
||||
...(nativeFrame && isLinux() && nativeFrameConfig.linux),
|
||||
...(nativeFrame && isMacOS() && nativeFrameConfig.macOS),
|
||||
...(nativeFrame && isWindows() && nativeFrameConfig.windows),
|
||||
});
|
||||
|
||||
electronLocalShortcut.register(mainWindow, 'Ctrl+Shift+I', () => {
|
||||
@@ -99,7 +239,6 @@ const createWindow = async () => {
|
||||
|
||||
ipcMain.on('window-maximize', () => {
|
||||
mainWindow?.maximize();
|
||||
mainWindow?.webContents.send('renderer-player-quit');
|
||||
});
|
||||
|
||||
ipcMain.on('window-unmaximize', () => {
|
||||
@@ -115,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', () => {
|
||||
@@ -127,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);
|
||||
}
|
||||
|
||||
@@ -143,14 +322,80 @@ const createWindow = async () => {
|
||||
mainWindow.minimize();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
createWinThumbarButtons();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
// mainWindow?.webContents.send('renderer-player-quit');
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
let saved = false;
|
||||
|
||||
mainWindow.on('close', (event) => {
|
||||
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
||||
if (isMacOS() && !forceQuit) {
|
||||
exitFromTray = true;
|
||||
}
|
||||
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) => {
|
||||
if (store.get('window_minimize_to_tray') === true) {
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
}
|
||||
});
|
||||
|
||||
if (isWindows()) {
|
||||
app.setAppUserModelId(process.execPath);
|
||||
}
|
||||
|
||||
if (isMacOS()) {
|
||||
app.on('before-quit', () => {
|
||||
forceQuit = true;
|
||||
});
|
||||
}
|
||||
|
||||
const menuBuilder = new MenuBuilder(mainWindow);
|
||||
menuBuilder.buildMenu();
|
||||
|
||||
@@ -160,103 +405,230 @@ const createWindow = async () => {
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Remove this if your app does not use auto updates
|
||||
// eslint-disable-next-line
|
||||
new AppUpdater();
|
||||
if (store.get('disable_auto_updates') !== true) {
|
||||
// eslint-disable-next-line
|
||||
new AppUpdater();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add event listeners...
|
||||
*/
|
||||
|
||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||
|
||||
export const getMainWindow = () => {
|
||||
return mainWindow;
|
||||
};
|
||||
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
|
||||
const BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
|
||||
const DEFAULT_MPV_PARAMETERS = () => {
|
||||
const parameters = [];
|
||||
if (
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=weak') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=no') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--gapless-audio')
|
||||
) {
|
||||
parameters.push('--gapless-audio=yes');
|
||||
}
|
||||
const prefetchPlaylistParams = [
|
||||
'--prefetch-playlist=no',
|
||||
'--prefetch-playlist=yes',
|
||||
'--prefetch-playlist',
|
||||
];
|
||||
|
||||
if (
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=yes') ||
|
||||
!MPV_PARAMETERS?.includes('--prefetch-playlist')
|
||||
) {
|
||||
const DEFAULT_MPV_PARAMETERS = (extraParameters?: string[]) => {
|
||||
const parameters = ['--idle=yes'];
|
||||
|
||||
if (!extraParameters?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||
parameters.push('--prefetch-playlist=yes');
|
||||
}
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
export const mpv = new MpvAPI(
|
||||
{
|
||||
audio_only: true,
|
||||
auto_restart: true,
|
||||
binary: BINARY_PATH || '',
|
||||
time_update: 1,
|
||||
},
|
||||
MPV_PARAMETERS
|
||||
? uniq([...DEFAULT_MPV_PARAMETERS(), ...MPV_PARAMETERS])
|
||||
: DEFAULT_MPV_PARAMETERS(),
|
||||
);
|
||||
let mpvInstance: MpvAPI | null = null;
|
||||
|
||||
mpv.start().catch((error) => {
|
||||
console.log('error starting mpv', error);
|
||||
});
|
||||
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
const { extraParameters, properties } = data;
|
||||
|
||||
mpv.on('status', (status) => {
|
||||
if (status.property === 'playlist-pos') {
|
||||
if (status.value !== 0) {
|
||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||
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,
|
||||
},
|
||||
params,
|
||||
);
|
||||
|
||||
console.log('Setting MPV properties: ', properties);
|
||||
mpv.setMultipleProperties(properties || {});
|
||||
|
||||
mpv.start().catch((error) => {
|
||||
console.log('MPV failed to start', error);
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpv.on('resumed', () => {
|
||||
console.log('MPV Event: resumed');
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is 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
|
||||
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
|
||||
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 = () => {
|
||||
return mpvInstance;
|
||||
};
|
||||
|
||||
ipcMain.on('player-set-properties', async (_event, data: Record<string, any>) => {
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.length === 1) {
|
||||
getMpvInstance()?.setProperty(Object.keys(data)[0], Object.values(data)[0]);
|
||||
} else {
|
||||
getMpvInstance()?.setMultipleProperties(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is playing
|
||||
mpv.on('resumed', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-play');
|
||||
ipcMain.on(
|
||||
'player-restart',
|
||||
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||
mpvInstance?.quit();
|
||||
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;
|
||||
});
|
||||
|
||||
// Automatically updates the play button when the player is stopped
|
||||
mpv.on('stopped', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||
});
|
||||
// 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',
|
||||
}
|
||||
|
||||
// Automatically updates the play button when the player is paused
|
||||
mpv.on('paused', () => {
|
||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||
});
|
||||
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]: () => {},
|
||||
};
|
||||
|
||||
// Event output every interval set by time_update, used to update the current time
|
||||
mpv.on('timeposition', (time: number) => {
|
||||
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||
});
|
||||
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', () => {
|
||||
mpv.stop();
|
||||
getMpvInstance()?.stop();
|
||||
getMpvInstance()?.quit();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
|
||||
getMpvInstance()?.quit();
|
||||
// Respect the OSX convention of having the application in memory even
|
||||
// after all windows have been closed
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
} else {
|
||||
mpv.stop();
|
||||
if (isMacOS()) {
|
||||
mainWindow = null;
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -264,6 +636,7 @@ app
|
||||
.whenReady()
|
||||
.then(() => {
|
||||
createWindow();
|
||||
createTray();
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
||||
import { PlayerData } from '../renderer/store';
|
||||
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,102 +10,8 @@ import { utils } from './preload/utils';
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
browser,
|
||||
ipc,
|
||||
ipcRenderer: {
|
||||
APP_RESTART() {
|
||||
ipcRenderer.send('app-restart');
|
||||
},
|
||||
PLAYER_AUTO_NEXT(data: PlayerData) {
|
||||
ipcRenderer.send('player-auto-next', data);
|
||||
},
|
||||
PLAYER_CURRENT_TIME() {
|
||||
ipcRenderer.send('player-current-time');
|
||||
},
|
||||
PLAYER_MEDIA_KEYS_DISABLE() {
|
||||
ipcRenderer.send('global-media-keys-disable');
|
||||
},
|
||||
PLAYER_MEDIA_KEYS_ENABLE() {
|
||||
ipcRenderer.send('global-media-keys-enable');
|
||||
},
|
||||
PLAYER_MUTE() {
|
||||
ipcRenderer.send('player-mute');
|
||||
},
|
||||
PLAYER_NEXT() {
|
||||
ipcRenderer.send('player-next');
|
||||
},
|
||||
PLAYER_PAUSE() {
|
||||
ipcRenderer.send('player-pause');
|
||||
},
|
||||
PLAYER_PLAY() {
|
||||
ipcRenderer.send('player-play');
|
||||
},
|
||||
PLAYER_PREVIOUS() {
|
||||
ipcRenderer.send('player-previous');
|
||||
},
|
||||
PLAYER_SEEK(seconds: number) {
|
||||
ipcRenderer.send('player-seek', seconds);
|
||||
},
|
||||
PLAYER_SEEK_TO(seconds: number) {
|
||||
ipcRenderer.send('player-seek-to', seconds);
|
||||
},
|
||||
PLAYER_SET_QUEUE(data: PlayerData) {
|
||||
ipcRenderer.send('player-set-queue', data);
|
||||
},
|
||||
PLAYER_SET_QUEUE_NEXT(data: PlayerData) {
|
||||
ipcRenderer.send('player-set-queue-next', data);
|
||||
},
|
||||
PLAYER_STOP() {
|
||||
ipcRenderer.send('player-stop');
|
||||
},
|
||||
PLAYER_VOLUME(value: number) {
|
||||
ipcRenderer.send('player-volume', value);
|
||||
},
|
||||
RENDERER_PLAYER_AUTO_NEXT(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-auto-next', cb);
|
||||
},
|
||||
RENDERER_PLAYER_CURRENT_TIME(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-current-time', cb);
|
||||
},
|
||||
RENDERER_PLAYER_NEXT(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-next', cb);
|
||||
},
|
||||
RENDERER_PLAYER_PAUSE(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-pause', cb);
|
||||
},
|
||||
RENDERER_PLAYER_PLAY(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-play', cb);
|
||||
},
|
||||
RENDERER_PLAYER_PLAY_PAUSE(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-play-pause', cb);
|
||||
},
|
||||
RENDERER_PLAYER_PREVIOUS(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-previous', cb);
|
||||
},
|
||||
RENDERER_PLAYER_STOP(cb: (event: IpcRendererEvent, data: any) => void) {
|
||||
ipcRenderer.on('renderer-player-stop', cb);
|
||||
},
|
||||
SETTINGS_GET(data: { property: string }) {
|
||||
return ipcRenderer.invoke('settings-get', data);
|
||||
},
|
||||
SETTINGS_SET(data: { property: string; value: any }) {
|
||||
ipcRenderer.send('settings-set', data);
|
||||
},
|
||||
removeAllListeners(value: string) {
|
||||
ipcRenderer.removeAllListeners(value);
|
||||
},
|
||||
windowClose() {
|
||||
ipcRenderer.send('window-close');
|
||||
},
|
||||
windowMaximize() {
|
||||
ipcRenderer.send('window-maximize');
|
||||
},
|
||||
windowMinimize() {
|
||||
ipcRenderer.send('window-minimize');
|
||||
},
|
||||
windowUnmaximize() {
|
||||
ipcRenderer.send('window-unmaximize');
|
||||
},
|
||||
},
|
||||
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,6 +1,27 @@
|
||||
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);
|
||||
};
|
||||
|
||||
const autoNext = (data: PlayerData) => {
|
||||
ipcRenderer.send('player-auto-next', data);
|
||||
};
|
||||
@@ -8,36 +29,55 @@ const autoNext = (data: PlayerData) => {
|
||||
const currentTime = () => {
|
||||
ipcRenderer.send('player-current-time');
|
||||
};
|
||||
|
||||
const mute = () => {
|
||||
ipcRenderer.send('player-mute');
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
ipcRenderer.send('player-next');
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
ipcRenderer.send('player-pause');
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
ipcRenderer.send('player-play');
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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) => {
|
||||
ipcRenderer.send('player-set-queue-next', data);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
ipcRenderer.send('player-stop');
|
||||
};
|
||||
|
||||
const volume = (value: number) => {
|
||||
ipcRenderer.send('player-volume', value);
|
||||
};
|
||||
@@ -46,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);
|
||||
};
|
||||
@@ -78,21 +122,69 @@ 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,
|
||||
play,
|
||||
previous,
|
||||
quit,
|
||||
restart,
|
||||
restoreQueue,
|
||||
saveQueue,
|
||||
seek,
|
||||
seekTo,
|
||||
setProperties,
|
||||
setQueue,
|
||||
setQueueNext,
|
||||
stop,
|
||||
@@ -102,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;
|
||||
};
|
||||
|
||||
@@ -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: undefined,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -567,6 +567,7 @@ export enum 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',
|
||||
|
||||
@@ -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,752 +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 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;
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
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,291 +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':
|
||||
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,494 +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 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 });
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -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,30 +316,16 @@ export enum AlbumListSort {
|
||||
}
|
||||
|
||||
export type AlbumListQuery = {
|
||||
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;
|
||||
@@ -436,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 };
|
||||
@@ -445,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 {
|
||||
@@ -471,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;
|
||||
@@ -576,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 };
|
||||
@@ -585,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',
|
||||
@@ -608,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;
|
||||
@@ -672,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 {
|
||||
@@ -704,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;
|
||||
@@ -769,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;
|
||||
@@ -806,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 {
|
||||
@@ -888,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;
|
||||
@@ -935,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;
|
||||
@@ -946,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 = {
|
||||
@@ -961,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 {
|
||||
@@ -978,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;
|
||||
@@ -1009,12 +927,11 @@ export const userListSortMap: UserListSortMap = {
|
||||
};
|
||||
|
||||
// Top Songs List
|
||||
export type RawTopSongListResponse = SSTopSongList | undefined;
|
||||
|
||||
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
||||
|
||||
export type TopSongListQuery = {
|
||||
artist: string;
|
||||
artistId: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@@ -1030,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 = {
|
||||
@@ -1042,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);
|
||||
}
|
||||
};
|
||||
@@ -8,29 +8,106 @@ 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';
|
||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||
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 { 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;
|
||||
root.style.setProperty('--content-font-family', contentFont);
|
||||
}, [contentFont]);
|
||||
|
||||
// Start the mpv instance on startup
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
withGlobalStyles
|
||||
@@ -42,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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -76,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: {
|
||||
@@ -98,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 }}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const AudioPlayer = forwardRef(
|
||||
const player1Ref = useRef<any>(null);
|
||||
const player2Ref = useRef<any>(null);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const audioDeviceId = useSettingsStore((state) => state.player.audioDeviceId);
|
||||
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get player1() {
|
||||
|
||||
@@ -5,6 +5,8 @@ import styled from 'styled-components';
|
||||
export type BadgeProps = MantineBadgeProps;
|
||||
|
||||
const StyledBadge = styled(MantineBadge)<BadgeProps>`
|
||||
border-radius: var(--badge-radius);
|
||||
|
||||
.mantine-Badge-root {
|
||||
color: var(--badge-fg);
|
||||
}
|
||||
|
||||
@@ -20,145 +20,37 @@ interface StyledButtonProps extends ButtonProps {
|
||||
}
|
||||
|
||||
const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||
color: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-fg)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-fg)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-fg)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
background: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-bg)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-bg)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-bg)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
border: none;
|
||||
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
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, border 0.2s ease-in-out;
|
||||
|
||||
svg {
|
||||
transition: fill 0.2s ease-in-out;
|
||||
fill: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-fg)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-fg)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-fg)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-fg)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-fg)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-fg)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
background: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-bg)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-bg)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-bg)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:not([data-disabled])&:hover {
|
||||
color: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-fg-hover)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-fg-hover)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-fg-hover) !important';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
background: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-bg-hover)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-bg-hover)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-bg-hover)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
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) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-fg-hover)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-fg-hover)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-fg-hover)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
}
|
||||
}
|
||||
|
||||
&:not([data-disabled])&:focus-visible {
|
||||
color: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-fg-hover)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-fg-hover)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-fg-hover)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
background: ${(props) => {
|
||||
switch (props.variant) {
|
||||
case 'default':
|
||||
return 'var(--btn-default-bg-hover)';
|
||||
case 'filled':
|
||||
return 'var(--btn-primary-bg-hover)';
|
||||
case 'subtle':
|
||||
return 'var(--btn-subtle-bg-hover)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}};
|
||||
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -171,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 {
|
||||
|
||||
@@ -7,7 +7,7 @@ import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
import type { PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import {
|
||||
@@ -112,7 +112,7 @@ export const CardControls = ({
|
||||
itemData: any;
|
||||
itemType: LibraryItem;
|
||||
}) => {
|
||||
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -19,12 +19,17 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
|
||||
min-width: ${({ minWidth }) => minWidth}px;
|
||||
max-width: ${({ maxWidth }) => maxWidth}px;
|
||||
background: var(--dropdown-menu-bg);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||
|
||||
button:first-child {
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
|
||||
button:last-child {
|
||||
border-bottom-right-radius: var(--dropdown-menu-border-radius);
|
||||
border-bottom-left-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
|
||||
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
|
||||
return (
|
||||
<StyledDatePicker
|
||||
withinPortal
|
||||
{...props}
|
||||
sx={{ maxWidth, width }}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -73,7 +61,7 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
||||
border-radius: var(--dropdown-menu-border-radius);
|
||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
||||
|
||||
*:first-child {
|
||||
/* *:first-child {
|
||||
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
@@ -81,11 +69,12 @@ const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
||||
*:last-child {
|
||||
border-bottom-right-radius: var(--dropdown-menu-border-radius);
|
||||
border-bottom-left-radius: var(--dropdown-menu-border-radius);
|
||||
}
|
||||
} */
|
||||
`;
|
||||
|
||||
const StyledMenuDivider = styled(MantineMenu.Divider)`
|
||||
/* margin: 0.3rem 0; */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
||||
@@ -97,7 +86,6 @@ export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transition="fade"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -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,10 +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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Flex, Group } from '@mantine/core';
|
||||
|
||||
export const Option = ({ children }: any) => {
|
||||
return (
|
||||
<Group
|
||||
grow
|
||||
p="0.5rem"
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
interface LabelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Label = ({ children }: LabelProps) => {
|
||||
return <Flex align="flex-start">{children}</Flex>;
|
||||
};
|
||||
|
||||
interface ControlProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Control = ({ children }: ControlProps) => {
|
||||
return <Flex justify="flex-end">{children}</Flex>;
|
||||
};
|
||||
|
||||
Option.Label = Label;
|
||||
Option.Control = Control;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useRef } from 'react';
|
||||
import { Flex, FlexProps } from '@mantine/core';
|
||||
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
const Container = styled(motion(Flex))<{
|
||||
height?: string;
|
||||
@@ -15,7 +17,11 @@ const Container = styled(motion(Flex))<{
|
||||
background: var(--titlebar-bg);
|
||||
`;
|
||||
|
||||
const Header = styled(motion.div)<{ $isHidden?: boolean; $padRight?: boolean }>`
|
||||
const Header = styled(motion.div)<{
|
||||
$isDraggable?: boolean;
|
||||
$isHidden?: boolean;
|
||||
$padRight?: boolean;
|
||||
}>`
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
width: 100%;
|
||||
@@ -23,7 +29,7 @@ const Header = styled(motion.div)<{ $isHidden?: boolean; $padRight?: boolean }>`
|
||||
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
|
||||
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||
-webkit-app-region: drag;
|
||||
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
|
||||
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
@@ -88,6 +94,7 @@ export const PageHeader = ({
|
||||
}: PageHeaderProps) => {
|
||||
const ref = useRef(null);
|
||||
const padRight = useShouldPadTitlebar();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
@@ -98,10 +105,11 @@ export const PageHeader = ({
|
||||
{...props}
|
||||
>
|
||||
<Header
|
||||
$isDraggable={windowBarStyle === Platform.WEB}
|
||||
$isHidden={isHidden}
|
||||
$padRight={padRight}
|
||||
>
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={false}>
|
||||
{!isHidden && (
|
||||
<TitleWrapper
|
||||
animate="animate"
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Popover = ({ children, ...props }: PopoverProps) => {
|
||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||
},
|
||||
}}
|
||||
transition="fade"
|
||||
transitionProps={{ transition: 'fade' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useMergedRef, useTimeout } from '@mantine/hooks';
|
||||
import { motion, useScroll } from 'framer-motion';
|
||||
import styled from 'styled-components';
|
||||
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
interface ScrollAreaProps extends MantineScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
@@ -26,16 +28,26 @@ const StyledScrollArea = styled(MantineScrollArea)`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string }>`
|
||||
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
|
||||
height: 100%;
|
||||
overflow-y: overlay !important;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
margin-top: ${(props) => props.scrollBarOffset || '65px'};
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
margin-top: ${(props) => props.scrollBarOffset || '65px'};
|
||||
margin-top: ${(props) =>
|
||||
props.windowBarStyle === Platform.WINDOWS ||
|
||||
props.windowBarStyle === Platform.MACOS ||
|
||||
props.windowBarStyle === Platform.LINUX
|
||||
? '0px'
|
||||
: props.scrollBarOffset || '65px'};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -74,6 +86,7 @@ export const NativeScrollArea = forwardRef(
|
||||
}: NativeScrollAreaProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) => {
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const [hideScrollbar, setHideScrollbar] = useState(false);
|
||||
const [hideHeader, setHideHeader] = useState(true);
|
||||
const { start, clear } = useTimeout(
|
||||
@@ -130,6 +143,7 @@ export const NativeScrollArea = forwardRef(
|
||||
ref={mergedRef}
|
||||
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
||||
scrollBarOffset={scrollBarOffset}
|
||||
windowBarStyle={windowBarStyle}
|
||||
onMouseEnter={() => {
|
||||
setHideScrollbar(false);
|
||||
clear();
|
||||
|
||||
@@ -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)' } },
|
||||
@@ -54,7 +62,7 @@ export const SearchInput = ({
|
||||
padding: isOpened ? '10px' : 0,
|
||||
},
|
||||
}}
|
||||
width={isOpened ? openedWidth || 150 : initialWidth || 50}
|
||||
width={isOpened ? openedWidth || 150 : initialWidth || 35}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleEscape}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -64,8 +64,7 @@ export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transition="fade"
|
||||
transitionDuration={100}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -126,8 +125,7 @@ export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) =>
|
||||
},
|
||||
}}
|
||||
sx={{ maxWidth, width }}
|
||||
transition="fade"
|
||||
transitionDuration={100}
|
||||
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TabsProps as MantineTabsProps } from '@mantine/core';
|
||||
import { Tabs as MantineTabs } from '@mantine/core';
|
||||
import { Suspense } from 'react';
|
||||
import { TabsPanelProps, TabsProps as MantineTabsProps, Tabs as MantineTabs } from '@mantine/core';
|
||||
import styled from 'styled-components';
|
||||
|
||||
type TabsProps = MantineTabsProps;
|
||||
@@ -12,14 +12,17 @@ const StyledTabs = styled(MantineTabs)`
|
||||
}
|
||||
|
||||
&.mantine-Tabs-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
background-color: var(--main-bg);
|
||||
}
|
||||
|
||||
& .mantine-Tabs-panel {
|
||||
padding: 0 1rem;
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
& .mantine-Tabs-tab {
|
||||
padding: 1rem;
|
||||
color: var(--btn-subtle-fg);
|
||||
border-radius: 0;
|
||||
@@ -35,27 +38,26 @@ const StyledTabs = styled(MantineTabs)`
|
||||
button[data-active] {
|
||||
color: var(--btn-subtle-fg);
|
||||
background: none;
|
||||
box-shadow: 2px 0 0 var(--primary-color) inset;
|
||||
border-color: var(--primary-color);
|
||||
|
||||
&:hover {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* button[data-active]::before {
|
||||
content: '';
|
||||
border-left: 2px solid var(--primary-color);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
} */
|
||||
`;
|
||||
|
||||
export const Tabs = ({ children, ...props }: TabsProps) => {
|
||||
return <StyledTabs {...props}>{children}</StyledTabs>;
|
||||
};
|
||||
|
||||
const Panel = ({ children, ...props }: TabsPanelProps) => {
|
||||
return (
|
||||
<StyledTabs.Panel {...props}>
|
||||
<Suspense fallback={<></>}>{children}</Suspense>
|
||||
</StyledTabs.Panel>
|
||||
);
|
||||
};
|
||||
|
||||
Tabs.List = StyledTabs.List;
|
||||
Tabs.Panel = StyledTabs.Panel;
|
||||
Tabs.Panel = Panel;
|
||||
Tabs.Tab = StyledTabs.Tab;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,10 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
|
||||
maxWidth: '250px',
|
||||
},
|
||||
}}
|
||||
transitionProps={{
|
||||
duration: 250,
|
||||
transition: 'fade',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
@@ -37,8 +41,6 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
|
||||
Tooltip.defaultProps = {
|
||||
openDelay: 0,
|
||||
position: 'top',
|
||||
transition: 'fade',
|
||||
transitionDuration: 250,
|
||||
withArrow: true,
|
||||
withinPortal: true,
|
||||
};
|
||||
|
||||
@@ -1,37 +1,61 @@
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath, useNavigate } from 'react-router';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
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 type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
||||
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';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
link?: boolean;
|
||||
}>`
|
||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth - 12}px`};
|
||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
||||
height: ${({ itemHeight, itemGap }) => `${itemHeight - 12 - itemGap}px`};
|
||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
||||
padding: 12px 12px 0;
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isHidden?: boolean;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
}
|
||||
|
||||
const DefaultCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: calc(100% - 2rem);
|
||||
margin: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
cursor: ${({ link }) => link && 'pointer'};
|
||||
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
|
||||
cursor: pointer;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
background: var(--card-default-bg-hover);
|
||||
}
|
||||
`;
|
||||
|
||||
&:hover div {
|
||||
const InnerCardContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -40,26 +64,16 @@ const CardWrapper = styled.div<{
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCard = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: var(--card-default-radius);
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div<{ size?: number }>`
|
||||
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: relative;
|
||||
width: ${({ size }) => size && `${size - 24}px`};
|
||||
height: ${({ size }) => size && `${size - 24}px`};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--placeholder-bg);
|
||||
border-radius: var(--card-default-radius);
|
||||
|
||||
&::before {
|
||||
@@ -75,91 +89,93 @@ const ImageSection = styled.div<{ size?: number }>`
|
||||
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`
|
||||
object-fit: cover;
|
||||
border-radius: var(--card-default-radius);
|
||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 20%);
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
const DetailContainer = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
sizes: {
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DefaultCard = ({
|
||||
listChildProps,
|
||||
data,
|
||||
columnIndex,
|
||||
controls,
|
||||
sizes,
|
||||
isHidden,
|
||||
}: BaseGridCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { index } = listChildProps;
|
||||
const { itemGap, itemHeight, itemWidth } = sizes;
|
||||
const { itemType, cardRows, route, handlePlayQueueAdd } = controls;
|
||||
|
||||
const cardSize = itemWidth - 24;
|
||||
|
||||
if (data) {
|
||||
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 (
|
||||
<CardWrapper
|
||||
key={`card-${columnIndex}-${index}`}
|
||||
link
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemWidth={itemWidth}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
generatePath(
|
||||
route.route,
|
||||
route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
),
|
||||
)
|
||||
}
|
||||
<DefaultCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
onClick={() => navigate(path)}
|
||||
>
|
||||
<StyledCard>
|
||||
<ImageSection size={itemWidth}>
|
||||
<InnerCardContainer>
|
||||
<ImageContainer $isFavorite={data?.userFavorite}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
height={cardSize}
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
importance="auto"
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={cardSize}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
@@ -170,59 +186,56 @@ export const DefaultCard = ({
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
<Placeholder
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={itemType}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
<DetailSection>
|
||||
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ImageContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={cardRows}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
</DetailContainer>
|
||||
</InnerCardContainer>
|
||||
</DefaultCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
key={`card-${columnIndex}-${index}`}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight}
|
||||
itemWidth={itemWidth + 12}
|
||||
<DefaultCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
$isHidden={isHidden}
|
||||
>
|
||||
<StyledCard>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageSection size={itemWidth} />
|
||||
</Skeleton>
|
||||
<DetailSection>
|
||||
{cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => (
|
||||
<Skeleton
|
||||
key={`row-${row.property}-${columnIndex}`}
|
||||
height={20}
|
||||
my={2}
|
||||
radius="md"
|
||||
visible={!data}
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
/>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
<InnerCardContainer>
|
||||
<ImageContainer>
|
||||
<Skeleton
|
||||
visible
|
||||
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,13 +1,11 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import React from 'react';
|
||||
import React, { MouseEvent, useState } from 'react';
|
||||
import type { UnstyledButtonProps } from '@mantine/core';
|
||||
import { Group } from '@mantine/core';
|
||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||
import { RiPlayFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||
import styled from 'styled-components';
|
||||
import { _Button } from '/@/renderer/components/button';
|
||||
import type { PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { Play } from '/@/renderer/types';
|
||||
import { useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { LibraryItem } from '/@/renderer/api/types';
|
||||
import { useHandleGeneralContextMenu } from '/@/renderer/features/context-menu/hooks/use-handle-context-menu';
|
||||
import {
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||
|
||||
const PlayButton = styled.button<PlayButtonType>`
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -28,7 +27,7 @@ const PlayButton = styled.button<PlayButtonType>`
|
||||
border-radius: 50%;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
transition: scale 0.2s linear;
|
||||
transition: scale 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
@@ -62,7 +61,9 @@ const SecondaryButton = styled(_Button)`
|
||||
}
|
||||
`;
|
||||
|
||||
const GridCardControlsContainer = styled.div`
|
||||
const GridCardControlsContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -71,29 +72,31 @@ 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);
|
||||
`;
|
||||
|
||||
// const TopControls = styled(ControlsRow)`
|
||||
// display: flex;
|
||||
// align-items: flex-start;
|
||||
// justify-content: space-between;
|
||||
// padding: 0.5rem;
|
||||
// `;
|
||||
|
||||
// const CenterControls = styled(ControlsRow)`
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// justify-content: center;
|
||||
// padding: 0.5rem;
|
||||
// `;
|
||||
|
||||
const BottomControls = styled(ControlsRow)`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 0.5rem;
|
||||
`;
|
||||
|
||||
@@ -109,12 +112,18 @@ 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 playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
|
||||
const [isFavorite, setIsFavorite] = useState(itemData?.userFavorite);
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
|
||||
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||
e.preventDefault();
|
||||
@@ -125,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();
|
||||
|
||||
@@ -137,7 +146,10 @@ export const GridCardControls = ({
|
||||
id: [itemData.id],
|
||||
isFavorite: itemData.userFavorite,
|
||||
itemType,
|
||||
serverId,
|
||||
});
|
||||
|
||||
setIsFavorite(!isFavorite);
|
||||
};
|
||||
|
||||
const handleContextMenu = useHandleGeneralContextMenu(
|
||||
@@ -146,20 +158,23 @@ export const GridCardControls = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<GridCardControlsContainer>
|
||||
<BottomControls>
|
||||
<>
|
||||
{isFavorite ? <FavoriteBanner /> : null}
|
||||
<GridCardControlsContainer
|
||||
$isFavorite
|
||||
className="card-controls"
|
||||
>
|
||||
<PlayButton onClick={handlePlay}>
|
||||
<RiPlayFill size={25} />
|
||||
</PlayButton>
|
||||
<Group spacing="xs">
|
||||
<BottomControls>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={handleFavorites}
|
||||
onClick={(e) => handleFavorites(e, itemData?.serverId)}
|
||||
>
|
||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||
{itemData?.userFavorite ? (
|
||||
{isFavorite ? (
|
||||
<RiHeartFill size={20} />
|
||||
) : (
|
||||
<RiHeartLine
|
||||
@@ -171,7 +186,6 @@ export const GridCardControls = ({
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
p={5}
|
||||
sx={{ svg: { fill: 'white !important' } }}
|
||||
variant="subtle"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -179,13 +193,13 @@ export const GridCardControls = ({
|
||||
handleContextMenu(e, [itemData]);
|
||||
}}
|
||||
>
|
||||
<RiMore2Fill
|
||||
<RiMoreFill
|
||||
color="white"
|
||||
size={20}
|
||||
/>
|
||||
</SecondaryButton>
|
||||
</Group>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
</BottomControls>
|
||||
</GridCardControlsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,15 +3,11 @@ import type { ListChildComponentProps } from 'react-window';
|
||||
import { areEqual } from 'react-window';
|
||||
import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card';
|
||||
import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card';
|
||||
import type { GridCardData } from '/@/renderer/types';
|
||||
import { ListDisplayType } from '/@/renderer/types';
|
||||
import { GridCardData, ListDisplayType } from '/@/renderer/types';
|
||||
|
||||
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
|
||||
const {
|
||||
itemHeight,
|
||||
itemWidth,
|
||||
columnCount,
|
||||
itemGap,
|
||||
itemCount,
|
||||
cardRows,
|
||||
itemData,
|
||||
@@ -27,9 +23,14 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
|
||||
const startIndex = index * columnCount;
|
||||
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
|
||||
|
||||
const columnCountInRow = stopIndex - startIndex + 1;
|
||||
let columnCountToAdd = 0;
|
||||
if (columnCountInRow !== columnCount) {
|
||||
columnCountToAdd = columnCount - columnCountInRow;
|
||||
}
|
||||
const View = display === ListDisplayType.CARD ? DefaultCard : PosterCard;
|
||||
|
||||
for (let i = startIndex; i <= stopIndex; i += 1) {
|
||||
for (let i = startIndex; i <= stopIndex + columnCountToAdd; i += 1) {
|
||||
cards.push(
|
||||
<View
|
||||
key={`card-${i}-${index}`}
|
||||
@@ -43,24 +44,20 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
|
||||
route,
|
||||
}}
|
||||
data={itemData[i]}
|
||||
isHidden={i > stopIndex}
|
||||
listChildProps={{ index }}
|
||||
sizes={{ itemGap, itemHeight, itemWidth }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
>
|
||||
{cards}
|
||||
</div>
|
||||
</>
|
||||
<div
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{cards}
|
||||
</div>
|
||||
);
|
||||
}, areEqual);
|
||||
|
||||
@@ -1,60 +1,56 @@
|
||||
import { Center } from '@mantine/core';
|
||||
import { RiAlbumFill } from 'react-icons/ri';
|
||||
import { generatePath } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
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 { Skeleton } from '/@/renderer/components/skeleton';
|
||||
import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
||||
import { Album, Artist, AlbumArtist, LibraryItem } from '/@/renderer/api/types';
|
||||
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';
|
||||
|
||||
const CardWrapper = styled.div<{
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
}>`
|
||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
|
||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
||||
height: ${({ itemHeight, itemGap }) => `${itemHeight - itemGap}px`};
|
||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
||||
user-select: none;
|
||||
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
isHidden?: boolean;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
}
|
||||
|
||||
&:hover div {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover * {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCard = styled.div`
|
||||
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
background: var(--card-poster-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
margin: 1rem;
|
||||
overflow: hidden;
|
||||
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover {
|
||||
background: var(--card-poster-bg-hover);
|
||||
.card-controls {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const ImageSection = styled.div`
|
||||
const LinkContainer = styled.div`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const ImageContainer = styled.div<{ $isFavorite?: boolean }>`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
aspect-ratio: 1/1;
|
||||
overflow: hidden;
|
||||
background: var(--card-default-bg);
|
||||
border-radius: var(--card-poster-radius);
|
||||
|
||||
&::before {
|
||||
@@ -70,150 +66,158 @@ const ImageSection = 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;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
&::before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .card-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ImageProps {
|
||||
height: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const Image = styled.img<ImageProps>`
|
||||
object-fit: cover;
|
||||
border: 0;
|
||||
border-radius: var(--card-poster-radius);
|
||||
`;
|
||||
|
||||
const ControlsContainer = styled.div`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
const Image = styled(SimpleImg)`
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
max-width: 100%;
|
||||
height: 100% !important;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
|
||||
img {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
|
||||
const DetailSection = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
const DetailContainer = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
interface BaseGridCardProps {
|
||||
columnIndex: number;
|
||||
controls: {
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||
itemType: LibraryItem;
|
||||
playButtonBehavior: Play;
|
||||
route: CardRoute;
|
||||
};
|
||||
data: any;
|
||||
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||
sizes: {
|
||||
itemGap: number;
|
||||
itemHeight: number;
|
||||
itemWidth: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const PosterCard = ({
|
||||
listChildProps,
|
||||
data,
|
||||
columnIndex,
|
||||
controls,
|
||||
sizes,
|
||||
isHidden,
|
||||
}: BaseGridCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (data) {
|
||||
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 (
|
||||
<CardWrapper
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
itemGap={sizes.itemGap}
|
||||
itemHeight={sizes.itemHeight}
|
||||
itemWidth={sizes.itemWidth}
|
||||
>
|
||||
<StyledCard>
|
||||
<Link
|
||||
tabIndex={0}
|
||||
to={generatePath(
|
||||
controls.route.route,
|
||||
controls.route.slugs?.reduce((acc, slug) => {
|
||||
return {
|
||||
...acc,
|
||||
[slug.slugProperty]: data[slug.idProperty],
|
||||
};
|
||||
}, {}),
|
||||
<PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
|
||||
<LinkContainer onClick={() => navigate(path)}>
|
||||
<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>
|
||||
)}
|
||||
>
|
||||
<ImageSection style={{ height: `${sizes.itemWidth}px` }}>
|
||||
{data?.imageUrl ? (
|
||||
<Image
|
||||
height={sizes.itemWidth}
|
||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||
src={data?.imageUrl}
|
||||
width={sizes.itemWidth}
|
||||
/>
|
||||
) : (
|
||||
<Center
|
||||
sx={{
|
||||
background: 'var(--placeholder-bg)',
|
||||
borderRadius: 'var(--card-poster-radius)',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<RiAlbumFill
|
||||
color="var(--placeholder-fg)"
|
||||
size={35}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<ControlsContainer>
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</ControlsContainer>
|
||||
</ImageSection>
|
||||
</Link>
|
||||
<DetailSection>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
<GridCardControls
|
||||
handleFavorite={controls.handleFavorite}
|
||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||
itemData={data}
|
||||
itemType={controls.itemType}
|
||||
/>
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
</ImageContainer>
|
||||
</LinkContainer>
|
||||
<DetailContainer>
|
||||
<CardRows
|
||||
data={data}
|
||||
rows={controls.cardRows}
|
||||
/>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CardWrapper
|
||||
<PosterCardContainer
|
||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||
itemGap={sizes.itemGap}
|
||||
itemHeight={sizes.itemHeight}
|
||||
itemWidth={sizes.itemWidth}
|
||||
$isHidden={isHidden}
|
||||
>
|
||||
<StyledCard>
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageSection style={{ height: `${sizes.itemWidth}px` }} />
|
||||
</Skeleton>
|
||||
<DetailSection>
|
||||
{controls.cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => (
|
||||
<Skeleton
|
||||
visible
|
||||
radius="sm"
|
||||
>
|
||||
<ImageContainer />
|
||||
</Skeleton>
|
||||
<DetailContainer>
|
||||
<Stack spacing="sm">
|
||||
{controls.cardRows.map((row, index) => (
|
||||
<Skeleton
|
||||
key={`row-${row.property}-${columnIndex}`}
|
||||
height={20}
|
||||
my={2}
|
||||
radius="md"
|
||||
visible={!data}
|
||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
||||
key={`${index}-${columnIndex}-${row.arrayProperty}`}
|
||||
visible
|
||||
height={14}
|
||||
radius="sm"
|
||||
/>
|
||||
))}
|
||||
</DetailSection>
|
||||
</StyledCard>
|
||||
</CardWrapper>
|
||||
</Stack>
|
||||
</DetailContainer>
|
||||
</PosterCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useRef, useMemo, useCallback, forwardRef, Ref, useImperativeHandle } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
Ref,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { FixedSizeListProps } from 'react-window';
|
||||
import InfiniteLoader from 'react-window-infinite-loader';
|
||||
@@ -13,30 +21,23 @@ 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;
|
||||
itemData: any[];
|
||||
height?: number;
|
||||
itemGap: number;
|
||||
itemSize: number;
|
||||
itemType: LibraryItem;
|
||||
loading?: boolean;
|
||||
minimumBatchSize?: number;
|
||||
route?: CardRoute;
|
||||
setItemData: (data: any[]) => void;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
// const constrainWidth = (width: number) => {
|
||||
// if (width < 1920) {
|
||||
// return width;
|
||||
// }
|
||||
|
||||
// return 1920;
|
||||
// };
|
||||
|
||||
export const VirtualInfiniteGrid = forwardRef(
|
||||
(
|
||||
{
|
||||
@@ -45,8 +46,6 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
itemSize,
|
||||
itemType,
|
||||
cardRows,
|
||||
itemData,
|
||||
setItemData,
|
||||
route,
|
||||
onScroll,
|
||||
display,
|
||||
@@ -64,16 +63,19 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
const listRef = useRef<any>(null);
|
||||
const loader = useRef<InfiniteLoader>(null);
|
||||
|
||||
const [itemData, setItemData] = useState<any[]>([]);
|
||||
|
||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||
const itemsPerRow = Math.floor((Number(width) - itemGap + 3) / (itemSize! + itemGap + 2));
|
||||
const itemsPerRow = itemSize;
|
||||
const widthPerItem = Number(width) / itemsPerRow;
|
||||
const itemHeight = widthPerItem + cardRows.length * 26;
|
||||
|
||||
return {
|
||||
columnCount: itemsPerRow,
|
||||
itemHeight: itemSize! + cardRows.length * 22 + itemGap,
|
||||
itemWidth: itemSize! + itemGap,
|
||||
itemHeight,
|
||||
rowCount: Math.ceil(itemCount / itemsPerRow),
|
||||
};
|
||||
}, [cardRows.length, itemCount, itemGap, itemSize, width]);
|
||||
}, [cardRows.length, itemCount, itemSize, width]);
|
||||
|
||||
const isItemLoaded = useCallback(
|
||||
(index: number) => {
|
||||
@@ -153,7 +155,7 @@ export const VirtualInfiniteGrid = forwardRef(
|
||||
itemCount={itemCount || 0}
|
||||
itemData={itemData}
|
||||
itemGap={itemGap}
|
||||
itemHeight={itemHeight + itemGap / 2}
|
||||
itemHeight={itemHeight}
|
||||
itemType={itemType}
|
||||
itemWidth={itemSize}
|
||||
refInstance={(list) => {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/renderer/types';
|
||||
|
||||
export const useFixedTableHeader = () => {
|
||||
const intersectRef = useRef<HTMLDivElement | null>(null);
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
const isNotPastTableIntersection = useInView(intersectRef, {
|
||||
margin: '-68px 0px 0px 0px',
|
||||
margin:
|
||||
windowBarStyle === Platform.WEB || windowBarStyle === Platform.LINUX
|
||||
? '-68px 0px 0px 0px'
|
||||
: '-98px 0px 0px 0px',
|
||||
});
|
||||
|
||||
const tableInView = useInView(tableContainerRef, {
|
||||
@@ -18,13 +24,19 @@ export const useFixedTableHeader = () => {
|
||||
const root = document.querySelector('main .ag-root');
|
||||
|
||||
if (isNotPastTableIntersection || !tableInView) {
|
||||
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.WINDOWS || windowBarStyle === Platform.MACOS) {
|
||||
header?.classList.add('window-frame');
|
||||
}
|
||||
header?.classList.add('ag-header-fixed');
|
||||
root?.classList.add('ag-header-fixed-margin');
|
||||
}
|
||||
}, [isNotPastTableIntersection, tableInView]);
|
||||
}, [isNotPastTableIntersection, tableInView, windowBarStyle]);
|
||||
|
||||
return { intersectRef, tableContainerRef };
|
||||
};
|
||||
|
||||