Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a19673d3c2 | |||
| 3efeaa7359 | |||
| 63be8c8fb8 | |||
| 975c31635a | |||
| 9b5bce34a0 | |||
| bb27758310 | |||
| 2d7c52a6b6 | |||
| cbb15ac7ee | |||
| b2db2b27da | |||
| 3dfeed1432 | |||
| 2101f1e9a7 | |||
| 8a0a8e4d54 | |||
| a9ca3f9083 | |||
| 6d5e10a31c | |||
| 5b616d5928 | |||
| 62670964c0 | |||
| 314bd766df | |||
| bdd023fde3 | |||
| 40aabd2217 | |||
| b9d5447b4f | |||
| 68a1cb9aaa | |||
| bf3024939a | |||
| df9464f762 | |||
| 17cf624f6a | |||
| 8f042ad448 | |||
| 1cbd61888f | |||
| 2ce49fc54e | |||
| bec328f1f4 | |||
| ea8c63b71b | |||
| 52049ce163 | |||
| 70c62c8b52 | |||
| fa79b4cbe0 | |||
| 438085633b | |||
| fe043d1823 | |||
| 9bd12df8f6 | |||
| 637d420e1c | |||
| c593b7bc46 | |||
| 5e90139b17 | |||
| ed86d8ffd2 | |||
| bcaaaac586 | |||
| 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 | |||
| 8eec6b6b8a | |||
| 60219c2522 | |||
| cdb5cdf442 | |||
| 23f84d68e8 | |||
| 0f7f4b969f | |||
| 563a4b3a7c | |||
| 4700772e6d | |||
| ffb7f915c3 | |||
| 17d5ef1f6b | |||
| 9dcc42ff28 | |||
| 2845476d83 | |||
| 147b155d60 | |||
| 8b5e463546 | |||
| 822dcd8429 | |||
| 9f2e873366 | |||
| d2c0d4c11f | |||
| 48b6e8bf93 | |||
| 17a6b37545 | |||
| eedcef8f52 | |||
| 757eddd6f1 | |||
| 13f48711a9 | |||
| 1bbdf09dcc | |||
| 3b7c6ce25e | |||
| 38118e74ae | |||
| 6872a7e8b2 | |||
| ab3236230b | |||
| 6ef88e56ec | |||
| 18c18ea322 | |||
| 22fec8f9d3 | |||
| f50ec5cf31 | |||
| 4cbc28a087 | |||
| 01fdd25406 | |||
| 320f583660 | |||
| 8cc5ec6797 | |||
| edfbc2538d | |||
| 484c96187c | |||
| 85bf910d65 | |||
| 5ddd0872ef | |||
| 2700774469 | |||
| c79a992829 | |||
| 5e693313d8 | |||
| 59f4f43e84 | |||
| be39c2bc1f | |||
| b49ba2d04c | |||
| d7c87efe10 | |||
| 44a4b88809 | |||
| 768269f074 | |||
| d23ae2a8db | |||
| 88591697a2 | |||
| dcbb00f7c4 | |||
| e063ee0c29 | |||
| e5f478218e | |||
| 9a809a61dd | |||
| 4058ab7491 | |||
| 48ccebd4c2 | |||
| fcf00b9de1 | |||
| f7919b296b | |||
| 0b6af1fd21 | |||
| 3f424b72f6 | |||
| fc9d4616ba | |||
| 2e74f7533a | |||
| 784da2f8b9 | |||
| dbc29568ca | |||
| 5614ad54f2 | |||
| 67523f1e7b | |||
| fc57605219 | |||
| a31a2ffdbf | |||
| 900d47d6f9 | |||
| 6bdf0736ec | |||
| 92cde507d9 | |||
| 8afd626806 | |||
| 53f3758d2a | |||
| 1e6eb33408 | |||
| 51e20a81b7 | |||
| 4e8dc868bb | |||
| 9b8bcb05bd | |||
| 55e2a9bf37 | |||
| a82b087969 | |||
| 45aef104fe | |||
| 6746903808 | |||
| 36c1f4e736 | |||
| b0ca7ab127 | |||
| 2026bc8f48 | |||
| 6da8663a1d | |||
| b4e9f48667 | |||
| d58ba92cbd | |||
| c51194cd03 | |||
| ba0ec909c8 | |||
| 0db1c36d86 | |||
| 3e9fb521f0 | |||
| b5ad30a9bc | |||
| 5344493845 | |||
| 6f2108940e | |||
| 4f3e732891 | |||
| 0e2678575a | |||
| d6035a5f97 | |||
| b82a5eda78 | |||
| d17f30f5e6 | |||
| 7a3bdb531d | |||
| c6f3b49a6e | |||
| 99cd48ca6d | |||
| 02ed9b7a5c | |||
| 0f66687843 | |||
| 586f42867d | |||
| 14a7f0254d | |||
| 0aa0e51daa | |||
| f4f06abd72 | |||
| 7d8cb0bb45 | |||
| 2edffa02d0 | |||
| cfa4e5e45c | |||
| f879171398 | |||
| 6bfebd2923 | |||
| 3c60f406ea | |||
| af1c16ee51 | |||
| 1b25d88692 | |||
| 8a48abbbc8 | |||
| 6deab38c67 | |||
| 915b0eb372 | |||
| 6bb0474d62 | |||
| 2df96c0d31 | |||
| d94d7b5ee5 | |||
| 2f7f6bead9 | |||
| 3bbddcf092 | |||
| 838c6a8b6a | |||
| 5889b8976c | |||
| d06ddc9560 | |||
| 9b1f4e7154 | |||
| b569ec31ae | |||
| f7b8e34905 | |||
| 3cf7127f56 | |||
| cb823d94e5 | |||
| 4d0620c5df | |||
| 01371d0227 | |||
| 9e6a81cb62 | |||
| 7b616b44fa | |||
| 4c275ea878 | |||
| b59c86f78f | |||
| 14e6b4e7d6 | |||
| 63cdefcb27 | |||
| 11f9721abe | |||
| 0a82438beb | |||
| 8bd1cc80bc | |||
| 624b1bb94d | |||
| cf904b5d51 | |||
| 2ab48f5c97 | |||
| d56799e519 | |||
| 083e219ed2 | |||
| 1b379882f5 | |||
| ab031820f6 | |||
| d1dfbaedaa | |||
| 3070586104 | |||
| bcfb9dbec3 | |||
| 888bab50c9 | |||
| 715ee0fa3f | |||
| 1963e93d2e | |||
| ad3728a55d | |||
| df4f05b14c | |||
| 0c7a0cc88a | |||
| 98ef0b44ec | |||
| 24f06db2b8 | |||
| d63e5f5784 | |||
| 75ef43dffb | |||
| 65974dbf28 | |||
| 16433457ad | |||
| 19eaf44394 | |||
| 861fcec14f | |||
| 72cbd23089 |
@@ -4,18 +4,6 @@ about: You're having technical issues. 🐞
|
|||||||
labels: 'bug'
|
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
|
## Expected Behavior
|
||||||
|
|
||||||
<!--- What should have happened? -->
|
<!--- What should have happened? -->
|
||||||
@@ -23,6 +11,8 @@ labels: 'bug'
|
|||||||
## Current Behavior
|
## Current Behavior
|
||||||
|
|
||||||
<!--- What went wrong? -->
|
<!--- 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
|
## Steps to Reproduce
|
||||||
|
|
||||||
@@ -44,24 +34,11 @@ labels: 'bug'
|
|||||||
## Context
|
## Context
|
||||||
|
|
||||||
<!--- How has this issue affected you? What are you trying to accomplish? -->
|
<!--- 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
|
## Your Environment
|
||||||
|
|
||||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||||
|
|
||||||
- Node version :
|
- Application version :
|
||||||
- electron-react-boilerplate version or branch :
|
|
||||||
- Operating System and version :
|
- Operating System and version :
|
||||||
- Link to your project :
|
- Node version (if developing locally) :
|
||||||
|
|
||||||
<!---
|
|
||||||
❗️❗️ 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
|
|
||||||
-->
|
|
||||||
|
|||||||
@@ -4,16 +4,6 @@ about: Ask a question.❓
|
|||||||
labels: 'question'
|
labels: 'question'
|
||||||
---
|
---
|
||||||
|
|
||||||
## Summary
|
<!-- Question issues will be closed. -->
|
||||||
|
<!-- Ask questions in the discussions tab: Please use discussions https://github.com/jeffvli/feishin/discussions -->
|
||||||
<!-- What do you need help with? -->
|
<!-- Or join the Discord/Matrix servers: https://discord.gg/FVKpcMDy5f https://matrix.to/#/#sonixd:matrix.org -->
|
||||||
|
|
||||||
<!---
|
|
||||||
❗️❗️ 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
|
|
||||||
-->
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: Feature request
|
name: Feature request
|
||||||
about: You want something added to the boilerplate. 🎉
|
about: Request a feature to be added to Feishin 🎉
|
||||||
labels: 'enhancement'
|
labels: 'enhancement'
|
||||||
---
|
---
|
||||||
|
|
||||||
<!---
|
## What do you want to be added?
|
||||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
|
||||||
|
|
||||||
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
|
|
||||||
-->
|
|
||||||
|
|||||||
@@ -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
|
on: workflow_dispatch
|
||||||
|
|
||||||
@@ -35,5 +35,5 @@ jobs:
|
|||||||
command: |
|
command: |
|
||||||
npm run postinstall
|
npm run postinstall
|
||||||
npm run build
|
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
|
on_retry_command: npm cache clean --force
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Comment on pull request
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ['Publish (PR)']
|
||||||
|
types: [completed]
|
||||||
|
jobs:
|
||||||
|
pr_comment:
|
||||||
|
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
# This snippet is public-domain, taken from
|
||||||
|
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
||||||
|
script: |
|
||||||
|
async function upsertComment(owner, repo, issue_number, purpose, body) {
|
||||||
|
const {data: comments} = await github.rest.issues.listComments(
|
||||||
|
{owner, repo, issue_number});
|
||||||
|
const marker = `<!-- bot: ${purpose} -->`;
|
||||||
|
body = marker + "\n" + body;
|
||||||
|
const existing = comments.filter((c) => c.body.includes(marker));
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const last = existing[existing.length - 1];
|
||||||
|
core.info(`Updating comment ${last.id}`);
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner, repo,
|
||||||
|
body,
|
||||||
|
comment_id: last.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
core.info(`Creating a comment in issue / PR #${issue_number}`);
|
||||||
|
await github.rest.issues.createComment({issue_number, body, owner, repo});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const {owner, repo} = context.repo;
|
||||||
|
const run_id = ${{github.event.workflow_run.id}};
|
||||||
|
const pull_requests = ${{ toJSON(github.event.workflow_run.pull_requests) }};
|
||||||
|
if (!pull_requests.length) {
|
||||||
|
return core.error("This workflow doesn't match any pull requests!");
|
||||||
|
}
|
||||||
|
const artifacts = await github.paginate(
|
||||||
|
github.rest.actions.listWorkflowRunArtifacts, {owner, repo, run_id});
|
||||||
|
if (!artifacts.length) {
|
||||||
|
return core.error(`No artifacts found`);
|
||||||
|
}
|
||||||
|
let body = `Download the artifacts for this pull request:\n`;
|
||||||
|
for (const art of artifacts) {
|
||||||
|
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||||
|
}
|
||||||
|
core.info("Review thread message body:", body);
|
||||||
|
for (const pr of pull_requests) {
|
||||||
|
await upsertComment(owner, repo, pr.number,
|
||||||
|
"nightly-link", body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Publish (PR)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- development
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [macos-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout git repo
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install Node and NPM
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 16
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Build releases
|
||||||
|
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 run package:pr
|
||||||
|
on_retry_command: npm cache clean --force
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: windows-binaries
|
||||||
|
path: |
|
||||||
|
release/build/*.exe
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: linux-binaries
|
||||||
|
path: |
|
||||||
|
release/build/*.AppImage
|
||||||
|
release/build/*.deb
|
||||||
|
release/build/*.rpm
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: macos-binaries
|
||||||
|
path: |
|
||||||
|
release/build/*.dmg
|
||||||
@@ -25,26 +25,33 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</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)
|
||||||
|
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_home.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
|
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
|
||||||
|
|
||||||
### After installing the server and database
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
### What music servers does Feishin support?
|
### 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)
|
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||||
- ~~[Airsonic](https://github.com/airsonic/airsonic)~~
|
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||||
- ~~[Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)~~
|
|
||||||
- ~~[Gonic](https://github.com/sentriz/gonic)~~
|
- ~~[Gonic](https://github.com/sentriz/gonic)~~
|
||||||
- ~~[Astiga](https://asti.ga/)~~
|
- ~~[Astiga](https://asti.ga/)~~
|
||||||
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
|
- ~~[Supysonic](https://github.com/spl0k/supysonic)~~
|
||||||
|
|||||||
|
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: 887 KiB |
|
After Width: | Height: | Size: 396 KiB |
@@ -2,7 +2,7 @@
|
|||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"productName": "Feishin",
|
"productName": "Feishin",
|
||||||
"description": "Feishin music server",
|
"description": "Feishin music server",
|
||||||
"version": "0.0.1-alpha3",
|
"version": "0.0.1-alpha6",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
"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",
|
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||||
"lint:styles": "npx stylelint **/*.tsx",
|
"lint:styles": "npx stylelint **/*.tsx",
|
||||||
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never",
|
||||||
|
"package:pr": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --win --mac --linux",
|
||||||
"package:dev": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never --dir",
|
"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",
|
"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": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"afterSign": ".erb/scripts/notarize.js",
|
"afterSign": ".erb/scripts/notarize.js",
|
||||||
|
"electronVersion": "22.3.1",
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": {
|
"target": {
|
||||||
"target": "default",
|
"target": "default",
|
||||||
@@ -157,6 +159,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@electron/rebuild": "^3.2.10",
|
||||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
|
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
|
||||||
"@stylelint/postcss-css-in-js": "^0.38.0",
|
"@stylelint/postcss-css-in-js": "^0.38.0",
|
||||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
||||||
@@ -169,7 +172,6 @@
|
|||||||
"@types/node": "^17.0.23",
|
"@types/node": "^17.0.23",
|
||||||
"@types/react": "^18.0.25",
|
"@types/react": "^18.0.25",
|
||||||
"@types/react-dom": "^18.0.8",
|
"@types/react-dom": "^18.0.8",
|
||||||
"@types/react-slider": "^1.3.1",
|
|
||||||
"@types/react-test-renderer": "^17.0.1",
|
"@types/react-test-renderer": "^17.0.1",
|
||||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||||
"@types/react-window": "^1.8.5",
|
"@types/react-window": "^1.8.5",
|
||||||
@@ -188,11 +190,10 @@
|
|||||||
"css-loader": "^6.7.1",
|
"css-loader": "^6.7.1",
|
||||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||||
"detect-port": "^1.3.0",
|
"detect-port": "^1.3.0",
|
||||||
"electron": "^21.2.0",
|
"electron": "^22.3.1",
|
||||||
"electron-builder": "^23.0.3",
|
"electron-builder": "^24.0.0-alpha.13",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-notarize": "^1.2.1",
|
"electron-notarize": "^1.2.1",
|
||||||
"electron-rebuild": "^3.2.7",
|
|
||||||
"electronmon": "^2.0.2",
|
"electronmon": "^2.0.2",
|
||||||
"eslint": "^8.30.0",
|
"eslint": "^8.30.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.0",
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
@@ -253,16 +254,18 @@
|
|||||||
"@ag-grid-community/react": "^28.2.1",
|
"@ag-grid-community/react": "^28.2.1",
|
||||||
"@ag-grid-community/styles": "^28.2.1",
|
"@ag-grid-community/styles": "^28.2.1",
|
||||||
"@emotion/react": "^11.10.4",
|
"@emotion/react": "^11.10.4",
|
||||||
"@mantine/core": "^5.9.5",
|
"@mantine/core": "^6.0.8",
|
||||||
"@mantine/dates": "^5.9.5",
|
"@mantine/dates": "^6.0.8",
|
||||||
"@mantine/dropzone": "^5.9.5",
|
"@mantine/dropzone": "^6.0.8",
|
||||||
"@mantine/form": "^5.9.5",
|
"@mantine/form": "^6.0.8",
|
||||||
"@mantine/hooks": "^5.9.5",
|
"@mantine/hooks": "^6.0.8",
|
||||||
"@mantine/modals": "^5.9.5",
|
"@mantine/modals": "^6.0.8",
|
||||||
"@mantine/notifications": "^5.9.5",
|
"@mantine/notifications": "^6.0.8",
|
||||||
"@mantine/spotlight": "^5.9.5",
|
"@mantine/utils": "^6.0.8",
|
||||||
"@tanstack/react-query": "^4.16.1",
|
"@tanstack/react-query": "^4.24.4",
|
||||||
"@tanstack/react-query-devtools": "^4.16.1",
|
"@tanstack/react-query-devtools": "^4.24.4",
|
||||||
|
"@ts-rest/core": "^3.19.2",
|
||||||
|
"axios": "^1.3.6",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"electron-debug": "^3.2.0",
|
"electron-debug": "^3.2.0",
|
||||||
"electron-localshortcut": "^3.2.1",
|
"electron-localshortcut": "^3.2.1",
|
||||||
@@ -276,7 +279,6 @@
|
|||||||
"i18next": "^21.6.16",
|
"i18next": "^21.6.16",
|
||||||
"immer": "^9.0.15",
|
"immer": "^9.0.15",
|
||||||
"is-electron": "^2.2.1",
|
"is-electron": "^2.2.1",
|
||||||
"ky": "^0.33.0",
|
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
@@ -292,7 +294,6 @@
|
|||||||
"react-router": "^6.5.0",
|
"react-router": "^6.5.0",
|
||||||
"react-router-dom": "^6.5.0",
|
"react-router-dom": "^6.5.0",
|
||||||
"react-simple-img": "^3.0.0",
|
"react-simple-img": "^3.0.0",
|
||||||
"react-slider": "^2.0.4",
|
|
||||||
"react-virtualized-auto-sizer": "^1.0.6",
|
"react-virtualized-auto-sizer": "^1.0.6",
|
||||||
"react-window": "^1.8.8",
|
"react-window": "^1.8.8",
|
||||||
"react-window-infinite-loader": "^1.0.8",
|
"react-window-infinite-loader": "^1.0.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "0.0.1-alpha3",
|
"version": "0.0.1-alpha6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "./dist/main/main.js",
|
"main": "./dist/main/main.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -12,6 +12,11 @@
|
|||||||
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
|
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
|
||||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"mpris-service": "^2.1.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "22.3.1"
|
||||||
|
},
|
||||||
"license": "GPL-3.0"
|
"license": "GPL-3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,140 +1,98 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import uniq from 'lodash/uniq';
|
import { getMpvInstance } from '../../../main';
|
||||||
import MpvAPI from 'node-mpv';
|
|
||||||
import { store } from '../settings';
|
|
||||||
import { getMainWindow } from '../../../main';
|
|
||||||
import { PlayerData } from '/@/renderer/store';
|
import { PlayerData } from '/@/renderer/store';
|
||||||
|
|
||||||
declare module 'node-mpv';
|
declare module 'node-mpv';
|
||||||
|
|
||||||
const BINARY_PATH = store.get('mpv_path') as string | undefined;
|
function wait(timeout: number) {
|
||||||
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
|
return new Promise((resolve) => {
|
||||||
const DEFAULT_MPV_PARAMETERS = () => {
|
setTimeout(() => {
|
||||||
const parameters = [];
|
resolve('resolved');
|
||||||
if (
|
}, timeout);
|
||||||
!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');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
ipcMain.on('player-start', async () => {
|
||||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=no') ||
|
await getMpvInstance()?.play();
|
||||||
!MPV_PARAMETERS?.includes('--prefetch-playlist=yes') ||
|
|
||||||
!MPV_PARAMETERS?.includes('--prefetch-playlist')
|
|
||||||
) {
|
|
||||||
parameters.push('--prefetch-playlist=yes');
|
|
||||||
}
|
|
||||||
|
|
||||||
return parameters;
|
|
||||||
};
|
|
||||||
|
|
||||||
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(),
|
|
||||||
);
|
|
||||||
|
|
||||||
mpv.start().catch((error) => {
|
|
||||||
console.log('error starting mpv', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
mpv.on('status', (status) => {
|
|
||||||
if (status.property === 'playlist-pos') {
|
|
||||||
if (status.value !== 0) {
|
|
||||||
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Automatically updates the play button when the player is playing
|
|
||||||
mpv.on('resumed', () => {
|
|
||||||
getMainWindow()?.webContents.send('renderer-player-play');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Automatically updates the play button when the player is stopped
|
|
||||||
mpv.on('stopped', () => {
|
|
||||||
getMainWindow()?.webContents.send('renderer-player-stop');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Automatically updates the play button when the player is paused
|
|
||||||
mpv.on('paused', () => {
|
|
||||||
getMainWindow()?.webContents.send('renderer-player-pause');
|
|
||||||
});
|
|
||||||
|
|
||||||
mpv.on('quit', () => {
|
|
||||||
console.log('mpv quit');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Starts the player
|
// Starts the player
|
||||||
ipcMain.on('player-play', async () => {
|
ipcMain.on('player-play', async () => {
|
||||||
await mpv.play();
|
await getMpvInstance()?.play();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pauses the player
|
// Pauses the player
|
||||||
ipcMain.on('player-pause', async () => {
|
ipcMain.on('player-pause', async () => {
|
||||||
await mpv.pause();
|
await getMpvInstance()?.pause();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stops the player
|
// Stops the player
|
||||||
ipcMain.on('player-stop', async () => {
|
ipcMain.on('player-stop', async () => {
|
||||||
await mpv.stop();
|
await getMpvInstance()?.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Goes to the next track in the playlist
|
// Goes to the next track in the playlist
|
||||||
ipcMain.on('player-next', async () => {
|
ipcMain.on('player-next', async () => {
|
||||||
await mpv.next();
|
await getMpvInstance()?.next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Goes to the previous track in the playlist
|
// Goes to the previous track in the playlist
|
||||||
ipcMain.on('player-previous', async () => {
|
ipcMain.on('player-previous', async () => {
|
||||||
await mpv.prev();
|
await getMpvInstance()?.prev();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seeks forward or backward by the given amount of seconds
|
// Seeks forward or backward by the given amount of seconds
|
||||||
ipcMain.on('player-seek', async (_event, time: number) => {
|
ipcMain.on('player-seek', async (_event, time: number) => {
|
||||||
await mpv.seek(time);
|
await getMpvInstance()?.seek(time);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seeks to the given time in seconds
|
// Seeks to the given time in seconds
|
||||||
ipcMain.on('player-seek-to', async (_event, time: number) => {
|
ipcMain.on('player-seek-to', async (_event, time: number) => {
|
||||||
await mpv.goToPosition(time);
|
await getMpvInstance()?.goToPosition(time);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sets the queue in position 0 and 1 to the given data. Used when manually starting a song or using the next/prev buttons
|
// 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) => {
|
||||||
if (data.queue.current) {
|
if (!data.queue.current && !data.queue.next) {
|
||||||
await mpv.load(data.queue.current.streamUrl, 'replace');
|
await getMpvInstance()?.clearPlaylist();
|
||||||
|
await getMpvInstance()?.pause();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.queue.next) {
|
let complete = false;
|
||||||
await mpv.load(data.queue.next.streamUrl, 'append');
|
|
||||||
|
while (!complete) {
|
||||||
|
try {
|
||||||
|
if (data.queue.current) {
|
||||||
|
await getMpvInstance()?.load(data.queue.current.streamUrl, 'replace');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.queue.next) {
|
||||||
|
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||||
|
}
|
||||||
|
|
||||||
|
complete = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
await wait(500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replaces the queue in position 1 to the given data
|
// Replaces the queue in position 1 to the given data
|
||||||
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
ipcMain.on('player-set-queue-next', async (_event, data: PlayerData) => {
|
||||||
const size = await mpv.getPlaylistSize();
|
const size = await getMpvInstance()?.getPlaylistSize();
|
||||||
|
|
||||||
|
if (!size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (size > 1) {
|
if (size > 1) {
|
||||||
await mpv.playlistRemove(1);
|
await getMpvInstance()?.playlistRemove(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.queue.next) {
|
if (data.queue.next) {
|
||||||
await mpv.load(data.queue.next.streamUrl, 'append');
|
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,23 +101,23 @@ ipcMain.on('player-auto-next', async (_event, data: PlayerData) => {
|
|||||||
// Always keep the current song as position 0 in the mpv queue
|
// 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
|
// This allows us to easily set update the next song in the queue without
|
||||||
// disturbing the currently playing song
|
// disturbing the currently playing song
|
||||||
await mpv.playlistRemove(0);
|
await getMpvInstance()?.playlistRemove(0);
|
||||||
|
|
||||||
if (data.queue.next) {
|
if (data.queue.next) {
|
||||||
await mpv.load(data.queue.next.streamUrl, 'append');
|
await getMpvInstance()?.load(data.queue.next.streamUrl, 'append');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sets the volume to the given value (0-100)
|
// Sets the volume to the given value (0-100)
|
||||||
ipcMain.on('player-volume', async (_event, value: number) => {
|
ipcMain.on('player-volume', async (_event, value: number) => {
|
||||||
await mpv.volume(value);
|
await getMpvInstance()?.volume(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggles the mute status
|
// Toggles the mute status
|
||||||
ipcMain.on('player-mute', async () => {
|
ipcMain.on('player-mute', async () => {
|
||||||
await mpv.mute();
|
await getMpvInstance()?.mute();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('player-quit', async () => {
|
ipcMain.on('player-quit', async () => {
|
||||||
await mpv.quit();
|
await getMpvInstance()?.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
import './mpris';
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { ipcMain } from 'electron';
|
||||||
|
import Player from 'mpris-service';
|
||||||
|
import { QueueSong, RelatedArtist } from '../../../renderer/api/types';
|
||||||
|
import { getMainWindow } from '../../main';
|
||||||
|
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const mprisPlayer = Player({
|
||||||
|
identity: 'Feishin',
|
||||||
|
maximumRate: 1.0,
|
||||||
|
minimumRate: 1.0,
|
||||||
|
name: 'Feishin',
|
||||||
|
rate: 1.0,
|
||||||
|
supportedInterfaces: ['player'],
|
||||||
|
supportedMimeTypes: ['audio/mpeg', 'application/ogg'],
|
||||||
|
supportedUriSchemes: ['file'],
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('quit', () => {
|
||||||
|
process.exit();
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('stop', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||||
|
mprisPlayer.playbackStatus = 'Paused';
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('pause', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||||
|
mprisPlayer.playbackStatus = 'Paused';
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('play', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-play');
|
||||||
|
mprisPlayer.playbackStatus = 'Playing';
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('playpause', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-play-pause');
|
||||||
|
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||||
|
mprisPlayer.playbackStatus = 'Playing';
|
||||||
|
} else {
|
||||||
|
mprisPlayer.playbackStatus = 'Paused';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('next', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-next');
|
||||||
|
|
||||||
|
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||||
|
mprisPlayer.playbackStatus = 'Playing';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('previous', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-previous');
|
||||||
|
|
||||||
|
if (mprisPlayer.playbackStatus !== 'Playing') {
|
||||||
|
mprisPlayer.playbackStatus = Player.PLAYBACK_STATUS_PLAYING;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('volume', (event: any) => {
|
||||||
|
getMainWindow()?.webContents.send('mpris-request-volume', {
|
||||||
|
volume: event,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('shuffle', (event: boolean) => {
|
||||||
|
getMainWindow()?.webContents.send('mpris-request-toggle-shuffle', { shuffle: event });
|
||||||
|
mprisPlayer.shuffle = event;
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('loopStatus', (event: string) => {
|
||||||
|
getMainWindow()?.webContents.send('mpris-request-toggle-repeat', { repeat: event });
|
||||||
|
mprisPlayer.loopStatus = event;
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('position', (event: any) => {
|
||||||
|
getMainWindow()?.webContents.send('mpris-request-position', {
|
||||||
|
position: event.position / 1e6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mprisPlayer.on('seek', (event: number) => {
|
||||||
|
getMainWindow()?.webContents.send('mpris-request-seek', {
|
||||||
|
offset: event / 1e6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('mpris-update-position', (_event, arg) => {
|
||||||
|
mprisPlayer.getPosition = () => arg * 1e6;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('mpris-update-seek', (_event, arg) => {
|
||||||
|
mprisPlayer.seeked(arg * 1e6);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('mpris-update-volume', (_event, arg) => {
|
||||||
|
mprisPlayer.volume = Number(arg);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('mpris-update-repeat', (_event, arg) => {
|
||||||
|
mprisPlayer.loopStatus = arg;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on('mpris-update-shuffle', (_event, arg) => {
|
||||||
|
mprisPlayer.shuffle = arg;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on(
|
||||||
|
'mpris-update-song',
|
||||||
|
(
|
||||||
|
_event,
|
||||||
|
args: {
|
||||||
|
currentTime: number;
|
||||||
|
repeat: PlayerRepeat;
|
||||||
|
shuffle: PlayerShuffle;
|
||||||
|
song: QueueSong;
|
||||||
|
status: PlayerStatus;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { song, status, repeat, shuffle } = args || {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
mprisPlayer.playbackStatus = status;
|
||||||
|
|
||||||
|
if (repeat) {
|
||||||
|
mprisPlayer.loopStatus =
|
||||||
|
repeat === 'all' ? 'Playlist' : repeat === 'one' ? 'Track' : 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shuffle) {
|
||||||
|
mprisPlayer.shuffle = shuffle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!song) return;
|
||||||
|
|
||||||
|
const upsizedImageUrl = song.imageUrl
|
||||||
|
? song.imageUrl
|
||||||
|
?.replace(/&size=\d+/, '&size=300')
|
||||||
|
.replace(/\?width=\d+/, '?width=300')
|
||||||
|
.replace(/&height=\d+/, '&height=300')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
mprisPlayer.metadata = {
|
||||||
|
'mpris:artUrl': upsizedImageUrl,
|
||||||
|
'mpris:length': song.duration ? Math.round((song.duration || 0) * 1e6) : null,
|
||||||
|
'mpris:trackid': song?.id
|
||||||
|
? mprisPlayer.objectPath(`track/${song.id?.replace('-', '')}`)
|
||||||
|
: '',
|
||||||
|
'xesam:album': song.album || null,
|
||||||
|
'xesam:albumArtist': song.albumArtists?.length ? song.albumArtists[0].name : null,
|
||||||
|
'xesam:artist':
|
||||||
|
song.artists?.length !== 0
|
||||||
|
? song.artists?.map((artist: RelatedArtist) => artist.name)
|
||||||
|
: null,
|
||||||
|
'xesam:discNumber': song.discNumber ? song.discNumber : null,
|
||||||
|
'xesam:genre': song.genres?.length ? song.genres.map((genre: any) => genre.name) : null,
|
||||||
|
'xesam:title': song.name || null,
|
||||||
|
'xesam:trackNumber': song.trackNumber ? song.trackNumber : null,
|
||||||
|
'xesam:useCount':
|
||||||
|
song.playCount !== null && song.playCount !== undefined ? song.playCount : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -9,16 +9,29 @@
|
|||||||
* `./src/main.js` using webpack. This gives us some performance wins.
|
* `./src/main.js` using webpack. This gives us some performance wins.
|
||||||
*/
|
*/
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { app, BrowserWindow, shell, ipcMain, globalShortcut } from 'electron';
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
shell,
|
||||||
|
ipcMain,
|
||||||
|
globalShortcut,
|
||||||
|
Tray,
|
||||||
|
Menu,
|
||||||
|
nativeImage,
|
||||||
|
} from 'electron';
|
||||||
import electronLocalShortcut from 'electron-localshortcut';
|
import electronLocalShortcut from 'electron-localshortcut';
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
|
import uniq from 'lodash/uniq';
|
||||||
|
import MpvAPI from 'node-mpv';
|
||||||
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
import { disableMediaKeys, enableMediaKeys } from './features/core/player/media-keys';
|
||||||
import { store } from './features/core/settings/index';
|
import { store } from './features/core/settings/index';
|
||||||
import MenuBuilder from './menu';
|
import MenuBuilder from './menu';
|
||||||
import { resolveHtmlPath } from './utils';
|
import { isLinux, isMacOS, isWindows, resolveHtmlPath } from './utils';
|
||||||
import './features';
|
import './features';
|
||||||
|
|
||||||
|
declare module 'node-mpv';
|
||||||
|
|
||||||
export default class AppUpdater {
|
export default class AppUpdater {
|
||||||
constructor() {
|
constructor() {
|
||||||
log.transports.file.level = 'info';
|
log.transports.file.level = 'info';
|
||||||
@@ -27,7 +40,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 mainWindow: BrowserWindow | null = null;
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
let exitFromTray = false;
|
||||||
|
let forceQuit = false;
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const sourceMapSupport = require('source-map-support');
|
const sourceMapSupport = require('source-map-support');
|
||||||
@@ -53,19 +77,111 @@ const installExtensions = async () => {
|
|||||||
.catch(console.log);
|
.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()) {
|
||||||
|
console.log('setting buttons');
|
||||||
|
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 () => {
|
const createWindow = async () => {
|
||||||
if (isDevelopment) {
|
if (isDevelopment) {
|
||||||
await installExtensions();
|
await installExtensions();
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
frame: false,
|
frame: false,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -81,6 +197,7 @@ const createWindow = async () => {
|
|||||||
preload: app.isPackaged
|
preload: app.isPackaged
|
||||||
? path.join(__dirname, 'preload.js')
|
? path.join(__dirname, 'preload.js')
|
||||||
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
: path.join(__dirname, '../../.erb/dll/preload.js'),
|
||||||
|
webSecurity: store.get('ignore_cors') ? false : undefined,
|
||||||
},
|
},
|
||||||
width: 1440,
|
width: 1440,
|
||||||
});
|
});
|
||||||
@@ -89,6 +206,10 @@ const createWindow = async () => {
|
|||||||
mainWindow?.webContents.openDevTools();
|
mainWindow?.webContents.openDevTools();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.on('window-dev-tools', () => {
|
||||||
|
mainWindow?.webContents.openDevTools();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('window-maximize', () => {
|
ipcMain.on('window-maximize', () => {
|
||||||
mainWindow?.maximize();
|
mainWindow?.maximize();
|
||||||
});
|
});
|
||||||
@@ -134,6 +255,7 @@ const createWindow = async () => {
|
|||||||
mainWindow.minimize();
|
mainWindow.minimize();
|
||||||
} else {
|
} else {
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
|
createWinThumbarButtons();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,6 +263,33 @@ const createWindow = async () => {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!exitFromTray && store.get('window_exit_to_tray')) {
|
||||||
|
if (isMacOS() && !forceQuit) {
|
||||||
|
exitFromTray = true;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow?.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
const menuBuilder = new MenuBuilder(mainWindow);
|
||||||
menuBuilder.buildMenu();
|
menuBuilder.buildMenu();
|
||||||
|
|
||||||
@@ -150,30 +299,121 @@ const createWindow = async () => {
|
|||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove this if your app does not use auto updates
|
if (store.get('disable_auto_updates') !== true) {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
new AppUpdater();
|
new AppUpdater();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Add event listeners...
|
|
||||||
*/
|
|
||||||
|
|
||||||
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling,MediaSessionService');
|
||||||
|
|
||||||
export const getMainWindow = () => {
|
const MPV_BINARY_PATH = store.get('mpv_path') as string | undefined;
|
||||||
return mainWindow;
|
const MPV_PARAMETERS = store.get('mpv_parameters') as Array<string> | undefined;
|
||||||
|
|
||||||
|
const prefetchPlaylistParams = [
|
||||||
|
'--prefetch-playlist=no',
|
||||||
|
'--prefetch-playlist=yes',
|
||||||
|
'--prefetch-playlist',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_MPV_PARAMETERS = () => {
|
||||||
|
const parameters = [];
|
||||||
|
|
||||||
|
if (!MPV_PARAMETERS?.some((param) => prefetchPlaylistParams.includes(param))) {
|
||||||
|
parameters.push('--prefetch-playlist=yes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mpvInstance: MpvAPI | null = null;
|
||||||
|
|
||||||
|
const createMpv = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
|
const { extraParameters, properties } = data;
|
||||||
|
|
||||||
|
mpvInstance = new MpvAPI(
|
||||||
|
{
|
||||||
|
audio_only: true,
|
||||||
|
auto_restart: false,
|
||||||
|
binary: MPV_BINARY_PATH || '',
|
||||||
|
time_update: 1,
|
||||||
|
},
|
||||||
|
MPV_PARAMETERS || extraParameters
|
||||||
|
? uniq([...DEFAULT_MPV_PARAMETERS(), ...(MPV_PARAMETERS || []), ...(extraParameters || [])])
|
||||||
|
: DEFAULT_MPV_PARAMETERS(),
|
||||||
|
);
|
||||||
|
|
||||||
|
mpvInstance.setMultipleProperties(properties || {});
|
||||||
|
|
||||||
|
mpvInstance.start().catch((error) => {
|
||||||
|
console.log('error starting mpv', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
mpvInstance.on('status', (status) => {
|
||||||
|
if (status.property === 'playlist-pos') {
|
||||||
|
if (status.value !== 0) {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-auto-next');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically updates the play button when the player is playing
|
||||||
|
mpvInstance.on('resumed', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-play');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically updates the play button when the player is stopped
|
||||||
|
mpvInstance.on('stopped', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-stop');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically updates the play button when the player is paused
|
||||||
|
mpvInstance.on('paused', () => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-pause');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event output every interval set by time_update, used to update the current time
|
||||||
|
mpvInstance.on('timeposition', (time: number) => {
|
||||||
|
getMainWindow()?.webContents.send('renderer-player-current-time', time);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on(
|
||||||
|
'player-restart',
|
||||||
|
async (_event, data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
|
mpvInstance?.quit();
|
||||||
|
createMpv(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
mainWindow?.webContents.send('renderer-player-quit');
|
getMpvInstance()?.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
getMpvInstance()?.quit();
|
||||||
// Respect the OSX convention of having the application in memory even
|
// Respect the OSX convention of having the application in memory even
|
||||||
// after all windows have been closed
|
// after all windows have been closed
|
||||||
globalShortcut.unregisterAll();
|
if (isMacOS()) {
|
||||||
if (process.platform !== 'darwin') {
|
mainWindow = null;
|
||||||
|
} else {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -182,6 +422,7 @@ app
|
|||||||
.whenReady()
|
.whenReady()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
createTray();
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
// On macOS it's common to re-create a window in the app when the
|
// 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.
|
// dock icon is clicked and there are no other windows open.
|
||||||
|
|||||||
@@ -1,109 +1,17 @@
|
|||||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
|
import { contextBridge } from 'electron';
|
||||||
import { PlayerData } from '../renderer/store';
|
|
||||||
import { browser } from './preload/browser';
|
import { browser } from './preload/browser';
|
||||||
import { ipc } from './preload/ipc';
|
import { ipc } from './preload/ipc';
|
||||||
import { localSettings } from './preload/local-settings';
|
import { localSettings } from './preload/local-settings';
|
||||||
|
import { mpris } from './preload/mpris';
|
||||||
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
import { mpvPlayer, mpvPlayerListener } from './preload/mpv-player';
|
||||||
|
import { utils } from './preload/utils';
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
browser,
|
browser,
|
||||||
ipc,
|
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,
|
localSettings,
|
||||||
|
mpris,
|
||||||
mpvPlayer,
|
mpvPlayer,
|
||||||
mpvPlayerListener,
|
mpvPlayerListener,
|
||||||
|
utils,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ const unmaximize = () => {
|
|||||||
ipcRenderer.send('window-unmaximize');
|
ipcRenderer.send('window-unmaximize');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const devtools = () => {
|
||||||
|
ipcRenderer.send('window-dev-tools');
|
||||||
|
};
|
||||||
|
|
||||||
export const browser = {
|
export const browser = {
|
||||||
|
devtools,
|
||||||
exit,
|
exit,
|
||||||
maximize,
|
maximize,
|
||||||
minimize,
|
minimize,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { IpcRendererEvent, ipcRenderer } from 'electron';
|
||||||
|
import { QueueSong } from '/@/renderer/api/types';
|
||||||
|
|
||||||
|
const updateSong = (args: { currentTime: number; song: QueueSong }) => {
|
||||||
|
ipcRenderer.send('mpris-update-song', args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePosition = (timeSec: number) => {
|
||||||
|
ipcRenderer.send('mpris-update-position', timeSec);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSeek = (timeSec: number) => {
|
||||||
|
ipcRenderer.send('mpris-update-seek', timeSec);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateVolume = (volume: number) => {
|
||||||
|
ipcRenderer.send('mpris-update-volume', volume);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRepeat = (repeat: string) => {
|
||||||
|
ipcRenderer.send('mpris-update-repeat', repeat);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShuffle = (shuffle: boolean) => {
|
||||||
|
ipcRenderer.send('mpris-update-shuffle', shuffle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRepeat = () => {
|
||||||
|
ipcRenderer.send('mpris-toggle-repeat');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleShuffle = () => {
|
||||||
|
ipcRenderer.send('mpris-toggle-shuffle');
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestPosition = (cb: (event: IpcRendererEvent, data: { position: number }) => void) => {
|
||||||
|
ipcRenderer.on('mpris-request-position', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestSeek = (cb: (event: IpcRendererEvent, data: { offset: number }) => void) => {
|
||||||
|
ipcRenderer.on('mpris-request-seek', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestVolume = (cb: (event: IpcRendererEvent, data: { volume: number }) => void) => {
|
||||||
|
ipcRenderer.on('mpris-request-volume', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestToggleRepeat = (cb: (event: IpcRendererEvent) => void) => {
|
||||||
|
ipcRenderer.on('mpris-request-toggle-repeat', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestToggleShuffle = (cb: (event: IpcRendererEvent) => void) => {
|
||||||
|
ipcRenderer.on('mpris-request-toggle-shuffle', cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mpris = {
|
||||||
|
requestPosition,
|
||||||
|
requestSeek,
|
||||||
|
requestToggleRepeat,
|
||||||
|
requestToggleShuffle,
|
||||||
|
requestVolume,
|
||||||
|
toggleRepeat,
|
||||||
|
toggleShuffle,
|
||||||
|
updatePosition,
|
||||||
|
updateRepeat,
|
||||||
|
updateSeek,
|
||||||
|
updateShuffle,
|
||||||
|
updateSong,
|
||||||
|
updateVolume,
|
||||||
|
};
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
import { ipcRenderer, IpcRendererEvent } from 'electron';
|
||||||
import { PlayerData } from '/@/renderer/store';
|
import { PlayerData } from '/@/renderer/store';
|
||||||
|
|
||||||
|
const restart = (data: { extraParameters?: string[]; properties?: Record<string, any> }) => {
|
||||||
|
ipcRenderer.send('player-restart', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProperties = (data: Record<string, any>) => {
|
||||||
|
console.log('Setting property :>>', data);
|
||||||
|
ipcRenderer.send('player-set-properties', data);
|
||||||
|
};
|
||||||
|
|
||||||
const autoNext = (data: PlayerData) => {
|
const autoNext = (data: PlayerData) => {
|
||||||
ipcRenderer.send('player-auto-next', data);
|
ipcRenderer.send('player-auto-next', data);
|
||||||
};
|
};
|
||||||
@@ -8,36 +17,47 @@ const autoNext = (data: PlayerData) => {
|
|||||||
const currentTime = () => {
|
const currentTime = () => {
|
||||||
ipcRenderer.send('player-current-time');
|
ipcRenderer.send('player-current-time');
|
||||||
};
|
};
|
||||||
|
|
||||||
const mute = () => {
|
const mute = () => {
|
||||||
ipcRenderer.send('player-mute');
|
ipcRenderer.send('player-mute');
|
||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
ipcRenderer.send('player-next');
|
ipcRenderer.send('player-next');
|
||||||
};
|
};
|
||||||
|
|
||||||
const pause = () => {
|
const pause = () => {
|
||||||
ipcRenderer.send('player-pause');
|
ipcRenderer.send('player-pause');
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
ipcRenderer.send('player-play');
|
ipcRenderer.send('player-play');
|
||||||
};
|
};
|
||||||
|
|
||||||
const previous = () => {
|
const previous = () => {
|
||||||
ipcRenderer.send('player-previous');
|
ipcRenderer.send('player-previous');
|
||||||
};
|
};
|
||||||
|
|
||||||
const seek = (seconds: number) => {
|
const seek = (seconds: number) => {
|
||||||
ipcRenderer.send('player-seek', seconds);
|
ipcRenderer.send('player-seek', seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const seekTo = (seconds: number) => {
|
const seekTo = (seconds: number) => {
|
||||||
ipcRenderer.send('player-seek-to', seconds);
|
ipcRenderer.send('player-seek-to', seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueue = (data: PlayerData) => {
|
const setQueue = (data: PlayerData) => {
|
||||||
ipcRenderer.send('player-set-queue', data);
|
ipcRenderer.send('player-set-queue', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setQueueNext = (data: PlayerData) => {
|
const setQueueNext = (data: PlayerData) => {
|
||||||
ipcRenderer.send('player-set-queue-next', data);
|
ipcRenderer.send('player-set-queue-next', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
ipcRenderer.send('player-stop');
|
ipcRenderer.send('player-stop');
|
||||||
};
|
};
|
||||||
|
|
||||||
const volume = (value: number) => {
|
const volume = (value: number) => {
|
||||||
ipcRenderer.send('player-volume', value);
|
ipcRenderer.send('player-volume', value);
|
||||||
};
|
};
|
||||||
@@ -91,8 +111,10 @@ export const mpvPlayer = {
|
|||||||
play,
|
play,
|
||||||
previous,
|
previous,
|
||||||
quit,
|
quit,
|
||||||
|
restart,
|
||||||
seek,
|
seek,
|
||||||
seekTo,
|
seekTo,
|
||||||
|
setProperties,
|
||||||
setQueue,
|
setQueue,
|
||||||
setQueueNext,
|
setQueueNext,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { isMacOS, isWindows, isLinux } from '../utils';
|
||||||
|
|
||||||
|
export const utils = {
|
||||||
|
isLinux,
|
||||||
|
isMacOS,
|
||||||
|
isWindows,
|
||||||
|
};
|
||||||
@@ -1,69 +1,91 @@
|
|||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
import { navidromeApi } from '/@/renderer/api/navidrome.api';
|
import { toast } from '/@/renderer/components/toast/index';
|
||||||
import { toast } from '/@/renderer/components/toast';
|
|
||||||
import type {
|
import type {
|
||||||
AlbumDetailArgs,
|
AlbumDetailArgs,
|
||||||
RawAlbumDetailResponse,
|
|
||||||
RawAlbumListResponse,
|
|
||||||
AlbumListArgs,
|
AlbumListArgs,
|
||||||
SongListArgs,
|
SongListArgs,
|
||||||
RawSongListResponse,
|
|
||||||
SongDetailArgs,
|
SongDetailArgs,
|
||||||
RawSongDetailResponse,
|
|
||||||
AlbumArtistDetailArgs,
|
AlbumArtistDetailArgs,
|
||||||
RawAlbumArtistDetailResponse,
|
|
||||||
AlbumArtistListArgs,
|
AlbumArtistListArgs,
|
||||||
RawAlbumArtistListResponse,
|
SetRatingArgs,
|
||||||
RatingArgs,
|
|
||||||
RawRatingResponse,
|
|
||||||
FavoriteArgs,
|
|
||||||
RawFavoriteResponse,
|
|
||||||
GenreListArgs,
|
GenreListArgs,
|
||||||
RawGenreListResponse,
|
|
||||||
CreatePlaylistArgs,
|
CreatePlaylistArgs,
|
||||||
RawCreatePlaylistResponse,
|
|
||||||
DeletePlaylistArgs,
|
DeletePlaylistArgs,
|
||||||
RawDeletePlaylistResponse,
|
|
||||||
PlaylistDetailArgs,
|
PlaylistDetailArgs,
|
||||||
RawPlaylistDetailResponse,
|
|
||||||
PlaylistListArgs,
|
PlaylistListArgs,
|
||||||
RawPlaylistListResponse,
|
|
||||||
MusicFolderListArgs,
|
MusicFolderListArgs,
|
||||||
RawMusicFolderListResponse,
|
|
||||||
PlaylistSongListArgs,
|
PlaylistSongListArgs,
|
||||||
ArtistListArgs,
|
ArtistListArgs,
|
||||||
RawArtistListResponse,
|
|
||||||
UpdatePlaylistArgs,
|
UpdatePlaylistArgs,
|
||||||
RawUpdatePlaylistResponse,
|
UserListArgs,
|
||||||
|
FavoriteArgs,
|
||||||
|
TopSongListArgs,
|
||||||
|
AddToPlaylistArgs,
|
||||||
|
AddToPlaylistResponse,
|
||||||
|
RemoveFromPlaylistArgs,
|
||||||
|
RemoveFromPlaylistResponse,
|
||||||
|
ScrobbleArgs,
|
||||||
|
ScrobbleResponse,
|
||||||
|
AlbumArtistDetailResponse,
|
||||||
|
FavoriteResponse,
|
||||||
|
CreatePlaylistResponse,
|
||||||
|
AlbumArtistListResponse,
|
||||||
|
AlbumDetailResponse,
|
||||||
|
AlbumListResponse,
|
||||||
|
ArtistListResponse,
|
||||||
|
GenreListResponse,
|
||||||
|
MusicFolderListResponse,
|
||||||
|
PlaylistDetailResponse,
|
||||||
|
PlaylistListResponse,
|
||||||
|
RatingResponse,
|
||||||
|
SongDetailResponse,
|
||||||
|
SongListResponse,
|
||||||
|
TopSongListResponse,
|
||||||
|
UpdatePlaylistResponse,
|
||||||
|
UserListResponse,
|
||||||
|
AuthenticationResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
import { ServerType } from '/@/renderer/types';
|
||||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
import { DeletePlaylistResponse } from './types';
|
||||||
|
import { ndController } from '/@/renderer/api/navidrome/navidrome-controller';
|
||||||
|
import { ssController } from '/@/renderer/api/subsonic/subsonic-controller';
|
||||||
|
import { jfController } from '/@/renderer/api/jellyfin/jellyfin-controller';
|
||||||
|
|
||||||
export type ControllerEndpoint = Partial<{
|
export type ControllerEndpoint = Partial<{
|
||||||
|
addToPlaylist: (args: AddToPlaylistArgs) => Promise<AddToPlaylistResponse>;
|
||||||
|
authenticate: (
|
||||||
|
url: string,
|
||||||
|
body: { password: string; username: string },
|
||||||
|
) => Promise<AuthenticationResponse>;
|
||||||
clearPlaylist: () => void;
|
clearPlaylist: () => void;
|
||||||
createFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
|
createFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<RawCreatePlaylistResponse>;
|
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
|
||||||
deleteFavorite: (args: FavoriteArgs) => Promise<RawFavoriteResponse>;
|
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||||
deletePlaylist: (args: DeletePlaylistArgs) => Promise<RawDeletePlaylistResponse>;
|
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
|
||||||
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<RawAlbumArtistDetailResponse>;
|
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
|
||||||
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<RawAlbumArtistListResponse>;
|
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
|
||||||
getAlbumDetail: (args: AlbumDetailArgs) => Promise<RawAlbumDetailResponse>;
|
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
|
||||||
getAlbumList: (args: AlbumListArgs) => Promise<RawAlbumListResponse>;
|
getAlbumList: (args: AlbumListArgs) => Promise<AlbumListResponse>;
|
||||||
getArtistDetail: () => void;
|
getArtistDetail: () => void;
|
||||||
getArtistList: (args: ArtistListArgs) => Promise<RawArtistListResponse>;
|
getArtistInfo: (args: any) => void;
|
||||||
|
getArtistList: (args: ArtistListArgs) => Promise<ArtistListResponse>;
|
||||||
getFavoritesList: () => void;
|
getFavoritesList: () => void;
|
||||||
getFolderItemList: () => void;
|
getFolderItemList: () => void;
|
||||||
getFolderList: () => void;
|
getFolderList: () => void;
|
||||||
getFolderSongs: () => void;
|
getFolderSongs: () => void;
|
||||||
getGenreList: (args: GenreListArgs) => Promise<RawGenreListResponse>;
|
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
|
||||||
getMusicFolderList: (args: MusicFolderListArgs) => Promise<RawMusicFolderListResponse>;
|
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
|
||||||
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<RawPlaylistDetailResponse>;
|
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
|
||||||
getPlaylistList: (args: PlaylistListArgs) => Promise<RawPlaylistListResponse>;
|
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<RawSongListResponse>;
|
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||||
getSongDetail: (args: SongDetailArgs) => Promise<RawSongDetailResponse>;
|
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||||
getSongList: (args: SongListArgs) => Promise<RawSongListResponse>;
|
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||||
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||||
|
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||||
|
setRating: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||||
|
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ApiController = {
|
type ApiController = {
|
||||||
@@ -74,86 +96,105 @@ type ApiController = {
|
|||||||
|
|
||||||
const endpoints: ApiController = {
|
const endpoints: ApiController = {
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
|
addToPlaylist: jfController.addToPlaylist,
|
||||||
|
authenticate: jfController.authenticate,
|
||||||
clearPlaylist: undefined,
|
clearPlaylist: undefined,
|
||||||
createFavorite: jellyfinApi.createFavorite,
|
createFavorite: jfController.createFavorite,
|
||||||
createPlaylist: jellyfinApi.createPlaylist,
|
createPlaylist: jfController.createPlaylist,
|
||||||
deleteFavorite: jellyfinApi.deleteFavorite,
|
deleteFavorite: jfController.deleteFavorite,
|
||||||
deletePlaylist: jellyfinApi.deletePlaylist,
|
deletePlaylist: jfController.deletePlaylist,
|
||||||
getAlbumArtistDetail: jellyfinApi.getAlbumArtistDetail,
|
getAlbumArtistDetail: jfController.getAlbumArtistDetail,
|
||||||
getAlbumArtistList: jellyfinApi.getAlbumArtistList,
|
getAlbumArtistList: jfController.getAlbumArtistList,
|
||||||
getAlbumDetail: jellyfinApi.getAlbumDetail,
|
getAlbumDetail: jfController.getAlbumDetail,
|
||||||
getAlbumList: jellyfinApi.getAlbumList,
|
getAlbumList: jfController.getAlbumList,
|
||||||
getArtistDetail: 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,
|
|
||||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
|
||||||
updateRating: undefined,
|
|
||||||
},
|
|
||||||
navidrome: {
|
|
||||||
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,
|
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
getFolderSongs: undefined,
|
getFolderSongs: undefined,
|
||||||
getGenreList: navidromeApi.getGenreList,
|
getGenreList: jfController.getGenreList,
|
||||||
getMusicFolderList: subsonicApi.getMusicFolderList,
|
getMusicFolderList: jfController.getMusicFolderList,
|
||||||
getPlaylistDetail: navidromeApi.getPlaylistDetail,
|
getPlaylistDetail: jfController.getPlaylistDetail,
|
||||||
getPlaylistList: navidromeApi.getPlaylistList,
|
getPlaylistList: jfController.getPlaylistList,
|
||||||
getPlaylistSongList: navidromeApi.getPlaylistSongList,
|
getPlaylistSongList: jfController.getPlaylistSongList,
|
||||||
getSongDetail: navidromeApi.getSongDetail,
|
getSongDetail: undefined,
|
||||||
getSongList: navidromeApi.getSongList,
|
getSongList: jfController.getSongList,
|
||||||
updatePlaylist: navidromeApi.updatePlaylist,
|
getTopSongs: jfController.getTopSongList,
|
||||||
updateRating: subsonicApi.updateRating,
|
getUserList: undefined,
|
||||||
|
removeFromPlaylist: jfController.removeFromPlaylist,
|
||||||
|
scrobble: jfController.scrobble,
|
||||||
|
setRating: undefined,
|
||||||
|
updatePlaylist: jfController.updatePlaylist,
|
||||||
|
},
|
||||||
|
navidrome: {
|
||||||
|
addToPlaylist: ndController.addToPlaylist,
|
||||||
|
authenticate: ndController.authenticate,
|
||||||
|
clearPlaylist: undefined,
|
||||||
|
createFavorite: ssController.createFavorite,
|
||||||
|
createPlaylist: ndController.createPlaylist,
|
||||||
|
deleteFavorite: ssController.removeFavorite,
|
||||||
|
deletePlaylist: ndController.deletePlaylist,
|
||||||
|
getAlbumArtistDetail: ndController.getAlbumArtistDetail,
|
||||||
|
getAlbumArtistList: ndController.getAlbumArtistList,
|
||||||
|
getAlbumDetail: ndController.getAlbumDetail,
|
||||||
|
getAlbumList: ndController.getAlbumList,
|
||||||
|
getArtistDetail: undefined,
|
||||||
|
getArtistInfo: undefined,
|
||||||
|
getArtistList: undefined,
|
||||||
|
getFavoritesList: undefined,
|
||||||
|
getFolderItemList: undefined,
|
||||||
|
getFolderList: undefined,
|
||||||
|
getFolderSongs: undefined,
|
||||||
|
getGenreList: ndController.getGenreList,
|
||||||
|
getMusicFolderList: ssController.getMusicFolderList,
|
||||||
|
getPlaylistDetail: ndController.getPlaylistDetail,
|
||||||
|
getPlaylistList: ndController.getPlaylistList,
|
||||||
|
getPlaylistSongList: ndController.getPlaylistSongList,
|
||||||
|
getSongDetail: ndController.getSongDetail,
|
||||||
|
getSongList: ndController.getSongList,
|
||||||
|
getTopSongs: ssController.getTopSongList,
|
||||||
|
getUserList: ndController.getUserList,
|
||||||
|
removeFromPlaylist: ndController.removeFromPlaylist,
|
||||||
|
scrobble: ssController.scrobble,
|
||||||
|
setRating: ssController.setRating,
|
||||||
|
updatePlaylist: ndController.updatePlaylist,
|
||||||
},
|
},
|
||||||
subsonic: {
|
subsonic: {
|
||||||
|
authenticate: ssController.authenticate,
|
||||||
clearPlaylist: undefined,
|
clearPlaylist: undefined,
|
||||||
createFavorite: subsonicApi.createFavorite,
|
createFavorite: ssController.createFavorite,
|
||||||
createPlaylist: undefined,
|
createPlaylist: undefined,
|
||||||
deleteFavorite: subsonicApi.deleteFavorite,
|
deleteFavorite: ssController.removeFavorite,
|
||||||
deletePlaylist: undefined,
|
deletePlaylist: undefined,
|
||||||
getAlbumArtistDetail: subsonicApi.getAlbumArtistDetail,
|
getAlbumArtistDetail: undefined,
|
||||||
getAlbumArtistList: subsonicApi.getAlbumArtistList,
|
getAlbumArtistList: undefined,
|
||||||
getAlbumDetail: subsonicApi.getAlbumDetail,
|
getAlbumDetail: undefined,
|
||||||
getAlbumList: subsonicApi.getAlbumList,
|
getAlbumList: undefined,
|
||||||
getArtistDetail: undefined,
|
getArtistDetail: undefined,
|
||||||
|
getArtistInfo: undefined,
|
||||||
getArtistList: undefined,
|
getArtistList: undefined,
|
||||||
getFavoritesList: undefined,
|
getFavoritesList: undefined,
|
||||||
getFolderItemList: undefined,
|
getFolderItemList: undefined,
|
||||||
getFolderList: undefined,
|
getFolderList: undefined,
|
||||||
getFolderSongs: undefined,
|
getFolderSongs: undefined,
|
||||||
getGenreList: undefined,
|
getGenreList: undefined,
|
||||||
getMusicFolderList: subsonicApi.getMusicFolderList,
|
getMusicFolderList: ssController.getMusicFolderList,
|
||||||
getPlaylistDetail: undefined,
|
getPlaylistDetail: undefined,
|
||||||
getPlaylistList: undefined,
|
getPlaylistList: undefined,
|
||||||
getSongDetail: undefined,
|
getSongDetail: undefined,
|
||||||
getSongList: undefined,
|
getSongList: undefined,
|
||||||
|
getTopSongs: ssController.getTopSongList,
|
||||||
|
getUserList: undefined,
|
||||||
|
scrobble: ssController.scrobble,
|
||||||
|
setRating: undefined,
|
||||||
updatePlaylist: undefined,
|
updatePlaylist: undefined,
|
||||||
updateRating: undefined,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiController = (endpoint: keyof ControllerEndpoint) => {
|
const apiController = (endpoint: keyof ControllerEndpoint, type?: ServerType) => {
|
||||||
const serverType = useAuthStore.getState().currentServer?.type;
|
const serverType = type || useAuthStore.getState().currentServer?.type;
|
||||||
|
|
||||||
if (!serverType) {
|
if (!serverType) {
|
||||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||||
@@ -173,63 +214,214 @@ const apiController = (endpoint: keyof ControllerEndpoint) => {
|
|||||||
return endpoints[serverType][endpoint];
|
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) => {
|
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) => {
|
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) => {
|
const getSongList = async (args: SongListArgs) => {
|
||||||
return (apiController('getSongList') as ControllerEndpoint['getSongList'])?.(args);
|
return (
|
||||||
|
apiController(
|
||||||
|
'getSongList',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getSongList']
|
||||||
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMusicFolderList = async (args: MusicFolderListArgs) => {
|
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) => {
|
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',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getAlbumArtistDetail']
|
||||||
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAlbumArtistList = async (args: AlbumArtistListArgs) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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) => {
|
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',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['addToPlaylist']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromPlaylist = async (args: RemoveFromPlaylistArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'removeFromPlaylist',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['removeFromPlaylist']
|
||||||
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPlaylistDetail = async (args: PlaylistDetailArgs) => {
|
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) => {
|
const getPlaylistSongList = async (args: PlaylistSongListArgs) => {
|
||||||
return (apiController('getPlaylistSongList') as ControllerEndpoint['getPlaylistSongList'])?.(
|
return (
|
||||||
args,
|
apiController(
|
||||||
);
|
'getPlaylistSongList',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getPlaylistSongList']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserList = async (args: UserListArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'getUserList',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getUserList']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFavorite = async (args: FavoriteArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'createFavorite',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['createFavorite']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFavorite = async (args: FavoriteArgs) => {
|
||||||
|
return (
|
||||||
|
apiController(
|
||||||
|
'deleteFavorite',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['deleteFavorite']
|
||||||
|
)?.(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',
|
||||||
|
args.apiClientProps.server?.type,
|
||||||
|
) as ControllerEndpoint['getTopSongs']
|
||||||
|
)?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrobble = async (args: ScrobbleArgs) => {
|
||||||
|
return (
|
||||||
|
apiController('scrobble', args.apiClientProps.server?.type) as ControllerEndpoint['scrobble']
|
||||||
|
)?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
|
addToPlaylist,
|
||||||
|
authenticate,
|
||||||
|
createFavorite,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
deleteFavorite,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
|
getAlbumArtistDetail,
|
||||||
getAlbumArtistList,
|
getAlbumArtistList,
|
||||||
getAlbumDetail,
|
getAlbumDetail,
|
||||||
getAlbumList,
|
getAlbumList,
|
||||||
@@ -240,5 +432,10 @@ export const controller = {
|
|||||||
getPlaylistList,
|
getPlaylistList,
|
||||||
getPlaylistSongList,
|
getPlaylistSongList,
|
||||||
getSongList,
|
getSongList,
|
||||||
|
getTopSongList,
|
||||||
|
getUserList,
|
||||||
|
removeFromPlaylist,
|
||||||
|
scrobble,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
|
updateRating,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { controller } from '/@/renderer/api/controller';
|
import { controller } from '/@/renderer/api/controller';
|
||||||
import { normalize } from '/@/renderer/api/normalize';
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
controller,
|
controller,
|
||||||
normalize,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,847 +0,0 @@
|
|||||||
import ky from 'ky';
|
|
||||||
import { nanoid } from 'nanoid/non-secure';
|
|
||||||
import type {
|
|
||||||
JFAlbum,
|
|
||||||
JFAlbumArtist,
|
|
||||||
JFAlbumArtistDetail,
|
|
||||||
JFAlbumArtistDetailResponse,
|
|
||||||
JFAlbumArtistList,
|
|
||||||
JFAlbumArtistListParams,
|
|
||||||
JFAlbumArtistListResponse,
|
|
||||||
JFAlbumDetail,
|
|
||||||
JFAlbumDetailResponse,
|
|
||||||
JFAlbumList,
|
|
||||||
JFAlbumListParams,
|
|
||||||
JFAlbumListResponse,
|
|
||||||
JFArtistList,
|
|
||||||
JFArtistListParams,
|
|
||||||
JFArtistListResponse,
|
|
||||||
JFAuthenticate,
|
|
||||||
JFCreatePlaylistResponse,
|
|
||||||
JFGenreList,
|
|
||||||
JFGenreListResponse,
|
|
||||||
JFMusicFolderList,
|
|
||||||
JFMusicFolderListResponse,
|
|
||||||
JFPlaylist,
|
|
||||||
JFPlaylistDetail,
|
|
||||||
JFPlaylistDetailResponse,
|
|
||||||
JFPlaylistList,
|
|
||||||
JFPlaylistListResponse,
|
|
||||||
JFSong,
|
|
||||||
JFSongList,
|
|
||||||
JFSongListParams,
|
|
||||||
JFSongListResponse,
|
|
||||||
} from '/@/renderer/api/jellyfin.types';
|
|
||||||
import { JFCollectionType } from '/@/renderer/api/jellyfin.types';
|
|
||||||
import {
|
|
||||||
Album,
|
|
||||||
AlbumArtist,
|
|
||||||
AlbumArtistDetailArgs,
|
|
||||||
AlbumArtistListArgs,
|
|
||||||
AlbumDetailArgs,
|
|
||||||
AlbumListArgs,
|
|
||||||
ArtistListArgs,
|
|
||||||
AuthenticationResponse,
|
|
||||||
CreatePlaylistArgs,
|
|
||||||
CreatePlaylistResponse,
|
|
||||||
DeletePlaylistArgs,
|
|
||||||
FavoriteArgs,
|
|
||||||
FavoriteResponse,
|
|
||||||
GenreListArgs,
|
|
||||||
MusicFolderListArgs,
|
|
||||||
Playlist,
|
|
||||||
PlaylistDetailArgs,
|
|
||||||
PlaylistListArgs,
|
|
||||||
playlistListSortMap,
|
|
||||||
PlaylistSongListArgs,
|
|
||||||
Song,
|
|
||||||
SongListArgs,
|
|
||||||
songListSortMap,
|
|
||||||
albumListSortMap,
|
|
||||||
artistListSortMap,
|
|
||||||
sortOrderMap,
|
|
||||||
albumArtistListSortMap,
|
|
||||||
UpdatePlaylistArgs,
|
|
||||||
UpdatePlaylistResponse,
|
|
||||||
} from '/@/renderer/api/types';
|
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
|
||||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
|
||||||
import { parseSearchParams } from '/@/renderer/utils';
|
|
||||||
|
|
||||||
const getCommaDelimitedString = (value: string[]) => {
|
|
||||||
return value.join(',');
|
|
||||||
};
|
|
||||||
|
|
||||||
const api = ky.create({});
|
|
||||||
|
|
||||||
const authenticate = async (
|
|
||||||
url: string,
|
|
||||||
body: {
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
},
|
|
||||||
): Promise<AuthenticationResponse> => {
|
|
||||||
const cleanServerUrl = url.replace(/\/$/, '');
|
|
||||||
|
|
||||||
const data = await ky
|
|
||||||
.post(`${cleanServerUrl}/users/authenticatebyname`, {
|
|
||||||
headers: {
|
|
||||||
'X-Emby-Authorization':
|
|
||||||
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1"',
|
|
||||||
},
|
|
||||||
json: {
|
|
||||||
pw: body.password,
|
|
||||||
username: body.username,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.json<JFAuthenticate>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
credential: data.AccessToken,
|
|
||||||
userId: data.User.Id,
|
|
||||||
username: data.User.Name,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<JFMusicFolderList> => {
|
|
||||||
const { server, signal } = args;
|
|
||||||
const userId = useAuthStore.getState().currentServer?.userId;
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`users/${userId}/items`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFMusicFolderListResponse>();
|
|
||||||
|
|
||||||
const musicFolders = data.Items.filter(
|
|
||||||
(folder) => folder.CollectionType === JFCollectionType.MUSIC,
|
|
||||||
);
|
|
||||||
|
|
||||||
return musicFolders;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGenreList = async (args: GenreListArgs): Promise<JFGenreList> => {
|
|
||||||
const { signal, server } = args;
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get('genres', {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFGenreListResponse>();
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAlbumArtistDetail = async (args: AlbumArtistDetailArgs): Promise<JFAlbumArtistDetail> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const searchParams = {
|
|
||||||
fields: 'Genres',
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`users/${server?.userId}/items/${query.id}`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: parseSearchParams(searchParams),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFAlbumArtistDetailResponse>();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
// const getAlbumArtistAlbums = () => {
|
|
||||||
// const { data: albumData } = await api.get(`/users/${auth.username}/items`, {
|
|
||||||
// params: {
|
|
||||||
// artistIds: options.id,
|
|
||||||
// fields: 'AudioInfo, ParentId, Genres, DateCreated, ChildCount, ParentId',
|
|
||||||
// includeItemTypes: 'MusicAlbum',
|
|
||||||
// parentId: options.musicFolderId,
|
|
||||||
// recursive: true,
|
|
||||||
// sortBy: 'SortName',
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const { data: similarData } = await api.get(`/artists/${options.id}/similar`, {
|
|
||||||
// params: { limit: 15, parentId: options.musicFolderId, userId: auth.username },
|
|
||||||
// });
|
|
||||||
// };
|
|
||||||
|
|
||||||
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<JFAlbumArtistList> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const searchParams: JFAlbumArtistListParams = {
|
|
||||||
fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
limit: query.limit,
|
|
||||||
parentId: query.musicFolderId,
|
|
||||||
recursive: true,
|
|
||||||
searchTerm: query.searchTerm,
|
|
||||||
sortBy: albumArtistListSortMap.jellyfin[query.sortBy],
|
|
||||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
userId: server?.userId || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get('artists/albumArtists', {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: parseSearchParams(searchParams),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFAlbumArtistListResponse>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: data.Items,
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
totalRecordCount: data.TotalRecordCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getArtistList = async (args: ArtistListArgs): Promise<JFArtistList> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const searchParams: JFArtistListParams = {
|
|
||||||
limit: query.limit,
|
|
||||||
parentId: query.musicFolderId,
|
|
||||||
recursive: true,
|
|
||||||
sortBy: artistListSortMap.jellyfin[query.sortBy],
|
|
||||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get('artists', {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: parseSearchParams(searchParams),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFArtistListResponse>();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<JFAlbumDetail> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const searchParams = {
|
|
||||||
fields: 'Genres, DateCreated, ChildCount',
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`users/${server?.userId}/items/${query.id}`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFAlbumDetailResponse>();
|
|
||||||
|
|
||||||
const songsSearchParams = {
|
|
||||||
fields: 'Genres, DateCreated, MediaSources, ParentId',
|
|
||||||
parentId: query.id,
|
|
||||||
sortBy: 'SortName',
|
|
||||||
};
|
|
||||||
|
|
||||||
const songsData = await api
|
|
||||||
.get(`users/${server?.userId}/items`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: songsSearchParams,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFSongListResponse>();
|
|
||||||
|
|
||||||
return { ...data, songs: songsData.Items };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAlbumList = async (args: AlbumListArgs): Promise<JFAlbumList> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const yearsGroup = [];
|
|
||||||
if (query.jfParams?.minYear && query.jfParams?.maxYear) {
|
|
||||||
for (let i = Number(query.jfParams.minYear); i <= Number(query.jfParams.maxYear); i += 1) {
|
|
||||||
yearsGroup.push(String(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
|
|
||||||
|
|
||||||
const searchParams: JFAlbumListParams & { maxYear?: number; minYear?: number } = {
|
|
||||||
includeItemTypes: 'MusicAlbum',
|
|
||||||
limit: query.limit,
|
|
||||||
parentId: query.musicFolderId,
|
|
||||||
recursive: true,
|
|
||||||
searchTerm: query.searchTerm,
|
|
||||||
sortBy: albumListSortMap.jellyfin[query.sortBy],
|
|
||||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
...query.jfParams,
|
|
||||||
maxYear: undefined,
|
|
||||||
minYear: undefined,
|
|
||||||
years: yearsFilter,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`users/${server?.userId}/items`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: parseSearchParams(searchParams),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFAlbumListResponse>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: data.Items,
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
totalRecordCount: data.TotalRecordCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSongList = async (args: SongListArgs): Promise<JFSongList> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const yearsGroup = [];
|
|
||||||
if (query.jfParams?.minYear && query.jfParams?.maxYear) {
|
|
||||||
for (let i = Number(query.jfParams.minYear); i <= Number(query.jfParams.maxYear); i += 1) {
|
|
||||||
yearsGroup.push(String(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const yearsFilter = yearsGroup.length ? getCommaDelimitedString(yearsGroup) : undefined;
|
|
||||||
const albumIdsFilter = query.albumIds ? getCommaDelimitedString(query.albumIds) : undefined;
|
|
||||||
const artistIdsFilter = query.artistIds ? getCommaDelimitedString(query.artistIds) : undefined;
|
|
||||||
|
|
||||||
const searchParams: JFSongListParams & { maxYear?: number; minYear?: number } = {
|
|
||||||
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],
|
|
||||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
...query.jfParams,
|
|
||||||
maxYear: undefined,
|
|
||||||
minYear: undefined,
|
|
||||||
years: yearsFilter,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`users/${server?.userId}/items`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: parseSearchParams(searchParams),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFSongListResponse>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: data.Items,
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
totalRecordCount: data.TotalRecordCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<JFPlaylistDetail> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const searchParams = {
|
|
||||||
fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
|
|
||||||
ids: query.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`users/${server?.userId}/items/${query.id}`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFPlaylistDetailResponse>();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<JFSongList> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const searchParams: JFSongListParams = {
|
|
||||||
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: server?.userId || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`playlists/${query.id}/items`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: parseSearchParams(searchParams),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFSongListResponse>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: data.Items,
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
totalRecordCount: data.TotalRecordCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlaylistList = async (args: PlaylistListArgs): Promise<JFPlaylistList> => {
|
|
||||||
const { query, server, signal } = args;
|
|
||||||
|
|
||||||
const searchParams = {
|
|
||||||
fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
|
||||||
includeItemTypes: 'Playlist',
|
|
||||||
limit: query.limit,
|
|
||||||
recursive: true,
|
|
||||||
sortBy: playlistListSortMap.jellyfin[query.sortBy],
|
|
||||||
sortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
||||||
startIndex: query.startIndex,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get(`users/${server?.userId}/items`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: parseSearchParams(searchParams),
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<JFPlaylistListResponse>();
|
|
||||||
|
|
||||||
const playlistItems = data.Items.filter((item) => item.MediaType === 'Audio');
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: playlistItems,
|
|
||||||
startIndex: 0,
|
|
||||||
totalRecordCount: playlistItems.length,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
|
||||||
const { query, server } = args;
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
MediaType: 'Audio',
|
|
||||||
Name: query.name,
|
|
||||||
Overview: query.comment || '',
|
|
||||||
UserId: server?.userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.post('playlists', {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
json: body,
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
})
|
|
||||||
.json<JFCreatePlaylistResponse>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: data.Id,
|
|
||||||
name: query.name,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
|
||||||
const { query, body, server } = args;
|
|
||||||
|
|
||||||
const json = {
|
|
||||||
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
|
||||||
MediaType: 'Audio',
|
|
||||||
Name: body.name,
|
|
||||||
Overview: body.comment || '',
|
|
||||||
PremiereDate: null,
|
|
||||||
ProviderIds: {},
|
|
||||||
Tags: [],
|
|
||||||
UserId: server?.userId, // Required
|
|
||||||
};
|
|
||||||
|
|
||||||
await api
|
|
||||||
.post(`items/${query.id}`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
json,
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
})
|
|
||||||
.json<null>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: query.id,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
|
|
||||||
const { query, server } = args;
|
|
||||||
|
|
||||||
await api.delete(`items/${query.id}`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
|
||||||
const { query, server } = args;
|
|
||||||
|
|
||||||
await api.post(`users/${server?.userId}/favoriteitems/${query.id}`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: query.id,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
|
||||||
const { query, server } = args;
|
|
||||||
|
|
||||||
await api.delete(`users/${server?.userId}/favoriteitems/${query.id}`, {
|
|
||||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: query.id,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStreamUrl = (args: {
|
|
||||||
container?: string;
|
|
||||||
deviceId: string;
|
|
||||||
eTag?: string;
|
|
||||||
id: string;
|
|
||||||
mediaSourceId?: string;
|
|
||||||
server: ServerListItem;
|
|
||||||
}) => {
|
|
||||||
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: JFAlbumArtist;
|
|
||||||
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: JFSong; size: number }) => {
|
|
||||||
const size = args.size ? args.size : 300;
|
|
||||||
|
|
||||||
if (!args.item.ImageTags?.Primary) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.item.ImageTags.Primary) {
|
|
||||||
return (
|
|
||||||
`${args.baseUrl}/Items` +
|
|
||||||
`/${args.item.Id}` +
|
|
||||||
'/Images/Primary' +
|
|
||||||
`?width=${size}&height=${size}` +
|
|
||||||
'&quality=96'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.item?.AlbumPrimaryImageTag) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to album art if no image embedded
|
|
||||||
return (
|
|
||||||
`${args.baseUrl}/Items` +
|
|
||||||
`/${args.item?.AlbumId}` +
|
|
||||||
'/Images/Primary' +
|
|
||||||
`?width=${size}&height=${size}` +
|
|
||||||
'&quality=96'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
|
||||||
const size = args.size ? args.size : 300;
|
|
||||||
|
|
||||||
if (!args.item.ImageTags?.Primary) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
`${args.baseUrl}/Items` +
|
|
||||||
`/${args.item.Id}` +
|
|
||||||
'/Images/Primary' +
|
|
||||||
`?width=${size}&height=${size}` +
|
|
||||||
'&quality=96'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeSong = (
|
|
||||||
item: JFSong,
|
|
||||||
server: ServerListItem,
|
|
||||||
deviceId: string,
|
|
||||||
imageSize?: number,
|
|
||||||
): Song => {
|
|
||||||
return {
|
|
||||||
album: item.Album,
|
|
||||||
albumArtists: item.AlbumArtists?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
|
||||||
albumId: item.AlbumId,
|
|
||||||
artistName: item.ArtistItems[0]?.Name,
|
|
||||||
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
|
||||||
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
|
||||||
bpm: null,
|
|
||||||
channels: 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 || 300 }),
|
|
||||||
isFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
|
||||||
lastPlayedAt: null,
|
|
||||||
name: item.Name,
|
|
||||||
note: null,
|
|
||||||
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
|
||||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
|
||||||
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
|
||||||
releaseDate: null,
|
|
||||||
releaseYear: (item.ProductionYear && String(item.ProductionYear)) || null,
|
|
||||||
serverId: server.id,
|
|
||||||
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,
|
|
||||||
type: ServerType.JELLYFIN,
|
|
||||||
uniqueId: nanoid(),
|
|
||||||
updatedAt: item.DateCreated,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAlbum = (item: JFAlbum, server: ServerListItem, imageSize?: number): Album => {
|
|
||||||
return {
|
|
||||||
albumArtists:
|
|
||||||
item.AlbumArtists.map((entry) => ({
|
|
||||||
id: entry.Id,
|
|
||||||
name: entry.Name,
|
|
||||||
})) || [],
|
|
||||||
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, 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,
|
|
||||||
isFavorite: item.UserData?.IsFavorite || false,
|
|
||||||
lastPlayedAt: null,
|
|
||||||
name: item.Name,
|
|
||||||
playCount: item.UserData?.PlayCount || 0,
|
|
||||||
rating: null,
|
|
||||||
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
|
||||||
releaseYear: item.ProductionYear,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAlbumArtist = (
|
|
||||||
item: JFAlbumArtist,
|
|
||||||
server: ServerListItem,
|
|
||||||
imageSize?: number,
|
|
||||||
): AlbumArtist => {
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
isFavorite: item.UserData.IsFavorite || false,
|
|
||||||
lastPlayedAt: null,
|
|
||||||
name: item.Name,
|
|
||||||
playCount: item.UserData.PlayCount,
|
|
||||||
rating: null,
|
|
||||||
songCount: null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePlaylist = (
|
|
||||||
item: JFPlaylist,
|
|
||||||
server: ServerListItem,
|
|
||||||
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,
|
|
||||||
name: item.Name,
|
|
||||||
public: null,
|
|
||||||
rules: null,
|
|
||||||
size: null,
|
|
||||||
songCount: item?.ChildCount || null,
|
|
||||||
userId: null,
|
|
||||||
username: null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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: any) => {
|
|
||||||
// return {
|
|
||||||
// albumCount: undefined,
|
|
||||||
// id: item.Id,
|
|
||||||
// songCount: undefined,
|
|
||||||
// title: item.Name,
|
|
||||||
// type: Item.Genre,
|
|
||||||
// uniqueId: nanoid(),
|
|
||||||
// };
|
|
||||||
// };
|
|
||||||
|
|
||||||
// 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 jellyfinApi = {
|
|
||||||
authenticate,
|
|
||||||
createFavorite,
|
|
||||||
createPlaylist,
|
|
||||||
deleteFavorite,
|
|
||||||
deletePlaylist,
|
|
||||||
getAlbumArtistDetail,
|
|
||||||
getAlbumArtistList,
|
|
||||||
getAlbumDetail,
|
|
||||||
getAlbumList,
|
|
||||||
getArtistList,
|
|
||||||
getGenreList,
|
|
||||||
getMusicFolderList,
|
|
||||||
getPlaylistDetail,
|
|
||||||
getPlaylistList,
|
|
||||||
getPlaylistSongList,
|
|
||||||
getSongList,
|
|
||||||
updatePlaylist,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const jfNormalize = {
|
|
||||||
album: normalizeAlbum,
|
|
||||||
albumArtist: normalizeAlbumArtist,
|
|
||||||
playlist: normalizePlaylist,
|
|
||||||
song: normalizeSong,
|
|
||||||
};
|
|
||||||
@@ -59,6 +59,25 @@ export type JFSongList = {
|
|||||||
totalRecordCount: number;
|
totalRecordCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JFAddToPlaylistResponse = {
|
||||||
|
added: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JFAddToPlaylistParams = {
|
||||||
|
ids: string[];
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JFAddToPlaylist = null;
|
||||||
|
|
||||||
|
export type JFRemoveFromPlaylistResponse = null;
|
||||||
|
|
||||||
|
export type JFRemoveFromPlaylistParams = {
|
||||||
|
entryIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JFRemoveFromPlaylist = null;
|
||||||
|
|
||||||
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
export interface JFPlaylistListResponse extends JFBasePaginatedResponse {
|
||||||
Items: JFPlaylist[];
|
Items: JFPlaylist[];
|
||||||
}
|
}
|
||||||
@@ -173,6 +192,10 @@ export type JFAlbumArtist = {
|
|||||||
PlaybackPositionTicks: number;
|
PlaybackPositionTicks: number;
|
||||||
Played: boolean;
|
Played: boolean;
|
||||||
};
|
};
|
||||||
|
} & {
|
||||||
|
similarArtists: {
|
||||||
|
items: JFAlbumArtist[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JFArtist = {
|
export type JFArtist = {
|
||||||
@@ -248,6 +271,7 @@ export type JFSong = {
|
|||||||
MediaType: string;
|
MediaType: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
ParentIndexNumber: number;
|
ParentIndexNumber: number;
|
||||||
|
PlaylistItemId?: string;
|
||||||
PremiereDate?: string;
|
PremiereDate?: string;
|
||||||
ProductionYear: number;
|
ProductionYear: number;
|
||||||
RunTimeTicks: number;
|
RunTimeTicks: number;
|
||||||
@@ -543,6 +567,7 @@ export enum JFSongListSort {
|
|||||||
ALBUM = 'Album,SortName',
|
ALBUM = 'Album,SortName',
|
||||||
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
|
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
|
||||||
ARTIST = 'Artist,Album,SortName',
|
ARTIST = 'Artist,Album,SortName',
|
||||||
|
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||||
NAME = 'Name,SortName',
|
NAME = 'Name,SortName',
|
||||||
PLAY_COUNT = 'PlayCount,SortName',
|
PLAY_COUNT = 'PlayCount,SortName',
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
|
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||||
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
|
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
||||||
|
import qs from 'qs';
|
||||||
|
import { toast } from '/@/renderer/components';
|
||||||
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
|
import omitBy from 'lodash/omitBy';
|
||||||
|
|
||||||
|
const c = initContract();
|
||||||
|
|
||||||
|
export const contract = c.router({
|
||||||
|
addToPlaylist: {
|
||||||
|
body: jfType._parameters.addToPlaylist,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'playlists/:id/items',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.addToPlaylist,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authenticate: {
|
||||||
|
body: jfType._parameters.authenticate,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'users/authenticatebyname',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.authenticate,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createFavorite: {
|
||||||
|
body: jfType._parameters.favorite,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'users/:userId/favoriteitems/:id',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.favorite,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createPlaylist: {
|
||||||
|
body: jfType._parameters.createPlaylist,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'playlists',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.createPlaylist,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deletePlaylist: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'items/:id',
|
||||||
|
responses: {
|
||||||
|
204: jfType._response.deletePlaylist,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumArtistDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items/:id',
|
||||||
|
query: jfType._parameters.albumArtistDetail,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.albumArtist,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumArtistList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'artists/albumArtists',
|
||||||
|
query: jfType._parameters.albumArtistList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.albumArtistList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items/:id',
|
||||||
|
query: jfType._parameters.albumDetail,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.album,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items',
|
||||||
|
query: jfType._parameters.albumList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.albumList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getArtistList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'artists',
|
||||||
|
query: jfType._parameters.albumArtistList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.albumArtistList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getGenreList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'genres',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.genreList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getMusicFolderList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.musicFolderList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPlaylistDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items/:id',
|
||||||
|
query: jfType._parameters.playlistDetail,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.playlist,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPlaylistList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items',
|
||||||
|
query: jfType._parameters.playlistList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.playlistList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPlaylistSongList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'playlists/:id/items',
|
||||||
|
query: jfType._parameters.songList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.playlistSongList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getSimilarArtistList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'artists/:id/similar',
|
||||||
|
query: jfType._parameters.similarArtistList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.albumArtistList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getSongDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'song/:id',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.song,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getSongList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items',
|
||||||
|
query: jfType._parameters.songList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.songList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getTopSongsList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'users/:userId/items',
|
||||||
|
query: jfType._parameters.songList,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.topSongsList,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
removeFavorite: {
|
||||||
|
body: jfType._parameters.favorite,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'users/:userId/favoriteitems/:id',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.favorite,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
removeFromPlaylist: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'items/:id',
|
||||||
|
query: jfType._parameters.removeFromPlaylist,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.removeFromPlaylist,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrobblePlaying: {
|
||||||
|
body: jfType._parameters.scrobble,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'sessions/playing',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.scrobble,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrobbleProgress: {
|
||||||
|
body: jfType._parameters.scrobble,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'sessions/playing/progress',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.scrobble,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrobbleStopped: {
|
||||||
|
body: jfType._parameters.scrobble,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'sessions/playing/stopped',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.scrobble,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatePlaylist: {
|
||||||
|
body: jfType._parameters.updatePlaylist,
|
||||||
|
method: 'PUT',
|
||||||
|
path: 'items/:id',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.updatePlaylist,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const axiosClient = axios.create({});
|
||||||
|
|
||||||
|
axiosClient.defaults.paramsSerializer = (params) => {
|
||||||
|
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
toast.error({
|
||||||
|
message: 'Your session has expired.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentServer = useAuthStore.getState().currentServer;
|
||||||
|
|
||||||
|
if (currentServer) {
|
||||||
|
const serverId = currentServer.id;
|
||||||
|
const token = currentServer.credential;
|
||||||
|
console.log(`token is expired: ${token}`);
|
||||||
|
useAuthStore.getState().actions.setCurrentServer(null);
|
||||||
|
useAuthStore.getState().actions.updateServer(serverId, { credential: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsePath = (fullPath: string) => {
|
||||||
|
const [path, params] = fullPath.split('?');
|
||||||
|
|
||||||
|
const parsedParams = qs.parse(params);
|
||||||
|
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||||
|
|
||||||
|
return {
|
||||||
|
params: notNilParams,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jfApiClient = (args: {
|
||||||
|
server: ServerListItem | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
url?: string;
|
||||||
|
}) => {
|
||||||
|
const { server, url, signal } = args;
|
||||||
|
|
||||||
|
return initClient(contract, {
|
||||||
|
api: async ({ path, method, headers, body }) => {
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
let token: string | undefined;
|
||||||
|
|
||||||
|
const { params, path: api } = parsePath(path);
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
baseUrl = `${server?.url}`;
|
||||||
|
token = server?.credential;
|
||||||
|
} else {
|
||||||
|
baseUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await axiosClient.request({
|
||||||
|
data: body,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(token && { 'X-MediaBrowser-Token': token }),
|
||||||
|
},
|
||||||
|
method: method as Method,
|
||||||
|
params,
|
||||||
|
signal,
|
||||||
|
url: `${baseUrl}/${api}`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
body: result.data,
|
||||||
|
status: result.status,
|
||||||
|
};
|
||||||
|
} catch (e: Error | AxiosError | any) {
|
||||||
|
if (isAxiosError(e)) {
|
||||||
|
const error = e as AxiosError;
|
||||||
|
const response = error.response as AxiosResponse;
|
||||||
|
return {
|
||||||
|
body: response.data,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseHeaders: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
baseUrl: '',
|
||||||
|
jsonQuery: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
import {
|
||||||
|
AuthenticationResponse,
|
||||||
|
MusicFolderListArgs,
|
||||||
|
MusicFolderListResponse,
|
||||||
|
GenreListArgs,
|
||||||
|
AlbumArtistDetailArgs,
|
||||||
|
AlbumArtistListArgs,
|
||||||
|
albumArtistListSortMap,
|
||||||
|
sortOrderMap,
|
||||||
|
ArtistListArgs,
|
||||||
|
artistListSortMap,
|
||||||
|
AlbumDetailArgs,
|
||||||
|
AlbumListArgs,
|
||||||
|
albumListSortMap,
|
||||||
|
TopSongListArgs,
|
||||||
|
SongListArgs,
|
||||||
|
songListSortMap,
|
||||||
|
AddToPlaylistArgs,
|
||||||
|
RemoveFromPlaylistArgs,
|
||||||
|
PlaylistDetailArgs,
|
||||||
|
PlaylistSongListArgs,
|
||||||
|
PlaylistListArgs,
|
||||||
|
playlistListSortMap,
|
||||||
|
CreatePlaylistArgs,
|
||||||
|
CreatePlaylistResponse,
|
||||||
|
UpdatePlaylistArgs,
|
||||||
|
UpdatePlaylistResponse,
|
||||||
|
DeletePlaylistArgs,
|
||||||
|
FavoriteArgs,
|
||||||
|
FavoriteResponse,
|
||||||
|
ScrobbleArgs,
|
||||||
|
ScrobbleResponse,
|
||||||
|
GenreListResponse,
|
||||||
|
AlbumArtistDetailResponse,
|
||||||
|
AlbumArtistListResponse,
|
||||||
|
AlbumDetailResponse,
|
||||||
|
AlbumListResponse,
|
||||||
|
SongListResponse,
|
||||||
|
AddToPlaylistResponse,
|
||||||
|
RemoveFromPlaylistResponse,
|
||||||
|
PlaylistDetailResponse,
|
||||||
|
PlaylistListResponse,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
|
import { jfNormalize } from './jellyfin-normalize';
|
||||||
|
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||||
|
import packageJson from '../../../../package.json';
|
||||||
|
|
||||||
|
const formatCommaDelimitedString = (value: string[]) => {
|
||||||
|
return value.join(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const authenticate = async (
|
||||||
|
url: string,
|
||||||
|
body: {
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
},
|
||||||
|
): Promise<AuthenticationResponse> => {
|
||||||
|
const cleanServerUrl = url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||||
|
body: {
|
||||||
|
Pw: body.password,
|
||||||
|
Username: body.username,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to authenticate');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
credential: res.body.AccessToken,
|
||||||
|
userId: res.body.User.Id,
|
||||||
|
username: res.body.User.Name,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
|
||||||
|
const { apiClientProps } = args;
|
||||||
|
const userId = apiClientProps.server?.userId;
|
||||||
|
|
||||||
|
if (!userId) throw new Error('No userId found');
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getMusicFolderList({
|
||||||
|
params: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get genre list');
|
||||||
|
}
|
||||||
|
|
||||||
|
const musicFolders = res.body.Items.filter(
|
||||||
|
(folder) => folder.CollectionType === jfType._enum.collection.MUSIC,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: musicFolders.map(jfNormalize.musicFolder),
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: musicFolders?.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||||
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getGenreList();
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get genre list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map(jfNormalize.genre),
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body?.Items?.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumArtistDetail = async (
|
||||||
|
args: AlbumArtistDetailArgs,
|
||||||
|
): Promise<AlbumArtistDetailResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Fields: 'Genres, Overview',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Limit: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200 || similarArtistsRes.status !== 200) {
|
||||||
|
throw new Error('Failed to get album artist detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jfNormalize.albumArtist(
|
||||||
|
{ ...res.body, similarArtists: similarArtistsRes.body },
|
||||||
|
apiClientProps.server,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||||
|
query: {
|
||||||
|
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
||||||
|
ImageTypeLimit: 1,
|
||||||
|
Limit: query.limit,
|
||||||
|
ParentId: query.musicFolderId,
|
||||||
|
Recursive: true,
|
||||||
|
SearchTerm: query.searchTerm,
|
||||||
|
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||||
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
|
StartIndex: query.startIndex,
|
||||||
|
UserId: apiClientProps.server?.userId || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get album artist list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||||
|
startIndex: query.startIndex,
|
||||||
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
||||||
|
query: {
|
||||||
|
Limit: query.limit,
|
||||||
|
ParentId: query.musicFolderId,
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: artistListSortMap.jellyfin[query.sortBy] || 'Name,SortName',
|
||||||
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
|
StartIndex: query.startIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get artist list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
||||||
|
startIndex: query.startIndex,
|
||||||
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getAlbumDetail({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
userId: apiClientProps.server.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Fields: 'Genres, DateCreated, ChildCount',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const songsRes = await jfApiClient(apiClientProps).getSongList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
ParentId: query.id,
|
||||||
|
SortBy: 'Album,SortName',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200 || songsRes.status !== 200) {
|
||||||
|
throw new Error('Failed to get album detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jfNormalize.album({ ...res.body, Songs: songsRes.body.Items }, apiClientProps.server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsGroup = [];
|
||||||
|
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
|
||||||
|
for (
|
||||||
|
let i = Number(query._custom?.jellyfin?.minYear);
|
||||||
|
i <= Number(query._custom?.jellyfin?.maxYear);
|
||||||
|
i += 1
|
||||||
|
) {
|
||||||
|
yearsGroup.push(String(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getAlbumList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
IncludeItemTypes: 'MusicAlbum',
|
||||||
|
Limit: query.limit,
|
||||||
|
ParentId: query.musicFolderId,
|
||||||
|
Recursive: true,
|
||||||
|
SearchTerm: query.searchTerm,
|
||||||
|
SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',
|
||||||
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
|
StartIndex: query.startIndex,
|
||||||
|
...query._custom?.jellyfin,
|
||||||
|
Years: yearsFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get album list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
||||||
|
startIndex: query.startIndex,
|
||||||
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
|
||||||
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getTopSongsList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
ArtistIds: query.artistId,
|
||||||
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
Limit: query.limit,
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: 'CommunityRating,SortName',
|
||||||
|
SortOrder: 'Descending',
|
||||||
|
UserId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get top song list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsGroup = [];
|
||||||
|
if (query._custom?.jellyfin?.minYear && query._custom?.jellyfin?.maxYear) {
|
||||||
|
for (
|
||||||
|
let i = Number(query._custom?.jellyfin?.minYear);
|
||||||
|
i <= Number(query._custom?.jellyfin?.maxYear);
|
||||||
|
i += 1
|
||||||
|
) {
|
||||||
|
yearsGroup.push(String(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
||||||
|
const albumIdsFilter = query.albumIds ? formatCommaDelimitedString(query.albumIds) : undefined;
|
||||||
|
const artistIdsFilter = query.artistIds ? formatCommaDelimitedString(query.artistIds) : undefined;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getSongList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
AlbumIds: albumIdsFilter,
|
||||||
|
ArtistIds: artistIdsFilter,
|
||||||
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
Limit: query.limit,
|
||||||
|
ParentId: query.musicFolderId,
|
||||||
|
Recursive: true,
|
||||||
|
SearchTerm: query.searchTerm,
|
||||||
|
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
||||||
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
|
StartIndex: query.startIndex,
|
||||||
|
...query._custom?.jellyfin,
|
||||||
|
Years: yearsFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get song list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||||
|
startIndex: query.startIndex,
|
||||||
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
|
||||||
|
const { query, body, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).addToPlaylist({
|
||||||
|
body: {
|
||||||
|
Ids: body.songId,
|
||||||
|
UserId: apiClientProps?.server?.userId,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to add to playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromPlaylist = async (
|
||||||
|
args: RemoveFromPlaylistArgs,
|
||||||
|
): Promise<RemoveFromPlaylistResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
|
||||||
|
body: null,
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
EntryIds: query.songId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to remove from playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getPlaylistDetail({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
|
||||||
|
Ids: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get playlist detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jfNormalize.playlist(res.body, apiClientProps.server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistSongList = async (args: PlaylistSongListArgs): Promise<SongListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getPlaylistSongList({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId',
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
Limit: query.limit,
|
||||||
|
SortBy: query.sortBy ? songListSortMap.jellyfin[query.sortBy] : undefined,
|
||||||
|
SortOrder: query.sortOrder ? sortOrderMap.jellyfin[query.sortOrder] : undefined,
|
||||||
|
StartIndex: 0,
|
||||||
|
UserId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get playlist song list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
||||||
|
startIndex: query.startIndex,
|
||||||
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
||||||
|
params: {
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
||||||
|
IncludeItemTypes: 'Playlist',
|
||||||
|
Limit: query.limit,
|
||||||
|
MediaTypes: 'Audio',
|
||||||
|
Recursive: true,
|
||||||
|
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
||||||
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
||||||
|
StartIndex: query.startIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get playlist list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)),
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||||
|
const { body, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).createPlaylist({
|
||||||
|
body: {
|
||||||
|
MediaType: 'Audio',
|
||||||
|
Name: body.name,
|
||||||
|
Overview: body.comment || '',
|
||||||
|
UserId: apiClientProps.server.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to create playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: res.body.Id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||||
|
const { query, body, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
||||||
|
body: {
|
||||||
|
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
||||||
|
MediaType: 'Audio',
|
||||||
|
Name: body.name,
|
||||||
|
Overview: body.comment || '',
|
||||||
|
PremiereDate: null,
|
||||||
|
ProviderIds: {},
|
||||||
|
Tags: [],
|
||||||
|
UserId: apiClientProps.server?.userId, // Required
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to update playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<null> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await jfApiClient(apiClientProps).deletePlaylist({
|
||||||
|
body: null,
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 204) {
|
||||||
|
throw new Error('Failed to delete playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of query.id) {
|
||||||
|
await jfApiClient(apiClientProps).createFavorite({
|
||||||
|
body: {},
|
||||||
|
params: {
|
||||||
|
id,
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
if (!apiClientProps.server?.userId) {
|
||||||
|
throw new Error('No userId found');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of query.id) {
|
||||||
|
await jfApiClient(apiClientProps).removeFavorite({
|
||||||
|
body: {},
|
||||||
|
params: {
|
||||||
|
id,
|
||||||
|
userId: apiClientProps.server?.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const position = query.position && Math.round(query.position);
|
||||||
|
|
||||||
|
if (query.submission) {
|
||||||
|
// Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
|
||||||
|
jfApiClient(apiClientProps).scrobbleStopped({
|
||||||
|
body: {
|
||||||
|
IsPaused: true,
|
||||||
|
ItemId: query.id,
|
||||||
|
PositionTicks: position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.event === 'start') {
|
||||||
|
jfApiClient(apiClientProps).scrobblePlaying({
|
||||||
|
body: {
|
||||||
|
ItemId: query.id,
|
||||||
|
PositionTicks: position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.event === 'pause') {
|
||||||
|
jfApiClient(apiClientProps).scrobbleProgress({
|
||||||
|
body: {
|
||||||
|
EventName: query.event,
|
||||||
|
IsPaused: true,
|
||||||
|
ItemId: query.id,
|
||||||
|
PositionTicks: position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.event === 'unpause') {
|
||||||
|
jfApiClient(apiClientProps).scrobbleProgress({
|
||||||
|
body: {
|
||||||
|
EventName: query.event,
|
||||||
|
IsPaused: false,
|
||||||
|
ItemId: query.id,
|
||||||
|
PositionTicks: position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
jfApiClient(apiClientProps).scrobbleProgress({
|
||||||
|
body: {
|
||||||
|
ItemId: query.id,
|
||||||
|
PositionTicks: position,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jfController = {
|
||||||
|
addToPlaylist,
|
||||||
|
authenticate,
|
||||||
|
createFavorite,
|
||||||
|
createPlaylist,
|
||||||
|
deleteFavorite,
|
||||||
|
deletePlaylist,
|
||||||
|
getAlbumArtistDetail,
|
||||||
|
getAlbumArtistList,
|
||||||
|
getAlbumDetail,
|
||||||
|
getAlbumList,
|
||||||
|
getArtistList,
|
||||||
|
getGenreList,
|
||||||
|
getMusicFolderList,
|
||||||
|
getPlaylistDetail,
|
||||||
|
getPlaylistList,
|
||||||
|
getPlaylistSongList,
|
||||||
|
getSongList,
|
||||||
|
getTopSongList,
|
||||||
|
removeFromPlaylist,
|
||||||
|
scrobble,
|
||||||
|
updatePlaylist,
|
||||||
|
};
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { JFAlbum, JFPlaylist, JFMusicFolder, JFGenre } from '/@/renderer/api/jellyfin.types';
|
||||||
|
import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
||||||
|
import {
|
||||||
|
Song,
|
||||||
|
LibraryItem,
|
||||||
|
Album,
|
||||||
|
AlbumArtist,
|
||||||
|
Playlist,
|
||||||
|
MusicFolder,
|
||||||
|
Genre,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const getStreamUrl = (args: {
|
||||||
|
container?: string;
|
||||||
|
deviceId: string;
|
||||||
|
eTag?: string;
|
||||||
|
id: string;
|
||||||
|
mediaSourceId?: string;
|
||||||
|
server: ServerListItem | null;
|
||||||
|
}) => {
|
||||||
|
const { id, server, deviceId } = args;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${server?.url}/audio` +
|
||||||
|
`/${id}/universal` +
|
||||||
|
`?userId=${server?.userId}` +
|
||||||
|
`&deviceId=${deviceId}` +
|
||||||
|
'&audioCodec=aac' +
|
||||||
|
`&api_key=${server?.credential}` +
|
||||||
|
`&playSessionId=${deviceId}` +
|
||||||
|
'&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg' +
|
||||||
|
'&transcodingContainer=ts' +
|
||||||
|
'&transcodingProtocol=hls'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumArtistCoverArtUrl = (args: {
|
||||||
|
baseUrl: string;
|
||||||
|
item: z.infer<typeof jfType._response.albumArtist>;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
const size = args.size ? args.size : 300;
|
||||||
|
|
||||||
|
if (!args.item.ImageTags?.Primary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/Items` +
|
||||||
|
`/${args.item.Id}` +
|
||||||
|
'/Images/Primary' +
|
||||||
|
`?width=${size}&height=${size}` +
|
||||||
|
'&quality=96'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumCoverArtUrl = (args: { baseUrl: string; item: JFAlbum; size: number }) => {
|
||||||
|
const size = args.size ? args.size : 300;
|
||||||
|
|
||||||
|
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/Items` +
|
||||||
|
`/${args.item.Id}` +
|
||||||
|
'/Images/Primary' +
|
||||||
|
`?width=${size}&height=${size}` +
|
||||||
|
'&quality=96'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSongCoverArtUrl = (args: {
|
||||||
|
baseUrl: string;
|
||||||
|
item: z.infer<typeof jfType._response.song>;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
const size = args.size ? args.size : 100;
|
||||||
|
|
||||||
|
if (!args.item.ImageTags?.Primary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.item.ImageTags.Primary) {
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/Items` +
|
||||||
|
`/${args.item.Id}` +
|
||||||
|
'/Images/Primary' +
|
||||||
|
`?width=${size}&height=${size}` +
|
||||||
|
'&quality=96'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.item?.AlbumPrimaryImageTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to album art if no image embedded
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/Items` +
|
||||||
|
`/${args.item?.AlbumId}` +
|
||||||
|
'/Images/Primary' +
|
||||||
|
`?width=${size}&height=${size}` +
|
||||||
|
'&quality=96'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistCoverArtUrl = (args: { baseUrl: string; item: JFPlaylist; size: number }) => {
|
||||||
|
const size = args.size ? args.size : 300;
|
||||||
|
|
||||||
|
if (!args.item.ImageTags?.Primary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/Items` +
|
||||||
|
`/${args.item.Id}` +
|
||||||
|
'/Images/Primary' +
|
||||||
|
`?width=${size}&height=${size}` +
|
||||||
|
'&quality=96'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSong = (
|
||||||
|
item: z.infer<typeof jfType._response.song>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
deviceId: string,
|
||||||
|
imageSize?: number,
|
||||||
|
): Song => {
|
||||||
|
return {
|
||||||
|
album: item.Album,
|
||||||
|
albumArtists: item.AlbumArtists?.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
name: entry.Name,
|
||||||
|
})),
|
||||||
|
albumId: item.AlbumId,
|
||||||
|
artistName: item.ArtistItems[0]?.Name,
|
||||||
|
artists: item.ArtistItems.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||||
|
bitRate: item.MediaSources && Number(Math.trunc(item.MediaSources[0]?.Bitrate / 1000)),
|
||||||
|
bpm: null,
|
||||||
|
channels: null,
|
||||||
|
comment: null,
|
||||||
|
compilation: null,
|
||||||
|
container: (item.MediaSources && item.MediaSources[0]?.Container) || null,
|
||||||
|
createdAt: item.DateCreated,
|
||||||
|
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
||||||
|
duration: item.RunTimeTicks / 10000000,
|
||||||
|
genres: item.GenreItems.map((entry: any) => ({ id: entry.Id, name: entry.Name })),
|
||||||
|
id: item.Id,
|
||||||
|
imagePlaceholderUrl: null,
|
||||||
|
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.Name,
|
||||||
|
path: (item.MediaSources && item.MediaSources[0]?.Path) || null,
|
||||||
|
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||||
|
playlistItemId: item.PlaylistItemId,
|
||||||
|
// releaseDate: (item.ProductionYear && new Date(item.ProductionYear, 0, 1).toISOString()) || null,
|
||||||
|
releaseDate: null,
|
||||||
|
releaseYear: item.ProductionYear ? String(item.ProductionYear) : null,
|
||||||
|
serverId: server?.id || '',
|
||||||
|
serverType: ServerType.JELLYFIN,
|
||||||
|
size: item.MediaSources && item.MediaSources[0]?.Size,
|
||||||
|
streamUrl: getStreamUrl({
|
||||||
|
container: item.MediaSources[0]?.Container,
|
||||||
|
deviceId,
|
||||||
|
eTag: item.MediaSources[0]?.ETag,
|
||||||
|
id: item.Id,
|
||||||
|
mediaSourceId: item.MediaSources[0]?.Id,
|
||||||
|
server,
|
||||||
|
}),
|
||||||
|
trackNumber: item.IndexNumber,
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: item.DateCreated,
|
||||||
|
userFavorite: (item.UserData && item.UserData.IsFavorite) || false,
|
||||||
|
userRating: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAlbum = (
|
||||||
|
item: z.infer<typeof jfType._response.album>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
imageSize?: number,
|
||||||
|
): Album => {
|
||||||
|
return {
|
||||||
|
albumArtists:
|
||||||
|
item.AlbumArtists.map((entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: null,
|
||||||
|
name: entry.Name,
|
||||||
|
})) || [],
|
||||||
|
artists: item.ArtistItems?.map((entry) => ({ id: entry.Id, imageUrl: null, name: entry.Name })),
|
||||||
|
backdropImageUrl: null,
|
||||||
|
createdAt: item.DateCreated,
|
||||||
|
duration: item.RunTimeTicks / 10000,
|
||||||
|
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||||
|
id: item.Id,
|
||||||
|
imagePlaceholderUrl: null,
|
||||||
|
imageUrl: getAlbumCoverArtUrl({
|
||||||
|
baseUrl: server?.url || '',
|
||||||
|
item,
|
||||||
|
size: imageSize || 300,
|
||||||
|
}),
|
||||||
|
isCompilation: null,
|
||||||
|
itemType: LibraryItem.ALBUM,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.Name,
|
||||||
|
playCount: item.UserData?.PlayCount || 0,
|
||||||
|
releaseDate: item.PremiereDate?.split('T')[0] || null,
|
||||||
|
releaseYear: item.ProductionYear || null,
|
||||||
|
serverId: server?.id || '',
|
||||||
|
serverType: ServerType.JELLYFIN,
|
||||||
|
size: null,
|
||||||
|
songCount: item?.ChildCount || null,
|
||||||
|
songs: item.Songs?.map((song) => normalizeSong(song, server, '', imageSize)),
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
|
||||||
|
userFavorite: item.UserData?.IsFavorite || false,
|
||||||
|
userRating: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAlbumArtist = (
|
||||||
|
item: z.infer<typeof jfType._response.albumArtist> & {
|
||||||
|
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
|
||||||
|
},
|
||||||
|
server: ServerListItem | null,
|
||||||
|
imageSize?: number,
|
||||||
|
): AlbumArtist => {
|
||||||
|
const similarArtists =
|
||||||
|
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
|
||||||
|
(entry) => ({
|
||||||
|
id: entry.Id,
|
||||||
|
imageUrl: getAlbumArtistCoverArtUrl({
|
||||||
|
baseUrl: server?.url || '',
|
||||||
|
item: entry,
|
||||||
|
size: imageSize || 300,
|
||||||
|
}),
|
||||||
|
name: entry.Name,
|
||||||
|
}),
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumCount: null,
|
||||||
|
backgroundImageUrl: null,
|
||||||
|
biography: item.Overview || null,
|
||||||
|
duration: item.RunTimeTicks / 10000,
|
||||||
|
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||||
|
id: item.Id,
|
||||||
|
imageUrl: getAlbumArtistCoverArtUrl({
|
||||||
|
baseUrl: server?.url || '',
|
||||||
|
item,
|
||||||
|
size: imageSize || 300,
|
||||||
|
}),
|
||||||
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.Name,
|
||||||
|
playCount: item.UserData?.PlayCount || 0,
|
||||||
|
serverId: server?.id || '',
|
||||||
|
serverType: ServerType.JELLYFIN,
|
||||||
|
similarArtists,
|
||||||
|
songCount: null,
|
||||||
|
userFavorite: item.UserData?.IsFavorite || false,
|
||||||
|
userRating: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePlaylist = (
|
||||||
|
item: z.infer<typeof jfType._response.playlist>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
imageSize?: number,
|
||||||
|
): Playlist => {
|
||||||
|
const imageUrl = getPlaylistCoverArtUrl({
|
||||||
|
baseUrl: server?.url || '',
|
||||||
|
item,
|
||||||
|
size: imageSize || 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagePlaceholderUrl = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: item.Overview || null,
|
||||||
|
duration: item.RunTimeTicks / 10000,
|
||||||
|
genres: item.GenreItems?.map((entry) => ({ id: entry.Id, name: entry.Name })),
|
||||||
|
id: item.Id,
|
||||||
|
imagePlaceholderUrl,
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
itemType: LibraryItem.PLAYLIST,
|
||||||
|
name: item.Name,
|
||||||
|
owner: null,
|
||||||
|
ownerId: null,
|
||||||
|
public: null,
|
||||||
|
rules: null,
|
||||||
|
serverId: server?.id || '',
|
||||||
|
serverType: ServerType.JELLYFIN,
|
||||||
|
size: null,
|
||||||
|
songCount: item?.ChildCount || null,
|
||||||
|
sync: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeMusicFolder = (item: JFMusicFolder): MusicFolder => {
|
||||||
|
return {
|
||||||
|
id: item.Id,
|
||||||
|
name: item.Name,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// const normalizeArtist = (item: any) => {
|
||||||
|
// return {
|
||||||
|
// album: (item.album || []).map((entry: any) => normalizeAlbum(entry)),
|
||||||
|
// albumCount: item.AlbumCount,
|
||||||
|
// duration: item.RunTimeTicks / 10000000,
|
||||||
|
// genre: item.GenreItems && item.GenreItems.map((entry: any) => normalizeItem(entry)),
|
||||||
|
// id: item.Id,
|
||||||
|
// image: getCoverArtUrl(item),
|
||||||
|
// info: {
|
||||||
|
// biography: item.Overview,
|
||||||
|
// externalUrl: (item.ExternalUrls || []).map((entry: any) => normalizeItem(entry)),
|
||||||
|
// imageUrl: undefined,
|
||||||
|
// similarArtist: (item.similarArtist || []).map((entry: any) => normalizeArtist(entry)),
|
||||||
|
// },
|
||||||
|
// starred: item.UserData && item.UserData?.IsFavorite ? 'true' : undefined,
|
||||||
|
// title: item.Name,
|
||||||
|
// uniqueId: nanoid(),
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
const normalizeGenre = (item: JFGenre): Genre => {
|
||||||
|
return {
|
||||||
|
albumCount: undefined,
|
||||||
|
id: item.Id,
|
||||||
|
name: item.Name,
|
||||||
|
songCount: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// const normalizeFolder = (item: any) => {
|
||||||
|
// return {
|
||||||
|
// created: item.DateCreated,
|
||||||
|
// id: item.Id,
|
||||||
|
// image: getCoverArtUrl(item, 150),
|
||||||
|
// isDir: true,
|
||||||
|
// title: item.Name,
|
||||||
|
// type: Item.Folder,
|
||||||
|
// uniqueId: nanoid(),
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const normalizeScanStatus = () => {
|
||||||
|
// return {
|
||||||
|
// count: 'N/a',
|
||||||
|
// scanning: false,
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const jfNormalize = {
|
||||||
|
album: normalizeAlbum,
|
||||||
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
genre: normalizeGenre,
|
||||||
|
musicFolder: normalizeMusicFolder,
|
||||||
|
playlist: normalizePlaylist,
|
||||||
|
song: normalizeSong,
|
||||||
|
};
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const sortOrderValues = ['Ascending', 'Descending'] as const;
|
||||||
|
|
||||||
|
const jfExternal = {
|
||||||
|
IMDB: 'Imdb',
|
||||||
|
MUSIC_BRAINZ: 'MusicBrainz',
|
||||||
|
THE_AUDIO_DB: 'TheAudioDb',
|
||||||
|
THE_MOVIE_DB: 'TheMovieDb',
|
||||||
|
TVDB: 'Tvdb',
|
||||||
|
};
|
||||||
|
|
||||||
|
const jfImage = {
|
||||||
|
BACKDROP: 'Backdrop',
|
||||||
|
BANNER: 'Banner',
|
||||||
|
BOX: 'Box',
|
||||||
|
CHAPTER: 'Chapter',
|
||||||
|
DISC: 'Disc',
|
||||||
|
LOGO: 'Logo',
|
||||||
|
PRIMARY: 'Primary',
|
||||||
|
THUMB: 'Thumb',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const jfCollection = {
|
||||||
|
MUSIC: 'music',
|
||||||
|
PLAYLISTS: 'playlists',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const error = z.object({
|
||||||
|
errors: z.object({
|
||||||
|
recursive: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
status: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
traceId: z.string(),
|
||||||
|
type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseParameters = z.object({
|
||||||
|
AlbumArtistIds: z.string().optional(),
|
||||||
|
ArtistIds: z.string().optional(),
|
||||||
|
EnableImageTypes: z.string().optional(),
|
||||||
|
EnableTotalRecordCount: z.boolean().optional(),
|
||||||
|
EnableUserData: z.boolean().optional(),
|
||||||
|
ExcludeItemTypes: z.string().optional(),
|
||||||
|
Fields: z.string().optional(),
|
||||||
|
ImageTypeLimit: z.number().optional(),
|
||||||
|
IncludeItemTypes: z.string().optional(),
|
||||||
|
IsFavorite: z.boolean().optional(),
|
||||||
|
Limit: z.number().optional(),
|
||||||
|
MediaTypes: z.string().optional(),
|
||||||
|
ParentId: z.string().optional(),
|
||||||
|
Recursive: z.boolean().optional(),
|
||||||
|
SearchTerm: z.string().optional(),
|
||||||
|
SortBy: z.string().optional(),
|
||||||
|
SortOrder: z.enum(sortOrderValues).optional(),
|
||||||
|
StartIndex: z.number().optional(),
|
||||||
|
UserId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const paginationParameters = z.object({
|
||||||
|
Limit: z.number().optional(),
|
||||||
|
NameStartsWith: z.string().optional(),
|
||||||
|
SortOrder: z.enum(sortOrderValues).optional(),
|
||||||
|
StartIndex: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pagination = z.object({
|
||||||
|
StartIndex: z.number(),
|
||||||
|
TotalRecordCount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageTags = z.object({
|
||||||
|
Logo: z.string().optional(),
|
||||||
|
Primary: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageBlurHashes = z.object({
|
||||||
|
Backdrop: z.string().optional(),
|
||||||
|
Logo: z.string().optional(),
|
||||||
|
Primary: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userData = z.object({
|
||||||
|
IsFavorite: z.boolean(),
|
||||||
|
Key: z.string(),
|
||||||
|
PlayCount: z.number(),
|
||||||
|
PlaybackPositionTicks: z.number(),
|
||||||
|
Played: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalUrl = z.object({
|
||||||
|
Name: z.string(),
|
||||||
|
Url: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaStream = z.object({
|
||||||
|
AspectRatio: z.string().optional(),
|
||||||
|
BitDepth: z.number().optional(),
|
||||||
|
BitRate: z.number().optional(),
|
||||||
|
ChannelLayout: z.string().optional(),
|
||||||
|
Channels: z.number().optional(),
|
||||||
|
Codec: z.string(),
|
||||||
|
CodecTimeBase: z.string(),
|
||||||
|
ColorSpace: z.string().optional(),
|
||||||
|
Comment: z.string().optional(),
|
||||||
|
DisplayTitle: z.string().optional(),
|
||||||
|
Height: z.number().optional(),
|
||||||
|
Index: z.number(),
|
||||||
|
IsDefault: z.boolean(),
|
||||||
|
IsExternal: z.boolean(),
|
||||||
|
IsForced: z.boolean(),
|
||||||
|
IsInterlaced: z.boolean(),
|
||||||
|
IsTextSubtitleStream: z.boolean(),
|
||||||
|
Level: z.number(),
|
||||||
|
PixelFormat: z.string().optional(),
|
||||||
|
Profile: z.string().optional(),
|
||||||
|
RealFrameRate: z.number().optional(),
|
||||||
|
RefFrames: z.number().optional(),
|
||||||
|
SampleRate: z.number().optional(),
|
||||||
|
SupportsExternalStream: z.boolean(),
|
||||||
|
TimeBase: z.string(),
|
||||||
|
Type: z.string(),
|
||||||
|
Width: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaSources = z.object({
|
||||||
|
Bitrate: z.number(),
|
||||||
|
Container: z.string(),
|
||||||
|
DefaultAudioStreamIndex: z.number(),
|
||||||
|
ETag: z.string(),
|
||||||
|
Formats: z.array(z.any()),
|
||||||
|
GenPtsInput: z.boolean(),
|
||||||
|
Id: z.string(),
|
||||||
|
IgnoreDts: z.boolean(),
|
||||||
|
IgnoreIndex: z.boolean(),
|
||||||
|
IsInfiniteStream: z.boolean(),
|
||||||
|
IsRemote: z.boolean(),
|
||||||
|
MediaAttachments: z.array(z.any()),
|
||||||
|
MediaStreams: z.array(mediaStream),
|
||||||
|
Name: z.string(),
|
||||||
|
Path: z.string(),
|
||||||
|
Protocol: z.string(),
|
||||||
|
ReadAtNativeFramerate: z.boolean(),
|
||||||
|
RequiredHttpHeaders: z.any(),
|
||||||
|
RequiresClosing: z.boolean(),
|
||||||
|
RequiresLooping: z.boolean(),
|
||||||
|
RequiresOpening: z.boolean(),
|
||||||
|
RunTimeTicks: z.number(),
|
||||||
|
Size: z.number(),
|
||||||
|
SupportsDirectPlay: z.boolean(),
|
||||||
|
SupportsDirectStream: z.boolean(),
|
||||||
|
SupportsProbing: z.boolean(),
|
||||||
|
SupportsTranscoding: z.boolean(),
|
||||||
|
Type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionInfo = z.object({
|
||||||
|
AdditionalUsers: z.array(z.any()),
|
||||||
|
ApplicationVersion: z.string(),
|
||||||
|
Capabilities: z.object({
|
||||||
|
PlayableMediaTypes: z.array(z.any()),
|
||||||
|
SupportedCommands: z.array(z.any()),
|
||||||
|
SupportsContentUploading: z.boolean(),
|
||||||
|
SupportsMediaControl: z.boolean(),
|
||||||
|
SupportsPersistentIdentifier: z.boolean(),
|
||||||
|
SupportsSync: z.boolean(),
|
||||||
|
}),
|
||||||
|
Client: z.string(),
|
||||||
|
DeviceId: z.string(),
|
||||||
|
DeviceName: z.string(),
|
||||||
|
HasCustomDeviceName: z.boolean(),
|
||||||
|
Id: z.string(),
|
||||||
|
IsActive: z.boolean(),
|
||||||
|
LastActivityDate: z.string(),
|
||||||
|
LastPlaybackCheckIn: z.string(),
|
||||||
|
NowPlayingQueue: z.array(z.any()),
|
||||||
|
NowPlayingQueueFullItems: z.array(z.any()),
|
||||||
|
PlayState: z.object({
|
||||||
|
CanSeek: z.boolean(),
|
||||||
|
IsMuted: z.boolean(),
|
||||||
|
IsPaused: z.boolean(),
|
||||||
|
RepeatMode: z.string(),
|
||||||
|
}),
|
||||||
|
PlayableMediaTypes: z.array(z.any()),
|
||||||
|
RemoteEndPoint: z.string(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
SupportedCommands: z.array(z.any()),
|
||||||
|
SupportsMediaControl: z.boolean(),
|
||||||
|
SupportsRemoteControl: z.boolean(),
|
||||||
|
UserId: z.string(),
|
||||||
|
UserName: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const configuration = z.object({
|
||||||
|
DisplayCollectionsView: z.boolean(),
|
||||||
|
DisplayMissingEpisodes: z.boolean(),
|
||||||
|
EnableLocalPassword: z.boolean(),
|
||||||
|
EnableNextEpisodeAutoPlay: z.boolean(),
|
||||||
|
GroupedFolders: z.array(z.any()),
|
||||||
|
HidePlayedInLatest: z.boolean(),
|
||||||
|
LatestItemsExcludes: z.array(z.any()),
|
||||||
|
MyMediaExcludes: z.array(z.any()),
|
||||||
|
OrderedViews: z.array(z.any()),
|
||||||
|
PlayDefaultAudioTrack: z.boolean(),
|
||||||
|
RememberAudioSelections: z.boolean(),
|
||||||
|
RememberSubtitleSelections: z.boolean(),
|
||||||
|
SubtitleLanguagePreference: z.string(),
|
||||||
|
SubtitleMode: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const policy = z.object({
|
||||||
|
AccessSchedules: z.array(z.any()),
|
||||||
|
AuthenticationProviderId: z.string(),
|
||||||
|
BlockUnratedItems: z.array(z.any()),
|
||||||
|
BlockedChannels: z.array(z.any()),
|
||||||
|
BlockedMediaFolders: z.array(z.any()),
|
||||||
|
BlockedTags: z.array(z.any()),
|
||||||
|
EnableAllChannels: z.boolean(),
|
||||||
|
EnableAllDevices: z.boolean(),
|
||||||
|
EnableAllFolders: z.boolean(),
|
||||||
|
EnableAudioPlaybackTranscoding: z.boolean(),
|
||||||
|
EnableContentDeletion: z.boolean(),
|
||||||
|
EnableContentDeletionFromFolders: z.array(z.any()),
|
||||||
|
EnableContentDownloading: z.boolean(),
|
||||||
|
EnableLiveTvAccess: z.boolean(),
|
||||||
|
EnableLiveTvManagement: z.boolean(),
|
||||||
|
EnableMediaConversion: z.boolean(),
|
||||||
|
EnableMediaPlayback: z.boolean(),
|
||||||
|
EnablePlaybackRemuxing: z.boolean(),
|
||||||
|
EnablePublicSharing: z.boolean(),
|
||||||
|
EnableRemoteAccess: z.boolean(),
|
||||||
|
EnableRemoteControlOfOtherUsers: z.boolean(),
|
||||||
|
EnableSharedDeviceControl: z.boolean(),
|
||||||
|
EnableSyncTranscoding: z.boolean(),
|
||||||
|
EnableUserPreferenceAccess: z.boolean(),
|
||||||
|
EnableVideoPlaybackTranscoding: z.boolean(),
|
||||||
|
EnabledChannels: z.array(z.any()),
|
||||||
|
EnabledDevices: z.array(z.any()),
|
||||||
|
EnabledFolders: z.array(z.any()),
|
||||||
|
ForceRemoteSourceTranscoding: z.boolean(),
|
||||||
|
InvalidLoginAttemptCount: z.number(),
|
||||||
|
IsAdministrator: z.boolean(),
|
||||||
|
IsDisabled: z.boolean(),
|
||||||
|
IsHidden: z.boolean(),
|
||||||
|
LoginAttemptsBeforeLockout: z.number(),
|
||||||
|
MaxActiveSessions: z.number(),
|
||||||
|
PasswordResetProviderId: z.string(),
|
||||||
|
RemoteClientBitrateLimit: z.number(),
|
||||||
|
SyncPlayAccess: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = z.object({
|
||||||
|
Configuration: configuration,
|
||||||
|
EnableAutoLogin: z.boolean(),
|
||||||
|
HasConfiguredEasyPassword: z.boolean(),
|
||||||
|
HasConfiguredPassword: z.boolean(),
|
||||||
|
HasPassword: z.boolean(),
|
||||||
|
Id: z.string(),
|
||||||
|
LastActivityDate: z.string(),
|
||||||
|
LastLoginDate: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
Policy: policy,
|
||||||
|
ServerId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticateParameters = z.object({
|
||||||
|
Pw: z.string(),
|
||||||
|
Username: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticate = z.object({
|
||||||
|
AccessToken: z.string(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
SessionInfo: sessionInfo,
|
||||||
|
User: user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const genreItem = z.object({
|
||||||
|
Id: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const genre = z.object({
|
||||||
|
BackdropImageTags: z.array(z.any()),
|
||||||
|
ChannelId: z.null(),
|
||||||
|
Id: z.string(),
|
||||||
|
ImageBlurHashes: imageBlurHashes,
|
||||||
|
ImageTags: imageTags,
|
||||||
|
LocationType: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
Type: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const genreList = z.object({
|
||||||
|
Items: z.array(genre),
|
||||||
|
});
|
||||||
|
|
||||||
|
const musicFolder = z.object({
|
||||||
|
BackdropImageTags: z.array(z.string()),
|
||||||
|
ChannelId: z.null(),
|
||||||
|
CollectionType: z.string(),
|
||||||
|
Id: z.string(),
|
||||||
|
ImageBlurHashes: imageBlurHashes,
|
||||||
|
ImageTags: imageTags,
|
||||||
|
IsFolder: z.boolean(),
|
||||||
|
LocationType: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
Type: z.string(),
|
||||||
|
UserData: userData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const musicFolderListParameters = z.object({
|
||||||
|
UserId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const musicFolderList = z.object({
|
||||||
|
Items: z.array(musicFolder),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlist = z.object({
|
||||||
|
BackdropImageTags: z.array(z.string()),
|
||||||
|
ChannelId: z.null(),
|
||||||
|
ChildCount: z.number().optional(),
|
||||||
|
DateCreated: z.string(),
|
||||||
|
GenreItems: z.array(genreItem),
|
||||||
|
Genres: z.array(z.string()),
|
||||||
|
Id: z.string(),
|
||||||
|
ImageBlurHashes: imageBlurHashes,
|
||||||
|
ImageTags: imageTags,
|
||||||
|
IsFolder: z.boolean(),
|
||||||
|
LocationType: z.string(),
|
||||||
|
MediaType: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
Overview: z.string().optional(),
|
||||||
|
RunTimeTicks: z.number(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
Type: z.string(),
|
||||||
|
UserData: userData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const jfPlaylistListSort = {
|
||||||
|
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||||
|
DURATION: 'Runtime',
|
||||||
|
NAME: 'SortName',
|
||||||
|
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||||
|
SONG_COUNT: 'ChildCount',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const playlistListParameters = paginationParameters.merge(
|
||||||
|
baseParameters.extend({
|
||||||
|
IncludeItemTypes: z.literal('Playlist'),
|
||||||
|
SortBy: z.nativeEnum(jfPlaylistListSort).optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const playlistList = pagination.extend({
|
||||||
|
Items: z.array(playlist),
|
||||||
|
});
|
||||||
|
|
||||||
|
const genericItem = z.object({
|
||||||
|
Id: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const song = z.object({
|
||||||
|
Album: z.string(),
|
||||||
|
AlbumArtist: z.string(),
|
||||||
|
AlbumArtists: z.array(genericItem),
|
||||||
|
AlbumId: z.string(),
|
||||||
|
AlbumPrimaryImageTag: z.string(),
|
||||||
|
ArtistItems: z.array(genericItem),
|
||||||
|
Artists: z.array(z.string()),
|
||||||
|
BackdropImageTags: z.array(z.string()),
|
||||||
|
ChannelId: z.null(),
|
||||||
|
DateCreated: z.string(),
|
||||||
|
ExternalUrls: z.array(externalUrl),
|
||||||
|
GenreItems: z.array(genericItem),
|
||||||
|
Genres: z.array(z.string()),
|
||||||
|
Id: z.string(),
|
||||||
|
ImageBlurHashes: imageBlurHashes,
|
||||||
|
ImageTags: imageTags,
|
||||||
|
IndexNumber: z.number(),
|
||||||
|
IsFolder: z.boolean(),
|
||||||
|
LocationType: z.string(),
|
||||||
|
MediaSources: z.array(mediaSources),
|
||||||
|
MediaType: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
ParentIndexNumber: z.number(),
|
||||||
|
PlaylistItemId: z.string().optional(),
|
||||||
|
PremiereDate: z.string().optional(),
|
||||||
|
ProductionYear: z.number(),
|
||||||
|
RunTimeTicks: z.number(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
SortName: z.string(),
|
||||||
|
Type: z.string(),
|
||||||
|
UserData: userData.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumArtist = z.object({
|
||||||
|
BackdropImageTags: z.array(z.string()),
|
||||||
|
ChannelId: z.null(),
|
||||||
|
DateCreated: z.string(),
|
||||||
|
ExternalUrls: z.array(externalUrl),
|
||||||
|
GenreItems: z.array(genreItem),
|
||||||
|
Genres: z.array(z.string()),
|
||||||
|
Id: z.string(),
|
||||||
|
ImageBlurHashes: imageBlurHashes,
|
||||||
|
ImageTags: imageTags,
|
||||||
|
LocationType: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
Overview: z.string(),
|
||||||
|
RunTimeTicks: z.number(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
Type: z.string(),
|
||||||
|
UserData: userData.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumDetailParameters = baseParameters;
|
||||||
|
|
||||||
|
const album = z.object({
|
||||||
|
AlbumArtist: z.string(),
|
||||||
|
AlbumArtists: z.array(genericItem),
|
||||||
|
AlbumPrimaryImageTag: z.string(),
|
||||||
|
ArtistItems: z.array(genericItem),
|
||||||
|
Artists: z.array(z.string()),
|
||||||
|
ChannelId: z.null(),
|
||||||
|
ChildCount: z.number().optional(),
|
||||||
|
DateCreated: z.string(),
|
||||||
|
DateLastMediaAdded: z.string().optional(),
|
||||||
|
ExternalUrls: z.array(externalUrl),
|
||||||
|
GenreItems: z.array(genericItem),
|
||||||
|
Genres: z.array(z.string()),
|
||||||
|
Id: z.string(),
|
||||||
|
ImageBlurHashes: imageBlurHashes,
|
||||||
|
ImageTags: imageTags,
|
||||||
|
IsFolder: z.boolean(),
|
||||||
|
LocationType: z.string(),
|
||||||
|
Name: z.string(),
|
||||||
|
ParentLogoImageTag: z.string(),
|
||||||
|
ParentLogoItemId: z.string(),
|
||||||
|
PremiereDate: z.string().optional(),
|
||||||
|
ProductionYear: z.number(),
|
||||||
|
RunTimeTicks: z.number(),
|
||||||
|
ServerId: z.string(),
|
||||||
|
Songs: z.array(song).optional(), // This is not a native Jellyfin property -- this is used for combined album detail
|
||||||
|
Type: z.string(),
|
||||||
|
UserData: userData.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jfAlbumListSort = {
|
||||||
|
ALBUM_ARTIST: 'AlbumArtist,SortName',
|
||||||
|
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||||
|
CRITIC_RATING: 'CriticRating,SortName',
|
||||||
|
NAME: 'SortName',
|
||||||
|
RANDOM: 'Random,SortName',
|
||||||
|
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||||
|
RELEASE_DATE: 'ProductionYear,PremiereDate,SortName',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const albumListParameters = paginationParameters.merge(
|
||||||
|
baseParameters.extend({
|
||||||
|
Filters: z.string().optional(),
|
||||||
|
GenreIds: z.string().optional(),
|
||||||
|
Genres: z.string().optional(),
|
||||||
|
IncludeItemTypes: z.literal('MusicAlbum'),
|
||||||
|
IsFavorite: z.boolean().optional(),
|
||||||
|
SearchTerm: z.string().optional(),
|
||||||
|
SortBy: z.nativeEnum(jfAlbumListSort).optional(),
|
||||||
|
Tags: z.string().optional(),
|
||||||
|
Years: z.string().optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const albumList = pagination.extend({
|
||||||
|
Items: z.array(album),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jfAlbumArtistListSort = {
|
||||||
|
ALBUM: 'Album,SortName',
|
||||||
|
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||||
|
NAME: 'Name,SortName',
|
||||||
|
RANDOM: 'Random,SortName',
|
||||||
|
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||||
|
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const albumArtistListParameters = paginationParameters.merge(
|
||||||
|
baseParameters.extend({
|
||||||
|
Filters: z.string().optional(),
|
||||||
|
Genres: z.string().optional(),
|
||||||
|
SortBy: z.nativeEnum(jfAlbumArtistListSort).optional(),
|
||||||
|
Years: z.string().optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const albumArtistList = pagination.extend({
|
||||||
|
Items: z.array(albumArtist),
|
||||||
|
});
|
||||||
|
|
||||||
|
const similarArtistListParameters = baseParameters.extend({
|
||||||
|
Limit: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jfSongListSort = {
|
||||||
|
ALBUM: 'Album,SortName',
|
||||||
|
ALBUM_ARTIST: 'AlbumArtist,Album,SortName',
|
||||||
|
ARTIST: 'Artist,Album,SortName',
|
||||||
|
COMMUNITY_RATING: 'CommunityRating,SortName',
|
||||||
|
DURATION: 'Runtime,AlbumArtist,Album,SortName',
|
||||||
|
NAME: 'Name,SortName',
|
||||||
|
PLAY_COUNT: 'PlayCount,SortName',
|
||||||
|
RANDOM: 'Random,SortName',
|
||||||
|
RECENTLY_ADDED: 'DateCreated,SortName',
|
||||||
|
RECENTLY_PLAYED: 'DatePlayed,SortName',
|
||||||
|
RELEASE_DATE: 'PremiereDate,AlbumArtist,Album,SortName',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const songListParameters = baseParameters.extend({
|
||||||
|
AlbumArtistIds: z.string().optional(),
|
||||||
|
AlbumIds: z.string().optional(),
|
||||||
|
ArtistIds: z.string().optional(),
|
||||||
|
Filters: z.string().optional(),
|
||||||
|
GenreIds: z.string().optional(),
|
||||||
|
Genres: z.string().optional(),
|
||||||
|
IsFavorite: z.boolean().optional(),
|
||||||
|
SearchTerm: z.string().optional(),
|
||||||
|
SortBy: z.nativeEnum(jfSongListSort).optional(),
|
||||||
|
Tags: z.string().optional(),
|
||||||
|
Years: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const songList = pagination.extend({
|
||||||
|
Items: z.array(song),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistSongList = songList;
|
||||||
|
|
||||||
|
const topSongsList = songList;
|
||||||
|
|
||||||
|
const playlistDetailParameters = baseParameters.extend({
|
||||||
|
Ids: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPlaylistParameters = z.object({
|
||||||
|
MediaType: z.literal('Audio'),
|
||||||
|
Name: z.string(),
|
||||||
|
Overview: z.string(),
|
||||||
|
UserId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPlaylist = z.object({
|
||||||
|
Id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePlaylist = z.null();
|
||||||
|
|
||||||
|
const updatePlaylistParameters = z.object({
|
||||||
|
Genres: z.array(genreItem),
|
||||||
|
MediaType: z.literal('Audio'),
|
||||||
|
Name: z.string(),
|
||||||
|
Overview: z.string(),
|
||||||
|
PremiereDate: z.null(),
|
||||||
|
ProviderIds: z.object({}),
|
||||||
|
Tags: z.array(genericItem),
|
||||||
|
UserId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToPlaylist = z.object({
|
||||||
|
Added: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToPlaylistParameters = z.object({
|
||||||
|
Ids: z.array(z.string()),
|
||||||
|
UserId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeFromPlaylist = z.null();
|
||||||
|
|
||||||
|
const removeFromPlaylistParameters = z.object({
|
||||||
|
EntryIds: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletePlaylist = z.null();
|
||||||
|
|
||||||
|
const deletePlaylistParameters = z.object({
|
||||||
|
Id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrobbleParameters = z.object({
|
||||||
|
EventName: z.string().optional(),
|
||||||
|
IsPaused: z.boolean().optional(),
|
||||||
|
ItemId: z.string(),
|
||||||
|
PositionTicks: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrobble = z.any();
|
||||||
|
|
||||||
|
const favorite = z.object({
|
||||||
|
IsFavorite: z.boolean(),
|
||||||
|
ItemId: z.string(),
|
||||||
|
Key: z.string(),
|
||||||
|
LastPlayedDate: z.string(),
|
||||||
|
Likes: z.boolean(),
|
||||||
|
PlayCount: z.number(),
|
||||||
|
PlaybackPositionTicks: z.number(),
|
||||||
|
Played: z.boolean(),
|
||||||
|
PlayedPercentage: z.number(),
|
||||||
|
Rating: z.number(),
|
||||||
|
UnplayedItemCount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const favoriteParameters = z.object({});
|
||||||
|
|
||||||
|
export const jfType = {
|
||||||
|
_enum: {
|
||||||
|
collection: jfCollection,
|
||||||
|
external: jfExternal,
|
||||||
|
image: jfImage,
|
||||||
|
},
|
||||||
|
_parameters: {
|
||||||
|
addToPlaylist: addToPlaylistParameters,
|
||||||
|
albumArtistDetail: baseParameters,
|
||||||
|
albumArtistList: albumArtistListParameters,
|
||||||
|
albumDetail: albumDetailParameters,
|
||||||
|
albumList: albumListParameters,
|
||||||
|
authenticate: authenticateParameters,
|
||||||
|
createPlaylist: createPlaylistParameters,
|
||||||
|
deletePlaylist: deletePlaylistParameters,
|
||||||
|
favorite: favoriteParameters,
|
||||||
|
musicFolderList: musicFolderListParameters,
|
||||||
|
playlistDetail: playlistDetailParameters,
|
||||||
|
playlistList: playlistListParameters,
|
||||||
|
removeFromPlaylist: removeFromPlaylistParameters,
|
||||||
|
scrobble: scrobbleParameters,
|
||||||
|
similarArtistList: similarArtistListParameters,
|
||||||
|
songList: songListParameters,
|
||||||
|
updatePlaylist: updatePlaylistParameters,
|
||||||
|
},
|
||||||
|
_response: {
|
||||||
|
addToPlaylist,
|
||||||
|
album,
|
||||||
|
albumArtist,
|
||||||
|
albumArtistList,
|
||||||
|
albumList,
|
||||||
|
authenticate,
|
||||||
|
createPlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
error,
|
||||||
|
favorite,
|
||||||
|
genre,
|
||||||
|
genreList,
|
||||||
|
musicFolderList,
|
||||||
|
playlist,
|
||||||
|
playlistList,
|
||||||
|
playlistSongList,
|
||||||
|
removeFromPlaylist,
|
||||||
|
scrobble,
|
||||||
|
song,
|
||||||
|
songList,
|
||||||
|
topSongsList,
|
||||||
|
updatePlaylist,
|
||||||
|
user,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,617 +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,
|
|
||||||
} from '/@/renderer/api/navidrome.types';
|
|
||||||
import { NDPlaylistListSort, NDSongListSort, NDSortOrder } from '/@/renderer/api/navidrome.types';
|
|
||||||
import type {
|
|
||||||
Album,
|
|
||||||
Song,
|
|
||||||
AuthenticationResponse,
|
|
||||||
AlbumDetailArgs,
|
|
||||||
GenreListArgs,
|
|
||||||
AlbumListArgs,
|
|
||||||
AlbumArtistListArgs,
|
|
||||||
AlbumArtistDetailArgs,
|
|
||||||
SongListArgs,
|
|
||||||
SongDetailArgs,
|
|
||||||
CreatePlaylistArgs,
|
|
||||||
DeletePlaylistArgs,
|
|
||||||
PlaylistListArgs,
|
|
||||||
PlaylistDetailArgs,
|
|
||||||
CreatePlaylistResponse,
|
|
||||||
PlaylistSongListArgs,
|
|
||||||
AlbumArtist,
|
|
||||||
Playlist,
|
|
||||||
UpdatePlaylistResponse,
|
|
||||||
UpdatePlaylistArgs,
|
|
||||||
} from '/@/renderer/api/types';
|
|
||||||
import {
|
|
||||||
playlistListSortMap,
|
|
||||||
albumArtistListSortMap,
|
|
||||||
songListSortMap,
|
|
||||||
albumListSortMap,
|
|
||||||
sortOrderMap,
|
|
||||||
} 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';
|
|
||||||
|
|
||||||
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 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 data = await api
|
|
||||||
.get(`api/artist/${query.id}`, {
|
|
||||||
headers: { 'x-nd-authorization': `Bearer ${server?.ndCredential}` },
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<NDAlbumArtistDetailResponse>();
|
|
||||||
|
|
||||||
return { ...data };
|
|
||||||
};
|
|
||||||
|
|
||||||
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 { query, server } = args;
|
|
||||||
|
|
||||||
const json: NDCreatePlaylistParams = {
|
|
||||||
comment: query.comment,
|
|
||||||
name: query.name,
|
|
||||||
public: query.public || false,
|
|
||||||
};
|
|
||||||
|
|
||||||
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: query.name,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
|
||||||
const { query, body, server, signal } = args;
|
|
||||||
|
|
||||||
const json: NDUpdatePlaylistParams = {
|
|
||||||
comment: body.comment || '',
|
|
||||||
name: body.name,
|
|
||||||
public: body.public || false,
|
|
||||||
};
|
|
||||||
|
|
||||||
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] : NDSortOrder.ASC,
|
|
||||||
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : NDPlaylistListSort.NAME,
|
|
||||||
_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 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;
|
|
||||||
|
|
||||||
// Dynamically determine the id field based on whether or not the item is a playlist song
|
|
||||||
if ('mediaFileId' in item) {
|
|
||||||
id = item.mediaFileId;
|
|
||||||
} else {
|
|
||||||
id = item.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageUrl = getCoverArtUrl({
|
|
||||||
baseUrl: server.url,
|
|
||||||
coverArtId: id,
|
|
||||||
credential: server.credential,
|
|
||||||
size: imageSize || 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
const imagePlaceholderUrl = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
album: item.album,
|
|
||||||
albumArtists: [{ id: item.artistId, name: item.artist }],
|
|
||||||
albumId: item.albumId,
|
|
||||||
artistName: item.artist,
|
|
||||||
artists: [{ id: item.artistId, name: item.artist }],
|
|
||||||
bitRate: item.bitRate,
|
|
||||||
bpm: item.bpm ? item.bpm : null,
|
|
||||||
channels: item.channels ? item.channels : 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,
|
|
||||||
isFavorite: item.starred,
|
|
||||||
lastPlayedAt: item.playDate ? item.playDate : null,
|
|
||||||
name: item.title,
|
|
||||||
note: item.comment ? item.comment : null,
|
|
||||||
path: item.path,
|
|
||||||
playCount: item.playCount,
|
|
||||||
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
|
||||||
releaseYear: String(item.year),
|
|
||||||
serverId: server.id,
|
|
||||||
size: item.size,
|
|
||||||
streamUrl: `${server.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server.credential}`,
|
|
||||||
trackNumber: item.trackNumber,
|
|
||||||
type: ServerType.NAVIDROME,
|
|
||||||
uniqueId: nanoid(),
|
|
||||||
updatedAt: item.updatedAt,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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, name: item.albumArtist }],
|
|
||||||
artists: [{ id: item.artistId, 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,
|
|
||||||
isFavorite: item.starred,
|
|
||||||
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
|
|
||||||
name: item.name,
|
|
||||||
playCount: item.playCount,
|
|
||||||
rating: item.rating,
|
|
||||||
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
|
||||||
releaseYear: item.minYear,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAlbumArtist = (item: NDAlbumArtist): AlbumArtist => {
|
|
||||||
return {
|
|
||||||
albumCount: item.albumCount,
|
|
||||||
backgroundImageUrl: null,
|
|
||||||
biography: item.biography,
|
|
||||||
duration: null,
|
|
||||||
genres: item.genres,
|
|
||||||
id: item.id,
|
|
||||||
imageUrl: item.largeImageUrl,
|
|
||||||
isFavorite: item.starred,
|
|
||||||
lastPlayedAt: item.playDate ? item.playDate.split('T')[0] : null,
|
|
||||||
name: item.name,
|
|
||||||
playCount: item.playCount,
|
|
||||||
rating: item.rating,
|
|
||||||
songCount: item.songCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
name: item.name,
|
|
||||||
public: item.public,
|
|
||||||
rules: item?.rules || null,
|
|
||||||
size: item.size,
|
|
||||||
songCount: item.songCount,
|
|
||||||
userId: item.ownerId,
|
|
||||||
username: item.ownerName,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const navidromeApi = {
|
|
||||||
authenticate,
|
|
||||||
createPlaylist,
|
|
||||||
deletePlaylist,
|
|
||||||
getAlbumArtistDetail,
|
|
||||||
getAlbumArtistList,
|
|
||||||
getAlbumDetail,
|
|
||||||
getAlbumList,
|
|
||||||
getGenreList,
|
|
||||||
getPlaylistDetail,
|
|
||||||
getPlaylistList,
|
|
||||||
getPlaylistSongList,
|
|
||||||
getSongDetail,
|
|
||||||
getSongList,
|
|
||||||
updatePlaylist,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ndNormalize = {
|
|
||||||
album: normalizeAlbum,
|
|
||||||
albumArtist: normalizeAlbumArtist,
|
|
||||||
playlist: normalizePlaylist,
|
|
||||||
song: normalizeSong,
|
|
||||||
};
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SSArtistInfo } from '/@/renderer/api/subsonic.types';
|
||||||
|
|
||||||
export type NDAuthenticate = {
|
export type NDAuthenticate = {
|
||||||
id: string;
|
id: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -8,6 +10,18 @@ export type NDAuthenticate = {
|
|||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NDUser = {
|
||||||
|
createdAt: string;
|
||||||
|
email: string;
|
||||||
|
id: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
lastAccessAt: string;
|
||||||
|
lastLoginAt: string;
|
||||||
|
name: string;
|
||||||
|
updatedAt: string;
|
||||||
|
userName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type NDGenre = {
|
export type NDGenre = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -101,19 +115,21 @@ export type NDAlbumArtist = {
|
|||||||
fullText: string;
|
fullText: string;
|
||||||
genres: NDGenre[];
|
genres: NDGenre[];
|
||||||
id: string;
|
id: string;
|
||||||
largeImageUrl: string;
|
largeImageUrl?: string;
|
||||||
mbzArtistId: string;
|
mbzArtistId: string;
|
||||||
mediumImageUrl: string;
|
mediumImageUrl?: string;
|
||||||
name: string;
|
name: string;
|
||||||
orderArtistName: string;
|
orderArtistName: string;
|
||||||
playCount: number;
|
playCount: number;
|
||||||
playDate: string;
|
playDate: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
size: number;
|
size: number;
|
||||||
smallImageUrl: string;
|
smallImageUrl?: string;
|
||||||
songCount: number;
|
songCount: number;
|
||||||
starred: boolean;
|
starred: boolean;
|
||||||
starredAt: string;
|
starredAt: string;
|
||||||
|
} & {
|
||||||
|
similarArtists?: SSArtistInfo['similarArtist'];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NDAuthenticationResponse = NDAuthenticate;
|
export type NDAuthenticationResponse = NDAuthenticate;
|
||||||
@@ -214,8 +230,8 @@ export type NDAlbumListParams = {
|
|||||||
|
|
||||||
export enum NDSongListSort {
|
export enum NDSongListSort {
|
||||||
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
|
ALBUM = 'album, order_album_artist_name, disc_number, track_number, title',
|
||||||
ALBUM_ARTIST = 'albumArtist',
|
ALBUM_ARTIST = 'order_album_artist_name, album, disc_number, track_number, title',
|
||||||
ALBUM_SONGS = 'discNumber, trackNumber',
|
ALBUM_SONGS = 'album, discNumber, trackNumber',
|
||||||
ARTIST = 'artist',
|
ARTIST = 'artist',
|
||||||
BPM = 'bpm',
|
BPM = 'bpm',
|
||||||
CHANNELS = 'channels',
|
CHANNELS = 'channels',
|
||||||
@@ -227,9 +243,10 @@ export enum NDSongListSort {
|
|||||||
PLAY_COUNT = 'playCount',
|
PLAY_COUNT = 'playCount',
|
||||||
PLAY_DATE = 'playDate',
|
PLAY_DATE = 'playDate',
|
||||||
RATING = 'rating',
|
RATING = 'rating',
|
||||||
|
RECENTLY_ADDED = 'createdAt',
|
||||||
TITLE = 'title',
|
TITLE = 'title',
|
||||||
TRACK = 'track',
|
TRACK = 'track',
|
||||||
YEAR = 'year',
|
YEAR = 'year, album, discNumber, trackNumber',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NDSongListParams = {
|
export type NDSongListParams = {
|
||||||
@@ -257,6 +274,26 @@ export type NDAlbumArtistListParams = {
|
|||||||
} & NDPagination &
|
} & NDPagination &
|
||||||
NDOrder;
|
NDOrder;
|
||||||
|
|
||||||
|
export type NDAddToPlaylistResponse = {
|
||||||
|
added: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDAddToPlaylistBody = {
|
||||||
|
ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDAddToPlaylist = null;
|
||||||
|
|
||||||
|
export type NDRemoveFromPlaylistResponse = {
|
||||||
|
ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDRemoveFromPlaylistParams = {
|
||||||
|
id: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDRemoveFromPlaylist = null;
|
||||||
|
|
||||||
export type NDCreatePlaylistParams = {
|
export type NDCreatePlaylistParams = {
|
||||||
comment?: string;
|
comment?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -339,3 +376,91 @@ export type NDPlaylistSongList = {
|
|||||||
startIndex: number;
|
startIndex: number;
|
||||||
totalRecordCount: number;
|
totalRecordCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const NDSongQueryFields = [
|
||||||
|
{ label: 'Album', type: 'string', value: 'album' },
|
||||||
|
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
|
||||||
|
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
|
||||||
|
{ label: 'Album Type', type: 'string', value: 'albumtype' },
|
||||||
|
{ label: 'Artist', type: 'string', value: 'artist' },
|
||||||
|
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
|
||||||
|
{ label: 'BPM', type: 'number', value: 'bpm' },
|
||||||
|
{ label: 'Catalog Number', type: 'string', value: 'catalognumber' },
|
||||||
|
{ label: 'Channels', type: 'number', value: 'channels' },
|
||||||
|
{ label: 'Comment', type: 'string', value: 'comment' },
|
||||||
|
{ label: 'Date Added', type: 'date', value: 'dateadded' },
|
||||||
|
{ label: 'Date Favorited', type: 'date', value: 'dateloved' },
|
||||||
|
{ label: 'Date Last Played', type: 'date', value: 'lastplayed' },
|
||||||
|
{ label: 'Date Modified', type: 'date', value: 'datemodified' },
|
||||||
|
{ label: 'Disc Subtitle', type: 'string', value: 'discsubtitle' },
|
||||||
|
{ label: 'Disc Number', type: 'number', value: 'discnumber' },
|
||||||
|
{ label: 'Duration', type: 'number', value: 'duration' },
|
||||||
|
{ label: 'File Path', type: 'string', value: 'filepath' },
|
||||||
|
{ label: 'File Type', type: 'string', value: 'filetype' },
|
||||||
|
{ label: 'Genre', type: 'string', value: 'genre' },
|
||||||
|
{ label: 'Has CoverArt', type: 'boolean', value: 'hascoverart' },
|
||||||
|
{ label: 'Is Compilation', type: 'boolean', value: 'compilation' },
|
||||||
|
{ label: 'Is Favorite', type: 'boolean', value: 'loved' },
|
||||||
|
{ label: 'Lyrics', type: 'string', value: 'lyrics' },
|
||||||
|
{ label: 'Name', type: 'string', value: 'title' },
|
||||||
|
{ label: 'Play Count', type: 'number', value: 'playcount' },
|
||||||
|
{ label: 'Rating', type: 'number', value: 'rating' },
|
||||||
|
{ label: 'Size', type: 'number', value: 'size' },
|
||||||
|
{ label: 'Sort Album', type: 'string', value: 'sortalbum' },
|
||||||
|
{ label: 'Sort Album Artist', type: 'string', value: 'sortalbumartist' },
|
||||||
|
{ label: 'Sort Artist', type: 'string', value: 'sortartist' },
|
||||||
|
{ label: 'Sort Name', type: 'string', value: 'sorttitle' },
|
||||||
|
{ label: 'Track Number', type: 'number', value: 'tracknumber' },
|
||||||
|
{ label: 'Year', type: 'number', value: 'year' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NDSongQueryDateOperators = [
|
||||||
|
{ label: 'is', value: 'is' },
|
||||||
|
{ label: 'is not', value: 'isNot' },
|
||||||
|
{ label: 'is before', value: 'before' },
|
||||||
|
{ label: 'is after', value: 'after' },
|
||||||
|
{ label: 'is in the last', value: 'inTheLast' },
|
||||||
|
{ label: 'is not in the last', value: 'notInTheLast' },
|
||||||
|
{ label: 'is in the range', value: 'inTheRange' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NDSongQueryStringOperators = [
|
||||||
|
{ label: 'is', value: 'is' },
|
||||||
|
{ label: 'is not', value: 'isNot' },
|
||||||
|
{ label: 'contains', value: 'contains' },
|
||||||
|
{ label: 'does not contain', value: 'notContains' },
|
||||||
|
{ label: 'starts with', value: 'startsWith' },
|
||||||
|
{ label: 'ends with', value: 'endsWith' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NDSongQueryBooleanOperators = [
|
||||||
|
{ label: 'is', value: 'is' },
|
||||||
|
{ label: 'is not', value: 'isNot' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NDSongQueryNumberOperators = [
|
||||||
|
{ label: 'is', value: 'is' },
|
||||||
|
{ label: 'is not', value: 'isNot' },
|
||||||
|
{ label: 'contains', value: 'contains' },
|
||||||
|
{ label: 'does not contain', value: 'notContains' },
|
||||||
|
{ label: 'is greater than', value: 'gt' },
|
||||||
|
{ label: 'is less than', value: 'lt' },
|
||||||
|
{ label: 'is in the range', value: 'inTheRange' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type NDUserListParams = {
|
||||||
|
_sort?: NDUserListSort;
|
||||||
|
} & NDPagination &
|
||||||
|
NDOrder;
|
||||||
|
|
||||||
|
export type NDUserListResponse = NDUser[];
|
||||||
|
|
||||||
|
export type NDUserList = {
|
||||||
|
items: NDUser[];
|
||||||
|
startIndex: number;
|
||||||
|
totalRecordCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum NDUserListSort {
|
||||||
|
NAME = 'name',
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
|
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
||||||
|
import omitBy from 'lodash/omitBy';
|
||||||
|
import qs from 'qs';
|
||||||
|
import { ndType } from './navidrome-types';
|
||||||
|
import { resultWithHeaders } from '/@/renderer/api/utils';
|
||||||
|
import { toast } from '/@/renderer/components/toast/index';
|
||||||
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const c = initContract();
|
||||||
|
|
||||||
|
export const contract = c.router({
|
||||||
|
addToPlaylist: {
|
||||||
|
body: ndType._parameters.addToPlaylist,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'playlist/:id/tracks',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.addToPlaylist),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authenticate: {
|
||||||
|
body: ndType._parameters.authenticate,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'auth/login',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.authenticate),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createPlaylist: {
|
||||||
|
body: ndType._parameters.createPlaylist,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'playlist',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.createPlaylist),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deletePlaylist: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'playlist/:id',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.deletePlaylist),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumArtistDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'artist/:id',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.albumArtist),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumArtistList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'artist',
|
||||||
|
query: ndType._parameters.albumArtistList,
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.albumArtistList),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'album/:id',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.album),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAlbumList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'album',
|
||||||
|
query: ndType._parameters.albumList,
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.albumList),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getGenreList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'genre',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.genreList),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPlaylistDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'playlist/:id',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.playlist),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPlaylistList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'playlist',
|
||||||
|
query: ndType._parameters.playlistList,
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.playlistList),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getPlaylistSongList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'playlist/:id/tracks',
|
||||||
|
query: ndType._parameters.songList,
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.playlistSongList),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getSongDetail: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'song/:id',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.song),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getSongList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'song',
|
||||||
|
query: ndType._parameters.songList,
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.songList),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getUserList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'user',
|
||||||
|
query: ndType._parameters.userList,
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.userList),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
removeFromPlaylist: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'playlist/:id/tracks',
|
||||||
|
query: ndType._parameters.removeFromPlaylist,
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.removeFromPlaylist),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
updatePlaylist: {
|
||||||
|
body: ndType._parameters.updatePlaylist,
|
||||||
|
method: 'PUT',
|
||||||
|
path: 'playlist/:id',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.updatePlaylist),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const axiosClient = axios.create({});
|
||||||
|
|
||||||
|
axiosClient.defaults.paramsSerializer = (params) => {
|
||||||
|
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const serverId = useAuthStore.getState().currentServer?.id;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
useAuthStore.getState().actions.updateServer(serverId, {
|
||||||
|
ndCredential: response.headers['x-nd-authorization'] as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
toast.error({
|
||||||
|
message: 'Your session has expired.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentServer = useAuthStore.getState().currentServer;
|
||||||
|
|
||||||
|
if (currentServer) {
|
||||||
|
const serverId = currentServer.id;
|
||||||
|
const token = currentServer.ndCredential;
|
||||||
|
console.log(`token is expired: ${token}`);
|
||||||
|
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||||
|
useAuthStore.getState().actions.setCurrentServer(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsePath = (fullPath: string) => {
|
||||||
|
const [path, params] = fullPath.split('?');
|
||||||
|
|
||||||
|
const parsedParams = qs.parse(params);
|
||||||
|
const notNilParams = omitBy(parsedParams, (value) => value === 'undefined' || value === 'null');
|
||||||
|
|
||||||
|
return {
|
||||||
|
params: notNilParams,
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ndApiClient = (args: {
|
||||||
|
server: ServerListItem | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
url?: string;
|
||||||
|
}) => {
|
||||||
|
const { server, url, signal } = args;
|
||||||
|
|
||||||
|
return initClient(contract, {
|
||||||
|
api: async ({ path, method, headers, body }) => {
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
let token: string | undefined;
|
||||||
|
|
||||||
|
const { params, path: api } = parsePath(path);
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
baseUrl = `${server?.url}/api`;
|
||||||
|
token = server?.ndCredential;
|
||||||
|
} else {
|
||||||
|
baseUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await axiosClient.request({
|
||||||
|
data: body,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
...(token && { 'x-nd-authorization': `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
method: method as Method,
|
||||||
|
params,
|
||||||
|
signal,
|
||||||
|
url: `${baseUrl}/${api}`,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
body: { data: result.data, headers: result.headers },
|
||||||
|
status: result.status,
|
||||||
|
};
|
||||||
|
} catch (e: Error | AxiosError | any) {
|
||||||
|
if (isAxiosError(e)) {
|
||||||
|
const error = e as AxiosError;
|
||||||
|
const response = error.response as AxiosResponse;
|
||||||
|
return {
|
||||||
|
body: { data: response.data, headers: response.headers },
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseHeaders: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
baseUrl: '',
|
||||||
|
jsonQuery: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import {
|
||||||
|
AlbumArtistDetailArgs,
|
||||||
|
AlbumArtistDetailResponse,
|
||||||
|
AddToPlaylistArgs,
|
||||||
|
AddToPlaylistResponse,
|
||||||
|
CreatePlaylistResponse,
|
||||||
|
CreatePlaylistArgs,
|
||||||
|
DeletePlaylistArgs,
|
||||||
|
DeletePlaylistResponse,
|
||||||
|
AlbumArtistListResponse,
|
||||||
|
AlbumArtistListArgs,
|
||||||
|
albumArtistListSortMap,
|
||||||
|
sortOrderMap,
|
||||||
|
AuthenticationResponse,
|
||||||
|
UserListResponse,
|
||||||
|
UserListArgs,
|
||||||
|
userListSortMap,
|
||||||
|
GenreListArgs,
|
||||||
|
GenreListResponse,
|
||||||
|
AlbumDetailResponse,
|
||||||
|
AlbumDetailArgs,
|
||||||
|
AlbumListArgs,
|
||||||
|
albumListSortMap,
|
||||||
|
AlbumListResponse,
|
||||||
|
SongListResponse,
|
||||||
|
SongListArgs,
|
||||||
|
songListSortMap,
|
||||||
|
SongDetailResponse,
|
||||||
|
SongDetailArgs,
|
||||||
|
UpdatePlaylistArgs,
|
||||||
|
UpdatePlaylistResponse,
|
||||||
|
PlaylistListResponse,
|
||||||
|
PlaylistDetailArgs,
|
||||||
|
PlaylistListArgs,
|
||||||
|
playlistListSortMap,
|
||||||
|
PlaylistDetailResponse,
|
||||||
|
PlaylistSongListArgs,
|
||||||
|
PlaylistSongListResponse,
|
||||||
|
RemoveFromPlaylistResponse,
|
||||||
|
RemoveFromPlaylistArgs,
|
||||||
|
} from '../types';
|
||||||
|
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
|
||||||
|
import { ndNormalize } from '/@/renderer/api/navidrome/navidrome-normalize';
|
||||||
|
import { ndType } from '/@/renderer/api/navidrome/navidrome-types';
|
||||||
|
|
||||||
|
const authenticate = async (
|
||||||
|
url: string,
|
||||||
|
body: { password: string; username: string },
|
||||||
|
): Promise<AuthenticationResponse> => {
|
||||||
|
const cleanServerUrl = url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const res = await ndApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||||
|
body: {
|
||||||
|
password: body.password,
|
||||||
|
username: body.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to authenticate');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
credential: `u=${body.username}&s=${res.body.data.subsonicSalt}&t=${res.body.data.subsonicToken}`,
|
||||||
|
ndCredential: res.body.data.token,
|
||||||
|
userId: res.body.data.id,
|
||||||
|
username: res.body.data.username,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserList = async (args: UserListArgs): Promise<UserListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getUserList({
|
||||||
|
query: {
|
||||||
|
_end: query.startIndex + (query.limit || 0),
|
||||||
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
|
_sort: userListSortMap.navidrome[query.sortBy],
|
||||||
|
_start: query.startIndex,
|
||||||
|
...query._custom?.navidrome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get user list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.data.map((user) => ndNormalize.user(user)),
|
||||||
|
startIndex: query?.startIndex || 0,
|
||||||
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGenreList = async (args: GenreListArgs): Promise<GenreListResponse> => {
|
||||||
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getGenreList({});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get genre list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.data,
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumArtistDetail = async (
|
||||||
|
args: AlbumArtistDetailArgs,
|
||||||
|
): Promise<AlbumArtistDetailResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get album artist detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiClientProps.server) {
|
||||||
|
throw new Error('Server is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ndNormalize.albumArtist(res.body.data, apiClientProps.server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<AlbumArtistListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getAlbumArtistList({
|
||||||
|
query: {
|
||||||
|
_end: query.startIndex + (query.limit || 0),
|
||||||
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
|
_sort: albumArtistListSortMap.navidrome[query.sortBy],
|
||||||
|
_start: query.startIndex,
|
||||||
|
name: query.searchTerm,
|
||||||
|
...query._custom?.navidrome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get album artist list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.data.map((albumArtist) =>
|
||||||
|
ndNormalize.albumArtist(albumArtist, apiClientProps.server),
|
||||||
|
),
|
||||||
|
startIndex: query.startIndex,
|
||||||
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<AlbumDetailResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const albumRes = await ndApiClient(apiClientProps).getAlbumDetail({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const songsData = await ndApiClient(apiClientProps).getSongList({
|
||||||
|
query: {
|
||||||
|
_end: 0,
|
||||||
|
_order: 'ASC',
|
||||||
|
_sort: 'album',
|
||||||
|
_start: 0,
|
||||||
|
album_id: [query.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (albumRes.status !== 200 || songsData.status !== 200) {
|
||||||
|
throw new Error('Failed to get album detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ndNormalize.album(
|
||||||
|
{ ...albumRes.body.data, songs: songsData.body.data },
|
||||||
|
apiClientProps.server,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlbumList = async (args: AlbumListArgs): Promise<AlbumListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getAlbumList({
|
||||||
|
query: {
|
||||||
|
_end: query.startIndex + (query.limit || 0),
|
||||||
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
|
_sort: albumListSortMap.navidrome[query.sortBy],
|
||||||
|
_start: query.startIndex,
|
||||||
|
artist_id: query.artistIds?.[0],
|
||||||
|
name: query.searchTerm,
|
||||||
|
...query._custom?.navidrome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get album list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.data.map((album) => ndNormalize.album(album, apiClientProps.server)),
|
||||||
|
startIndex: query?.startIndex || 0,
|
||||||
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSongList = async (args: SongListArgs): Promise<SongListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getSongList({
|
||||||
|
query: {
|
||||||
|
_end: query.startIndex + (query.limit || -1),
|
||||||
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
|
_sort: songListSortMap.navidrome[query.sortBy],
|
||||||
|
_start: query.startIndex,
|
||||||
|
album_id: query.albumIds,
|
||||||
|
artist_id: query.artistIds,
|
||||||
|
title: query.searchTerm,
|
||||||
|
...query._custom?.navidrome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get song list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.data.map((song) => ndNormalize.song(song, apiClientProps.server, '')),
|
||||||
|
startIndex: query?.startIndex || 0,
|
||||||
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSongDetail = async (args: SongDetailArgs): Promise<SongDetailResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getSongDetail({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get song detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ndNormalize.song(res.body.data, apiClientProps.server, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPlaylist = async (args: CreatePlaylistArgs): Promise<CreatePlaylistResponse> => {
|
||||||
|
const { body, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).createPlaylist({
|
||||||
|
body: {
|
||||||
|
comment: body.comment,
|
||||||
|
name: body.name,
|
||||||
|
public: body._custom?.navidrome?.public,
|
||||||
|
rules: body._custom?.navidrome?.rules,
|
||||||
|
sync: body._custom?.navidrome?.sync,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to create playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: res.body.data.id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePlaylist = async (args: UpdatePlaylistArgs): Promise<UpdatePlaylistResponse> => {
|
||||||
|
const { query, body, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).updatePlaylist({
|
||||||
|
body: {
|
||||||
|
comment: body.comment || '',
|
||||||
|
name: body.name,
|
||||||
|
public: body._custom?.navidrome?.public || false,
|
||||||
|
rules: body._custom?.navidrome?.rules ? body._custom.navidrome.rules : undefined,
|
||||||
|
sync: body._custom?.navidrome?.sync || undefined,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to update playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePlaylist = async (args: DeletePlaylistArgs): Promise<DeletePlaylistResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).deletePlaylist({
|
||||||
|
body: null,
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to delete playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistList = async (args: PlaylistListArgs): Promise<PlaylistListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getPlaylistList({
|
||||||
|
query: {
|
||||||
|
_end: query.startIndex + (query.limit || 0),
|
||||||
|
_order: sortOrderMap.navidrome[query.sortOrder],
|
||||||
|
_sort: query.sortBy ? playlistListSortMap.navidrome[query.sortBy] : undefined,
|
||||||
|
_start: query.startIndex,
|
||||||
|
...query._custom?.navidrome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get playlist list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.data.map((item) => ndNormalize.playlist(item, apiClientProps.server)),
|
||||||
|
startIndex: query?.startIndex || 0,
|
||||||
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistDetail = async (args: PlaylistDetailArgs): Promise<PlaylistDetailResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getPlaylistDetail({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get playlist detail');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ndNormalize.playlist(res.body.data, apiClientProps.server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaylistSongList = async (
|
||||||
|
args: PlaylistSongListArgs,
|
||||||
|
): Promise<PlaylistSongListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).getPlaylistSongList({
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
_end: query.startIndex + (query.limit || 0),
|
||||||
|
_order: query.sortOrder ? sortOrderMap.navidrome[query.sortOrder] : 'ASC',
|
||||||
|
_sort: query.sortBy ? songListSortMap.navidrome[query.sortBy] : ndType._enum.songList.ID,
|
||||||
|
_start: query.startIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get playlist song list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.data.map((item) => ndNormalize.song(item, apiClientProps.server, '')),
|
||||||
|
startIndex: query?.startIndex || 0,
|
||||||
|
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToPlaylist = async (args: AddToPlaylistArgs): Promise<AddToPlaylistResponse> => {
|
||||||
|
const { body, query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).addToPlaylist({
|
||||||
|
body: {
|
||||||
|
ids: body.songId,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to add to playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromPlaylist = async (
|
||||||
|
args: RemoveFromPlaylistArgs,
|
||||||
|
): Promise<RemoveFromPlaylistResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ndApiClient(apiClientProps).removeFromPlaylist({
|
||||||
|
body: null,
|
||||||
|
params: {
|
||||||
|
id: query.id,
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
ids: query.songId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to remove from playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ndController = {
|
||||||
|
addToPlaylist,
|
||||||
|
authenticate,
|
||||||
|
createPlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
getAlbumArtistDetail,
|
||||||
|
getAlbumArtistList,
|
||||||
|
getAlbumDetail,
|
||||||
|
getAlbumList,
|
||||||
|
getGenreList,
|
||||||
|
getPlaylistDetail,
|
||||||
|
getPlaylistList,
|
||||||
|
getPlaylistSongList,
|
||||||
|
getSongDetail,
|
||||||
|
getSongList,
|
||||||
|
getUserList,
|
||||||
|
removeFromPlaylist,
|
||||||
|
updatePlaylist,
|
||||||
|
};
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { Song, LibraryItem, Album, AlbumArtist, Playlist, User } from '/@/renderer/api/types';
|
||||||
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
|
import z from 'zod';
|
||||||
|
import { ndType } from './navidrome-types';
|
||||||
|
|
||||||
|
const getCoverArtUrl = (args: {
|
||||||
|
baseUrl: string | undefined;
|
||||||
|
coverArtId: string;
|
||||||
|
credential: string | undefined;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
const size = args.size ? args.size : 250;
|
||||||
|
|
||||||
|
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||||
|
`?id=${args.coverArtId}` +
|
||||||
|
`&${args.credential}` +
|
||||||
|
'&v=1.13.0' +
|
||||||
|
'&c=feishin' +
|
||||||
|
`&size=${size}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSong = (
|
||||||
|
item: z.infer<typeof ndType._response.song> | z.infer<typeof ndType._response.playlistSong>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
deviceId: string,
|
||||||
|
imageSize?: number,
|
||||||
|
): Song => {
|
||||||
|
let id;
|
||||||
|
let playlistItemId;
|
||||||
|
|
||||||
|
// Dynamically determine the id field based on whether or not the item is a playlist song
|
||||||
|
if ('mediaFileId' in item) {
|
||||||
|
id = item.mediaFileId;
|
||||||
|
playlistItemId = item.id;
|
||||||
|
} else {
|
||||||
|
id = item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = getCoverArtUrl({
|
||||||
|
baseUrl: server?.url,
|
||||||
|
coverArtId: id,
|
||||||
|
credential: server?.credential,
|
||||||
|
size: imageSize || 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagePlaceholderUrl = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
album: item.album,
|
||||||
|
albumArtists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
|
albumId: item.albumId,
|
||||||
|
artistName: item.artist,
|
||||||
|
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
|
bitRate: item.bitRate,
|
||||||
|
bpm: item.bpm ? item.bpm : null,
|
||||||
|
channels: item.channels ? item.channels : null,
|
||||||
|
comment: item.comment ? item.comment : null,
|
||||||
|
compilation: item.compilation,
|
||||||
|
container: item.suffix,
|
||||||
|
createdAt: item.createdAt.split('T')[0],
|
||||||
|
discNumber: item.discNumber,
|
||||||
|
duration: item.duration,
|
||||||
|
genres: item.genres,
|
||||||
|
id,
|
||||||
|
imagePlaceholderUrl,
|
||||||
|
imageUrl,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||||
|
name: item.title,
|
||||||
|
path: item.path,
|
||||||
|
playCount: item.playCount,
|
||||||
|
playlistItemId,
|
||||||
|
releaseDate: new Date(item.year, 0, 1).toISOString(),
|
||||||
|
releaseYear: String(item.year),
|
||||||
|
serverId: server?.id || 'unknown',
|
||||||
|
serverType: ServerType.NAVIDROME,
|
||||||
|
size: item.size,
|
||||||
|
streamUrl: `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`,
|
||||||
|
trackNumber: item.trackNumber,
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
userFavorite: item.starred || false,
|
||||||
|
userRating: item.rating || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAlbum = (
|
||||||
|
item: z.infer<typeof ndType._response.album> & {
|
||||||
|
songs?: z.infer<typeof ndType._response.songList>;
|
||||||
|
},
|
||||||
|
server: ServerListItem | null,
|
||||||
|
imageSize?: number,
|
||||||
|
): Album => {
|
||||||
|
const imageUrl = getCoverArtUrl({
|
||||||
|
baseUrl: server?.url,
|
||||||
|
coverArtId: item.coverArtId || item.id,
|
||||||
|
credential: server?.credential,
|
||||||
|
size: imageSize || 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagePlaceholderUrl = null;
|
||||||
|
|
||||||
|
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumArtists: [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }],
|
||||||
|
artists: [{ id: item.artistId, imageUrl: null, name: item.artist }],
|
||||||
|
backdropImageUrl: imageBackdropUrl,
|
||||||
|
createdAt: item.createdAt.split('T')[0],
|
||||||
|
duration: item.duration * 1000 || null,
|
||||||
|
genres: item.genres,
|
||||||
|
id: item.id,
|
||||||
|
imagePlaceholderUrl,
|
||||||
|
imageUrl,
|
||||||
|
isCompilation: item.compilation,
|
||||||
|
itemType: LibraryItem.ALBUM,
|
||||||
|
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||||
|
name: item.name,
|
||||||
|
playCount: item.playCount,
|
||||||
|
releaseDate: new Date(item.minYear, 0, 1).toISOString(),
|
||||||
|
releaseYear: item.minYear,
|
||||||
|
serverId: server?.id || 'unknown',
|
||||||
|
serverType: ServerType.NAVIDROME,
|
||||||
|
size: item.size,
|
||||||
|
songCount: item.songCount,
|
||||||
|
songs: item.songs ? item.songs.map((song) => normalizeSong(song, server, '')) : undefined,
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
userFavorite: item.starred,
|
||||||
|
userRating: item.rating || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAlbumArtist = (
|
||||||
|
item: z.infer<typeof ndType._response.albumArtist>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
): AlbumArtist => {
|
||||||
|
const imageUrl =
|
||||||
|
item.largeImageUrl === '/app/artist-placeholder.webp' ? null : item.largeImageUrl;
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumCount: item.albumCount,
|
||||||
|
backgroundImageUrl: null,
|
||||||
|
biography: item.biography || null,
|
||||||
|
duration: null,
|
||||||
|
genres: item.genres,
|
||||||
|
id: item.id,
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
itemType: LibraryItem.ALBUM_ARTIST,
|
||||||
|
lastPlayedAt: item.playDate.includes('0001-') ? null : item.playDate,
|
||||||
|
name: item.name,
|
||||||
|
playCount: item.playCount,
|
||||||
|
serverId: server?.id || 'unknown',
|
||||||
|
serverType: ServerType.NAVIDROME,
|
||||||
|
similarArtists: null,
|
||||||
|
// similarArtists:
|
||||||
|
// item.similarArtists?.map((artist) => ({
|
||||||
|
// id: artist.id,
|
||||||
|
// imageUrl: artist?.artistImageUrl || null,
|
||||||
|
// name: artist.name,
|
||||||
|
// })) || null,
|
||||||
|
songCount: item.songCount,
|
||||||
|
userFavorite: item.starred,
|
||||||
|
userRating: item.rating,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePlaylist = (
|
||||||
|
item: z.infer<typeof ndType._response.playlist>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
imageSize?: number,
|
||||||
|
): Playlist => {
|
||||||
|
const imageUrl = getCoverArtUrl({
|
||||||
|
baseUrl: server?.url,
|
||||||
|
coverArtId: item.id,
|
||||||
|
credential: server?.credential,
|
||||||
|
size: imageSize || 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagePlaceholderUrl = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: item.comment,
|
||||||
|
duration: item.duration * 1000,
|
||||||
|
genres: [],
|
||||||
|
id: item.id,
|
||||||
|
imagePlaceholderUrl,
|
||||||
|
imageUrl,
|
||||||
|
itemType: LibraryItem.PLAYLIST,
|
||||||
|
name: item.name,
|
||||||
|
owner: item.ownerName,
|
||||||
|
ownerId: item.ownerId,
|
||||||
|
public: item.public,
|
||||||
|
rules: item?.rules || null,
|
||||||
|
serverId: server?.id || 'unknown',
|
||||||
|
serverType: ServerType.NAVIDROME,
|
||||||
|
size: item.size,
|
||||||
|
songCount: item.songCount,
|
||||||
|
sync: item.sync,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
|
||||||
|
return {
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
email: item.email || null,
|
||||||
|
id: item.id,
|
||||||
|
isAdmin: item.isAdmin,
|
||||||
|
lastLoginAt: item.lastLoginAt,
|
||||||
|
name: item.userName,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ndNormalize = {
|
||||||
|
album: normalizeAlbum,
|
||||||
|
albumArtist: normalizeAlbumArtist,
|
||||||
|
playlist: normalizePlaylist,
|
||||||
|
song: normalizeSong,
|
||||||
|
user: normalizeUser,
|
||||||
|
};
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const sortOrderValues = ['ASC', 'DESC'] as const;
|
||||||
|
|
||||||
|
const error = z.string();
|
||||||
|
|
||||||
|
const paginationParameters = z.object({
|
||||||
|
_end: z.number().optional(),
|
||||||
|
_order: z.enum(sortOrderValues),
|
||||||
|
_start: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticate = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
isAdmin: z.boolean(),
|
||||||
|
name: z.string(),
|
||||||
|
subsonicSalt: z.string(),
|
||||||
|
subsonicToken: z.string(),
|
||||||
|
token: z.string(),
|
||||||
|
username: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticateParameters = z.object({
|
||||||
|
password: z.string(),
|
||||||
|
username: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = z.object({
|
||||||
|
createdAt: z.string(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
isAdmin: z.boolean(),
|
||||||
|
lastAccessAt: z.string(),
|
||||||
|
lastLoginAt: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
userName: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const userList = z.array(user);
|
||||||
|
|
||||||
|
const ndUserListSort = {
|
||||||
|
NAME: 'name',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const userListParameters = paginationParameters.extend({
|
||||||
|
_sort: z.nativeEnum(ndUserListSort).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const genre = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const genreList = z.array(genre);
|
||||||
|
|
||||||
|
const albumArtist = z.object({
|
||||||
|
albumCount: z.number(),
|
||||||
|
biography: z.string(),
|
||||||
|
externalInfoUpdatedAt: z.string(),
|
||||||
|
externalUrl: z.string(),
|
||||||
|
fullText: z.string(),
|
||||||
|
genres: z.array(genre),
|
||||||
|
id: z.string(),
|
||||||
|
largeImageUrl: z.string().optional(),
|
||||||
|
mbzArtistId: z.string().optional(),
|
||||||
|
mediumImageUrl: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
orderArtistName: z.string(),
|
||||||
|
playCount: z.number(),
|
||||||
|
playDate: z.string(),
|
||||||
|
rating: z.number(),
|
||||||
|
size: z.number(),
|
||||||
|
smallImageUrl: z.string().optional(),
|
||||||
|
songCount: z.number(),
|
||||||
|
starred: z.boolean(),
|
||||||
|
starredAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumArtistList = z.array(albumArtist);
|
||||||
|
|
||||||
|
const ndAlbumArtistListSort = {
|
||||||
|
ALBUM_COUNT: 'albumCount',
|
||||||
|
FAVORITED: 'starred ASC, starredAt ASC',
|
||||||
|
NAME: 'name',
|
||||||
|
PLAY_COUNT: 'playCount',
|
||||||
|
RATING: 'rating',
|
||||||
|
SONG_COUNT: 'songCount',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const albumArtistListParameters = paginationParameters.extend({
|
||||||
|
_sort: z.nativeEnum(ndAlbumArtistListSort).optional(),
|
||||||
|
genre_id: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
starred: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const album = z.object({
|
||||||
|
albumArtist: z.string(),
|
||||||
|
albumArtistId: z.string(),
|
||||||
|
allArtistIds: z.string(),
|
||||||
|
artist: z.string(),
|
||||||
|
artistId: z.string(),
|
||||||
|
compilation: z.boolean(),
|
||||||
|
coverArtId: z.string().optional(), // Removed after v0.48.0
|
||||||
|
coverArtPath: z.string().optional(), // Removed after v0.48.0
|
||||||
|
createdAt: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
fullText: z.string(),
|
||||||
|
genre: z.string(),
|
||||||
|
genres: z.array(genre),
|
||||||
|
id: z.string(),
|
||||||
|
maxYear: z.number(),
|
||||||
|
mbzAlbumArtistId: z.string().optional(),
|
||||||
|
mbzAlbumId: z.string().optional(),
|
||||||
|
minYear: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
orderAlbumArtistName: z.string(),
|
||||||
|
orderAlbumName: z.string(),
|
||||||
|
playCount: z.number(),
|
||||||
|
playDate: z.string(),
|
||||||
|
rating: z.number().optional(),
|
||||||
|
size: z.number(),
|
||||||
|
songCount: z.number(),
|
||||||
|
sortAlbumArtistName: z.string(),
|
||||||
|
sortArtistName: z.string(),
|
||||||
|
starred: z.boolean(),
|
||||||
|
starredAt: z.string().optional(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumList = z.array(album);
|
||||||
|
|
||||||
|
const ndAlbumListSort = {
|
||||||
|
ALBUM_ARTIST: 'albumArtist',
|
||||||
|
ARTIST: 'artist',
|
||||||
|
DURATION: 'duration',
|
||||||
|
NAME: 'name',
|
||||||
|
PLAY_COUNT: 'playCount',
|
||||||
|
PLAY_DATE: 'play_date',
|
||||||
|
RANDOM: 'random',
|
||||||
|
RATING: 'rating',
|
||||||
|
RECENTLY_ADDED: 'recently_added',
|
||||||
|
SONG_COUNT: 'songCount',
|
||||||
|
STARRED: 'starred',
|
||||||
|
YEAR: 'max_year',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const albumListParameters = paginationParameters.extend({
|
||||||
|
_sort: z.nativeEnum(ndAlbumListSort).optional(),
|
||||||
|
album_id: z.string().optional(),
|
||||||
|
artist_id: z.string().optional(),
|
||||||
|
compilation: z.boolean().optional(),
|
||||||
|
genre_id: z.string().optional(),
|
||||||
|
has_rating: z.boolean().optional(),
|
||||||
|
id: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
recently_added: z.boolean().optional(),
|
||||||
|
starred: z.boolean().optional(),
|
||||||
|
year: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const song = z.object({
|
||||||
|
album: z.string(),
|
||||||
|
albumArtist: z.string(),
|
||||||
|
albumArtistId: z.string(),
|
||||||
|
albumId: z.string(),
|
||||||
|
artist: z.string(),
|
||||||
|
artistId: z.string(),
|
||||||
|
bitRate: z.number(),
|
||||||
|
bookmarkPosition: z.number(),
|
||||||
|
bpm: z.number().optional(),
|
||||||
|
channels: z.number().optional(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
compilation: z.boolean(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
discNumber: z.number(),
|
||||||
|
duration: z.number(),
|
||||||
|
fullText: z.string(),
|
||||||
|
genre: z.string(),
|
||||||
|
genres: z.array(genre),
|
||||||
|
hasCoverArt: z.boolean(),
|
||||||
|
id: z.string(),
|
||||||
|
lyrics: z.string().optional(),
|
||||||
|
mbzAlbumArtistId: z.string().optional(),
|
||||||
|
mbzAlbumId: z.string().optional(),
|
||||||
|
mbzArtistId: z.string().optional(),
|
||||||
|
mbzTrackId: z.string().optional(),
|
||||||
|
orderAlbumArtistName: z.string(),
|
||||||
|
orderAlbumName: z.string(),
|
||||||
|
orderArtistName: z.string(),
|
||||||
|
orderTitle: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
playCount: z.number(),
|
||||||
|
playDate: z.string(),
|
||||||
|
rating: z.number().optional(),
|
||||||
|
size: z.number(),
|
||||||
|
sortAlbumArtistName: z.string(),
|
||||||
|
sortArtistName: z.string(),
|
||||||
|
starred: z.boolean(),
|
||||||
|
starredAt: z.string().optional(),
|
||||||
|
suffix: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
trackNumber: z.number(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
year: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const songList = z.array(song);
|
||||||
|
|
||||||
|
const ndSongListSort = {
|
||||||
|
ALBUM: 'album, order_album_artist_name, disc_number, track_number, title',
|
||||||
|
ALBUM_ARTIST: 'order_album_artist_name, album, disc_number, track_number, title',
|
||||||
|
ALBUM_SONGS: 'album, discNumber, trackNumber',
|
||||||
|
ARTIST: 'artist',
|
||||||
|
BPM: 'bpm',
|
||||||
|
CHANNELS: 'channels',
|
||||||
|
COMMENT: 'comment',
|
||||||
|
DURATION: 'duration',
|
||||||
|
FAVORITED: 'starred ASC, starredAt ASC',
|
||||||
|
GENRE: 'genre',
|
||||||
|
ID: 'id',
|
||||||
|
PLAY_COUNT: 'playCount',
|
||||||
|
PLAY_DATE: 'playDate',
|
||||||
|
RATING: 'rating',
|
||||||
|
RECENTLY_ADDED: 'createdAt',
|
||||||
|
TITLE: 'title',
|
||||||
|
TRACK: 'track',
|
||||||
|
YEAR: 'year, album, discNumber, trackNumber',
|
||||||
|
};
|
||||||
|
|
||||||
|
const songListParameters = paginationParameters.extend({
|
||||||
|
_sort: z.nativeEnum(ndSongListSort).optional(),
|
||||||
|
album_id: z.array(z.string()).optional(),
|
||||||
|
artist_id: z.array(z.string()).optional(),
|
||||||
|
genre_id: z.string().optional(),
|
||||||
|
starred: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlist = z.object({
|
||||||
|
comment: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
evaluatedAt: z.string(),
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
ownerId: z.string(),
|
||||||
|
ownerName: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
public: z.boolean(),
|
||||||
|
rules: z.record(z.string(), z.any()),
|
||||||
|
size: z.number(),
|
||||||
|
songCount: z.number(),
|
||||||
|
sync: z.boolean(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistList = z.array(playlist);
|
||||||
|
|
||||||
|
const ndPlaylistListSort = {
|
||||||
|
DURATION: 'duration',
|
||||||
|
NAME: 'name',
|
||||||
|
OWNER: 'ownerName',
|
||||||
|
PUBLIC: 'public',
|
||||||
|
SONG_COUNT: 'songCount',
|
||||||
|
UPDATED_AT: 'updatedAt',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const playlistListParameters = paginationParameters.extend({
|
||||||
|
_sort: z.nativeEnum(ndPlaylistListSort).optional(),
|
||||||
|
owner_id: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistSong = song.extend({
|
||||||
|
mediaFileId: z.string(),
|
||||||
|
playlistId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistSongList = z.array(playlistSong);
|
||||||
|
|
||||||
|
const createPlaylist = playlist.pick({
|
||||||
|
id: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPlaylistParameters = z.object({
|
||||||
|
comment: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
public: z.boolean().optional(),
|
||||||
|
rules: z.record(z.any()).optional(),
|
||||||
|
sync: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePlaylist = playlist;
|
||||||
|
|
||||||
|
const updatePlaylistParameters = createPlaylistParameters.partial();
|
||||||
|
|
||||||
|
const deletePlaylist = z.null();
|
||||||
|
|
||||||
|
const addToPlaylist = z.object({
|
||||||
|
added: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addToPlaylistParameters = z.object({
|
||||||
|
ids: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeFromPlaylist = z.object({
|
||||||
|
ids: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeFromPlaylistParameters = z.object({
|
||||||
|
ids: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ndType = {
|
||||||
|
_enum: {
|
||||||
|
albumArtistList: ndAlbumArtistListSort,
|
||||||
|
albumList: ndAlbumListSort,
|
||||||
|
playlistList: ndPlaylistListSort,
|
||||||
|
songList: ndSongListSort,
|
||||||
|
userList: ndUserListSort,
|
||||||
|
},
|
||||||
|
_parameters: {
|
||||||
|
addToPlaylist: addToPlaylistParameters,
|
||||||
|
albumArtistList: albumArtistListParameters,
|
||||||
|
albumList: albumListParameters,
|
||||||
|
authenticate: authenticateParameters,
|
||||||
|
createPlaylist: createPlaylistParameters,
|
||||||
|
playlistList: playlistListParameters,
|
||||||
|
removeFromPlaylist: removeFromPlaylistParameters,
|
||||||
|
songList: songListParameters,
|
||||||
|
updatePlaylist: updatePlaylistParameters,
|
||||||
|
userList: userListParameters,
|
||||||
|
},
|
||||||
|
_response: {
|
||||||
|
addToPlaylist,
|
||||||
|
album,
|
||||||
|
albumArtist,
|
||||||
|
albumArtistList,
|
||||||
|
albumList,
|
||||||
|
authenticate,
|
||||||
|
createPlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
error,
|
||||||
|
genre,
|
||||||
|
genreList,
|
||||||
|
playlist,
|
||||||
|
playlistList,
|
||||||
|
playlistSong,
|
||||||
|
playlistSongList,
|
||||||
|
removeFromPlaylist,
|
||||||
|
song,
|
||||||
|
songList,
|
||||||
|
updatePlaylist,
|
||||||
|
user,
|
||||||
|
userList,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,223 +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,
|
|
||||||
} from '/@/renderer/api/navidrome.types';
|
|
||||||
import { SSGenreList, SSMusicFolderList } from '/@/renderer/api/subsonic.types';
|
|
||||||
import type {
|
|
||||||
Album,
|
|
||||||
RawAlbumArtistListResponse,
|
|
||||||
RawAlbumDetailResponse,
|
|
||||||
RawAlbumListResponse,
|
|
||||||
RawGenreListResponse,
|
|
||||||
RawMusicFolderListResponse,
|
|
||||||
RawPlaylistDetailResponse,
|
|
||||||
RawPlaylistListResponse,
|
|
||||||
RawSongListResponse,
|
|
||||||
} 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 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 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));
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const normalize = {
|
|
||||||
albumArtistList,
|
|
||||||
albumDetail,
|
|
||||||
albumList,
|
|
||||||
genreList,
|
|
||||||
musicFolderList,
|
|
||||||
playlistDetail,
|
|
||||||
playlistList,
|
|
||||||
songList,
|
|
||||||
};
|
|
||||||
@@ -7,11 +7,14 @@ import type {
|
|||||||
PlaylistListQuery,
|
PlaylistListQuery,
|
||||||
PlaylistDetailQuery,
|
PlaylistDetailQuery,
|
||||||
PlaylistSongListQuery,
|
PlaylistSongListQuery,
|
||||||
|
UserListQuery,
|
||||||
|
AlbumArtistDetailQuery,
|
||||||
|
TopSongListQuery,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
albumArtists: {
|
albumArtists: {
|
||||||
detail: (serverId: string, query?: AlbumArtistListQuery) => {
|
detail: (serverId: string, query?: AlbumArtistDetailQuery) => {
|
||||||
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
if (query) return [serverId, 'albumArtists', 'detail', query] as const;
|
||||||
return [serverId, 'albumArtists', 'detail'] as const;
|
return [serverId, 'albumArtists', 'detail'] as const;
|
||||||
},
|
},
|
||||||
@@ -20,6 +23,10 @@ export const queryKeys = {
|
|||||||
return [serverId, 'albumArtists', 'list'] as const;
|
return [serverId, 'albumArtists', 'list'] as const;
|
||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
root: (serverId: string) => [serverId, 'albumArtists'] as const,
|
||||||
|
topSongs: (serverId: string, query?: TopSongListQuery) => {
|
||||||
|
if (query) return [serverId, 'albumArtists', 'topSongs', query] as const;
|
||||||
|
return [serverId, 'albumArtists', 'topSongs'] as const;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
albums: {
|
albums: {
|
||||||
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
detail: (serverId: string, query?: AlbumDetailQuery) =>
|
||||||
@@ -79,4 +86,11 @@ export const queryKeys = {
|
|||||||
},
|
},
|
||||||
root: (serverId: string) => [serverId, 'songs'] as const,
|
root: (serverId: string) => [serverId, 'songs'] as const,
|
||||||
},
|
},
|
||||||
|
users: {
|
||||||
|
list: (serverId: string, query?: UserListQuery) => {
|
||||||
|
if (query) return [serverId, 'users', 'list', query] as const;
|
||||||
|
return [serverId, 'users', 'list'] as const;
|
||||||
|
},
|
||||||
|
root: (serverId: string) => [serverId, 'users'] as const,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
import ky from 'ky';
|
|
||||||
import md5 from 'md5';
|
|
||||||
import { randomString } from '/@/renderer/utils';
|
|
||||||
import type {
|
|
||||||
SSAlbumListResponse,
|
|
||||||
SSAlbumDetailResponse,
|
|
||||||
SSArtistIndex,
|
|
||||||
SSAlbumArtistList,
|
|
||||||
SSAlbumArtistListResponse,
|
|
||||||
SSGenreListResponse,
|
|
||||||
SSMusicFolderList,
|
|
||||||
SSMusicFolderListResponse,
|
|
||||||
SSGenreList,
|
|
||||||
SSAlbumDetail,
|
|
||||||
SSAlbumList,
|
|
||||||
SSAlbumArtistDetail,
|
|
||||||
SSAlbumArtistDetailResponse,
|
|
||||||
SSFavoriteParams,
|
|
||||||
SSFavoriteResponse,
|
|
||||||
SSRatingParams,
|
|
||||||
SSRatingResponse,
|
|
||||||
SSAlbumArtistDetailParams,
|
|
||||||
SSAlbumArtistListParams,
|
|
||||||
} from '/@/renderer/api/subsonic.types';
|
|
||||||
import type {
|
|
||||||
AlbumArtistDetailArgs,
|
|
||||||
AlbumArtistListArgs,
|
|
||||||
AlbumDetailArgs,
|
|
||||||
AlbumListArgs,
|
|
||||||
AuthenticationResponse,
|
|
||||||
FavoriteArgs,
|
|
||||||
FavoriteResponse,
|
|
||||||
GenreListArgs,
|
|
||||||
MusicFolderListArgs,
|
|
||||||
RatingArgs,
|
|
||||||
} from '/@/renderer/api/types';
|
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
|
||||||
import { toast } from '/@/renderer/components/toast';
|
|
||||||
|
|
||||||
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}/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') {
|
|
||||||
toast.warn({ message: 'Issue from Subsonic API' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(data['subsonic-response']), { status: 200 });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
beforeRequest: [
|
|
||||||
(request) => {
|
|
||||||
const server = useAuthStore.getState().currentServer;
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (server) {
|
|
||||||
const authParams = server.credential.split(/&?\w=/gm);
|
|
||||||
|
|
||||||
searchParams.set('u', server.username);
|
|
||||||
searchParams.set('v', '1.13.0');
|
|
||||||
searchParams.set('c', 'Feishin');
|
|
||||||
searchParams.set('f', 'json');
|
|
||||||
|
|
||||||
if (authParams?.length === 4) {
|
|
||||||
searchParams.set('s', authParams[2]);
|
|
||||||
searchParams.set('t', authParams[3]);
|
|
||||||
} else if (authParams?.length === 3) {
|
|
||||||
searchParams.set('p', authParams[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ky(request, { searchParams });
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 data = await api
|
|
||||||
.get('rest/getMusicFolders.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<SSMusicFolderListResponse>();
|
|
||||||
|
|
||||||
return data.musicFolders.musicFolder;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAlbumArtistDetail = async (
|
|
||||||
args: AlbumArtistDetailArgs,
|
|
||||||
): Promise<SSAlbumArtistDetail> => {
|
|
||||||
const { server, signal, query } = args;
|
|
||||||
|
|
||||||
const searchParams: SSAlbumArtistDetailParams = {
|
|
||||||
id: query.id,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 searchParams: SSAlbumArtistListParams = {
|
|
||||||
musicFolderId: query.musicFolderId,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 data = await api
|
|
||||||
.get('rest/getGenres.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<SSGenreListResponse>();
|
|
||||||
|
|
||||||
return data.genres.genre;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
|
||||||
const { server, query, signal } = args;
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get('rest/getAlbum.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: { id: query.id },
|
|
||||||
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 normalizedParams = {};
|
|
||||||
const data = await api
|
|
||||||
.get('rest/getAlbumList2.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams: normalizedParams,
|
|
||||||
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 searchParams: SSFavoriteParams = {
|
|
||||||
albumId: query.type === 'album' ? query.id : undefined,
|
|
||||||
artistId: query.type === 'albumArtist' ? query.id : undefined,
|
|
||||||
id: query.type === 'song' ? query.id : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
await api
|
|
||||||
.get('rest/star.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<SSFavoriteResponse>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: query.id,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
|
||||||
const { server, query, signal } = args;
|
|
||||||
|
|
||||||
const searchParams: SSFavoriteParams = {
|
|
||||||
albumId: query.type === 'album' ? query.id : undefined,
|
|
||||||
artistId: query.type === 'albumArtist' ? query.id : undefined,
|
|
||||||
id: query.type === 'song' ? query.id : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
await api
|
|
||||||
.get('rest/unstar.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<SSFavoriteResponse>();
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: query.id,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRating = async (args: RatingArgs) => {
|
|
||||||
const { server, query, signal } = args;
|
|
||||||
|
|
||||||
const searchParams: SSRatingParams = {
|
|
||||||
id: query.id,
|
|
||||||
rating: query.rating,
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await api
|
|
||||||
.get('rest/setRating.view', {
|
|
||||||
prefixUrl: server?.url,
|
|
||||||
searchParams,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
.json<SSRatingResponse>();
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const subsonicApi = {
|
|
||||||
authenticate,
|
|
||||||
createFavorite,
|
|
||||||
deleteFavorite,
|
|
||||||
getAlbumArtistDetail,
|
|
||||||
getAlbumArtistList,
|
|
||||||
getAlbumDetail,
|
|
||||||
getAlbumList,
|
|
||||||
getCoverArtUrl,
|
|
||||||
getGenreList,
|
|
||||||
getMusicFolderList,
|
|
||||||
updateRating,
|
|
||||||
};
|
|
||||||
@@ -65,6 +65,12 @@ export type SSAlbumDetailResponse = {
|
|||||||
album: SSAlbum;
|
album: SSAlbum;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SSArtistInfoParams = {
|
||||||
|
count?: number;
|
||||||
|
id: string;
|
||||||
|
includeNotPresent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type SSArtistInfoResponse = {
|
export type SSArtistInfoResponse = {
|
||||||
artistInfo2: SSArtistInfo;
|
artistInfo2: SSArtistInfo;
|
||||||
};
|
};
|
||||||
@@ -75,6 +81,13 @@ export type SSArtistInfo = {
|
|||||||
lastFmUrl?: string;
|
lastFmUrl?: string;
|
||||||
mediumImageUrl?: string;
|
mediumImageUrl?: string;
|
||||||
musicBrainzId?: string;
|
musicBrainzId?: string;
|
||||||
|
similarArtist?: {
|
||||||
|
albumCount: string;
|
||||||
|
artistImageUrl?: string;
|
||||||
|
coverArt?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
smallImageUrl?: string;
|
smallImageUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,3 +199,26 @@ export type SSRatingParams = {
|
|||||||
export type SSRating = null;
|
export type SSRating = null;
|
||||||
|
|
||||||
export type SSRatingResponse = null;
|
export type SSRatingResponse = null;
|
||||||
|
|
||||||
|
export type SSTopSongListParams = {
|
||||||
|
artist: string;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSTopSongListResponse = {
|
||||||
|
topSongs: {
|
||||||
|
song: SSSong[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSTopSongList = {
|
||||||
|
items: SSSong[];
|
||||||
|
startIndex: number;
|
||||||
|
totalRecordCount: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSScrobbleParams = {
|
||||||
|
id: string;
|
||||||
|
submission?: boolean;
|
||||||
|
time?: number;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { initClient, initContract } from '@ts-rest/core';
|
||||||
|
import axios, { Method, AxiosError, isAxiosError, AxiosResponse } from 'axios';
|
||||||
|
import qs from 'qs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
|
import { ServerListItem } from '/@/renderer/api/types';
|
||||||
|
import { toast } from '/@/renderer/components/toast/index';
|
||||||
|
|
||||||
|
const c = initContract();
|
||||||
|
|
||||||
|
export const contract = c.router({
|
||||||
|
authenticate: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'ping.view',
|
||||||
|
query: ssType._parameters.authenticate,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.authenticate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createFavorite: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'star.view',
|
||||||
|
query: ssType._parameters.createFavorite,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.createFavorite,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getArtistInfo: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getArtistInfo.view',
|
||||||
|
query: ssType._parameters.artistInfo,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.artistInfo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getMusicFolderList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getMusicFolders.view',
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.musicFolderList,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getTopSongsList: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'getTopSongs.view',
|
||||||
|
query: ssType._parameters.topSongsList,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.topSongsList,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
removeFavorite: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'unstar.view',
|
||||||
|
query: ssType._parameters.removeFavorite,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.removeFavorite,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scrobble: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'scrobble.view',
|
||||||
|
query: ssType._parameters.scrobble,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.scrobble,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setRating: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'setRating.view',
|
||||||
|
query: ssType._parameters.setRating,
|
||||||
|
responses: {
|
||||||
|
200: ssType._response.setRating,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const axiosClient = axios.create({});
|
||||||
|
|
||||||
|
axiosClient.defaults.paramsSerializer = (params) => {
|
||||||
|
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||||
|
};
|
||||||
|
|
||||||
|
axiosClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
if (data['subsonic-response'].status !== 'ok') {
|
||||||
|
// Suppress code related to non-linked lastfm or spotify from Navidrome
|
||||||
|
if (data['subsonic-response'].error.code !== 0) {
|
||||||
|
toast.error({
|
||||||
|
message: data['subsonic-response'].error.message,
|
||||||
|
title: 'Issue from Subsonic API',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ssApiClient = (args: {
|
||||||
|
server: ServerListItem | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
url?: string;
|
||||||
|
}) => {
|
||||||
|
const { server, url, signal } = args;
|
||||||
|
|
||||||
|
return initClient(contract, {
|
||||||
|
api: async ({ path, method, headers, body }) => {
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
const authParams: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (server) {
|
||||||
|
baseUrl = `${server.url}/rest`;
|
||||||
|
const token = server.credential;
|
||||||
|
const params = token.split(/&?\w=/gm);
|
||||||
|
|
||||||
|
authParams.u = server.username;
|
||||||
|
if (params?.length === 4) {
|
||||||
|
authParams.s = params[2];
|
||||||
|
authParams.t = params[3];
|
||||||
|
} else if (params?.length === 3) {
|
||||||
|
authParams.p = params[2];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await axiosClient.request<z.infer<typeof ssType._response.baseResponse>>({
|
||||||
|
data: body,
|
||||||
|
headers,
|
||||||
|
method: method as Method,
|
||||||
|
params: {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
url: `${baseUrl}/${path}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: result.data['subsonic-response'],
|
||||||
|
status: result.status,
|
||||||
|
};
|
||||||
|
} catch (e: Error | AxiosError | any) {
|
||||||
|
if (isAxiosError(e)) {
|
||||||
|
const error = e as AxiosError;
|
||||||
|
const response = error.response as AxiosResponse;
|
||||||
|
return {
|
||||||
|
body: response.data,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
baseHeaders: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
baseUrl: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import md5 from 'md5';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||||
|
import { ssNormalize } from '/@/renderer/api/subsonic/subsonic-normalize';
|
||||||
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
|
import {
|
||||||
|
ArtistInfoArgs,
|
||||||
|
AuthenticationResponse,
|
||||||
|
FavoriteArgs,
|
||||||
|
FavoriteResponse,
|
||||||
|
LibraryItem,
|
||||||
|
MusicFolderListArgs,
|
||||||
|
MusicFolderListResponse,
|
||||||
|
SetRatingArgs,
|
||||||
|
RatingResponse,
|
||||||
|
ScrobbleArgs,
|
||||||
|
ScrobbleResponse,
|
||||||
|
SongListResponse,
|
||||||
|
TopSongListArgs,
|
||||||
|
} from '/@/renderer/api/types';
|
||||||
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
|
||||||
|
const authenticate = async (
|
||||||
|
url: string,
|
||||||
|
body: {
|
||||||
|
legacy?: boolean;
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
},
|
||||||
|
): Promise<AuthenticationResponse> => {
|
||||||
|
let credential: string;
|
||||||
|
let credentialParams: {
|
||||||
|
p?: string;
|
||||||
|
s?: string;
|
||||||
|
t?: string;
|
||||||
|
u: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanServerUrl = url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (body.legacy) {
|
||||||
|
credential = `u=${body.username}&p=${body.password}`;
|
||||||
|
credentialParams = {
|
||||||
|
p: body.password,
|
||||||
|
u: body.username,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const salt = randomString(12);
|
||||||
|
const hash = md5(body.password + salt);
|
||||||
|
credential = `u=${body.username}&s=${salt}&t=${hash}`;
|
||||||
|
credentialParams = {
|
||||||
|
s: salt,
|
||||||
|
t: hash,
|
||||||
|
u: body.username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await ssApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
||||||
|
query: {
|
||||||
|
c: 'Feishin',
|
||||||
|
f: 'json',
|
||||||
|
v: '1.13.0',
|
||||||
|
...credentialParams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
credential,
|
||||||
|
userId: null,
|
||||||
|
username: body.username,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMusicFolderList = async (args: MusicFolderListArgs): Promise<MusicFolderListResponse> => {
|
||||||
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).getMusicFolderList({});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get music folder list');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: res.body.musicFolders.musicFolder,
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.musicFolders.musicFolder.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// export const getAlbumArtistDetail = async (
|
||||||
|
// args: AlbumArtistDetailArgs,
|
||||||
|
// ): Promise<SSAlbumArtistDetail> => {
|
||||||
|
// const { server, signal, query } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams: SSAlbumArtistDetailParams = {
|
||||||
|
// id: query.id,
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('/getArtist.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams,
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumArtistDetailResponse>();
|
||||||
|
|
||||||
|
// return data.artist;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getAlbumArtistList = async (args: AlbumArtistListArgs): Promise<SSAlbumArtistList> => {
|
||||||
|
// const { signal, server, query } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams: SSAlbumArtistListParams = {
|
||||||
|
// musicFolderId: query.musicFolderId,
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getArtists.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams,
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumArtistListResponse>();
|
||||||
|
|
||||||
|
// const artists = (data.artists?.index || []).flatMap((index: SSArtistIndex) => index.artist);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// items: artists,
|
||||||
|
// startIndex: query.startIndex,
|
||||||
|
// totalRecordCount: null,
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getGenreList = async (args: GenreListArgs): Promise<SSGenreList> => {
|
||||||
|
// const { server, signal } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getGenres.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams: defaultParams,
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSGenreListResponse>();
|
||||||
|
|
||||||
|
// return data.genres.genre;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getAlbumDetail = async (args: AlbumDetailArgs): Promise<SSAlbumDetail> => {
|
||||||
|
// const { server, query, signal } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams = {
|
||||||
|
// id: query.id,
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getAlbum.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams: parseSearchParams(searchParams),
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumDetailResponse>();
|
||||||
|
|
||||||
|
// const { song: songs, ...dataWithoutSong } = data.album;
|
||||||
|
// return { ...dataWithoutSong, songs };
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const getAlbumList = async (args: AlbumListArgs): Promise<SSAlbumList> => {
|
||||||
|
// const { server, query, signal } = args;
|
||||||
|
// const defaultParams = getDefaultParams(server);
|
||||||
|
|
||||||
|
// const searchParams = {
|
||||||
|
// ...defaultParams,
|
||||||
|
// };
|
||||||
|
// const data = await api
|
||||||
|
// .get('rest/getAlbumList2.view', {
|
||||||
|
// prefixUrl: server?.url,
|
||||||
|
// searchParams: parseSearchParams(searchParams),
|
||||||
|
// signal,
|
||||||
|
// })
|
||||||
|
// .json<SSAlbumListResponse>();
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// items: data.albumList2.album,
|
||||||
|
// startIndex: query.startIndex,
|
||||||
|
// totalRecordCount: null,
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
const createFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).createFavorite({
|
||||||
|
query: {
|
||||||
|
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||||
|
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||||
|
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to create favorite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).removeFavorite({
|
||||||
|
query: {
|
||||||
|
albumId: query.type === LibraryItem.ALBUM ? query.id : undefined,
|
||||||
|
artistId: query.type === LibraryItem.ALBUM_ARTIST ? query.id : undefined,
|
||||||
|
id: query.type === LibraryItem.SONG ? query.id : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to delete favorite');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRating = async (args: SetRatingArgs): Promise<RatingResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const itemIds = query.item.map((item) => item.id);
|
||||||
|
|
||||||
|
for (const id of itemIds) {
|
||||||
|
await ssApiClient(apiClientProps).setRating({
|
||||||
|
query: {
|
||||||
|
id,
|
||||||
|
rating: query.rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTopSongList = async (args: TopSongListArgs): Promise<SongListResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).getTopSongsList({
|
||||||
|
query: {
|
||||||
|
artist: query.artist,
|
||||||
|
count: query.limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get top songs');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items:
|
||||||
|
res.body.topSongs?.song?.map((song) => ssNormalize.song(song, apiClientProps.server, '')) ||
|
||||||
|
[],
|
||||||
|
startIndex: 0,
|
||||||
|
totalRecordCount: res.body.topSongs?.song?.length || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArtistInfo = async (
|
||||||
|
args: ArtistInfoArgs,
|
||||||
|
): Promise<z.infer<typeof ssType._response.artistInfo>> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).getArtistInfo({
|
||||||
|
query: {
|
||||||
|
count: query.limit,
|
||||||
|
id: query.artistId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to get artist info');
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrobble = async (args: ScrobbleArgs): Promise<ScrobbleResponse> => {
|
||||||
|
const { query, apiClientProps } = args;
|
||||||
|
|
||||||
|
const res = await ssApiClient(apiClientProps).scrobble({
|
||||||
|
query: {
|
||||||
|
id: query.id,
|
||||||
|
submission: query.submission,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error('Failed to scrobble');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssController = {
|
||||||
|
authenticate,
|
||||||
|
createFavorite,
|
||||||
|
getArtistInfo,
|
||||||
|
getMusicFolderList,
|
||||||
|
getTopSongList,
|
||||||
|
removeFavorite,
|
||||||
|
scrobble,
|
||||||
|
setRating,
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ssType } from '/@/renderer/api/subsonic/subsonic-types';
|
||||||
|
import { QueueSong, LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const getCoverArtUrl = (args: {
|
||||||
|
baseUrl: string | undefined;
|
||||||
|
coverArtId?: string;
|
||||||
|
credential: string | undefined;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
const size = args.size ? args.size : 150;
|
||||||
|
|
||||||
|
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`${args.baseUrl}/rest/getCoverArt.view` +
|
||||||
|
`?id=${args.coverArtId}` +
|
||||||
|
`&${args.credential}` +
|
||||||
|
'&v=1.13.0' +
|
||||||
|
'&c=feishin' +
|
||||||
|
`&size=${size}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSong = (
|
||||||
|
item: z.infer<typeof ssType._response.song>,
|
||||||
|
server: ServerListItem | null,
|
||||||
|
deviceId: string,
|
||||||
|
): QueueSong => {
|
||||||
|
const imageUrl =
|
||||||
|
getCoverArtUrl({
|
||||||
|
baseUrl: server?.url,
|
||||||
|
coverArtId: item.coverArt,
|
||||||
|
credential: server?.credential,
|
||||||
|
size: 100,
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
const streamUrl = `${server?.url}/rest/stream.view?id=${item.id}&v=1.13.0&c=feishin_${deviceId}&${server?.credential}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
album: item.album || '',
|
||||||
|
albumArtists: [
|
||||||
|
{
|
||||||
|
id: item.artistId || '',
|
||||||
|
imageUrl: null,
|
||||||
|
name: item.artist || '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
albumId: item.albumId || '',
|
||||||
|
artistName: item.artist || '',
|
||||||
|
artists: [
|
||||||
|
{
|
||||||
|
id: item.artistId || '',
|
||||||
|
imageUrl: null,
|
||||||
|
name: item.artist || '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bitRate: item.bitRate || 0,
|
||||||
|
bpm: null,
|
||||||
|
channels: null,
|
||||||
|
comment: null,
|
||||||
|
compilation: null,
|
||||||
|
container: item.contentType,
|
||||||
|
createdAt: item.created,
|
||||||
|
discNumber: item.discNumber || 1,
|
||||||
|
duration: item.duration || 0,
|
||||||
|
genres: item.genre
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: item.genre,
|
||||||
|
name: item.genre,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
id: item.id,
|
||||||
|
imagePlaceholderUrl: null,
|
||||||
|
imageUrl,
|
||||||
|
itemType: LibraryItem.SONG,
|
||||||
|
lastPlayedAt: null,
|
||||||
|
name: item.title,
|
||||||
|
path: item.path,
|
||||||
|
playCount: item?.playCount || 0,
|
||||||
|
releaseDate: null,
|
||||||
|
releaseYear: item.year ? String(item.year) : null,
|
||||||
|
serverId: server?.id || 'unknown',
|
||||||
|
serverType: ServerType.SUBSONIC,
|
||||||
|
size: item.size,
|
||||||
|
streamUrl,
|
||||||
|
trackNumber: item.track || 1,
|
||||||
|
uniqueId: nanoid(),
|
||||||
|
updatedAt: '',
|
||||||
|
userFavorite: item.starred || false,
|
||||||
|
userRating: item.userRating || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ssNormalize = {
|
||||||
|
song: normalizeSong,
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const baseResponse = z.object({
|
||||||
|
'subsonic-response': z.object({
|
||||||
|
status: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticate = z.null();
|
||||||
|
|
||||||
|
const authenticateParameters = z.object({
|
||||||
|
c: z.string(),
|
||||||
|
f: z.string(),
|
||||||
|
p: z.string().optional(),
|
||||||
|
s: z.string().optional(),
|
||||||
|
t: z.string().optional(),
|
||||||
|
u: z.string(),
|
||||||
|
v: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFavoriteParameters = z.object({
|
||||||
|
albumId: z.array(z.string()).optional(),
|
||||||
|
artistId: z.array(z.string()).optional(),
|
||||||
|
id: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createFavorite = z.null();
|
||||||
|
|
||||||
|
const removeFavoriteParameters = z.object({
|
||||||
|
albumId: z.array(z.string()).optional(),
|
||||||
|
artistId: z.array(z.string()).optional(),
|
||||||
|
id: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeFavorite = z.null();
|
||||||
|
|
||||||
|
const setRatingParameters = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
rating: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setRating = z.null();
|
||||||
|
|
||||||
|
const musicFolder = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const musicFolderList = z.object({
|
||||||
|
musicFolders: z.object({
|
||||||
|
musicFolder: z.array(musicFolder),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const song = z.object({
|
||||||
|
album: z.string().optional(),
|
||||||
|
albumId: z.string().optional(),
|
||||||
|
artist: z.string().optional(),
|
||||||
|
artistId: z.string().optional(),
|
||||||
|
averageRating: z.number().optional(),
|
||||||
|
bitRate: z.number().optional(),
|
||||||
|
contentType: z.string(),
|
||||||
|
coverArt: z.string().optional(),
|
||||||
|
created: z.string(),
|
||||||
|
discNumber: z.number(),
|
||||||
|
duration: z.number().optional(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
isDir: z.boolean(),
|
||||||
|
isVideo: z.boolean(),
|
||||||
|
parent: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
playCount: z.number().optional(),
|
||||||
|
size: z.number(),
|
||||||
|
starred: z.boolean().optional(),
|
||||||
|
suffix: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
track: z.number().optional(),
|
||||||
|
type: z.string(),
|
||||||
|
userRating: z.number().optional(),
|
||||||
|
year: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const album = z.object({
|
||||||
|
album: z.string(),
|
||||||
|
artist: z.string(),
|
||||||
|
artistId: z.string(),
|
||||||
|
coverArt: z.string(),
|
||||||
|
created: z.string(),
|
||||||
|
duration: z.number(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
isDir: z.boolean(),
|
||||||
|
isVideo: z.boolean(),
|
||||||
|
name: z.string(),
|
||||||
|
parent: z.string(),
|
||||||
|
song: z.array(song),
|
||||||
|
songCount: z.number(),
|
||||||
|
starred: z.boolean().optional(),
|
||||||
|
title: z.string(),
|
||||||
|
userRating: z.number().optional(),
|
||||||
|
year: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumListParameters = z.object({
|
||||||
|
fromYear: z.number().optional(),
|
||||||
|
genre: z.string().optional(),
|
||||||
|
musicFolderId: z.string().optional(),
|
||||||
|
offset: z.number().optional(),
|
||||||
|
size: z.number().optional(),
|
||||||
|
toYear: z.number().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumList = z.array(album.omit({ song: true }));
|
||||||
|
|
||||||
|
const albumArtist = z.object({
|
||||||
|
albumCount: z.string(),
|
||||||
|
artistImageUrl: z.string().optional(),
|
||||||
|
coverArt: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const albumArtistList = z.object({
|
||||||
|
artist: z.array(albumArtist),
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistInfoParameters = z.object({
|
||||||
|
count: z.number().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
includeNotPresent: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistInfo = z.object({
|
||||||
|
artistInfo2: z.object({
|
||||||
|
biography: z.string().optional(),
|
||||||
|
largeImageUrl: z.string().optional(),
|
||||||
|
lastFmUrl: z.string().optional(),
|
||||||
|
mediumImageUrl: z.string().optional(),
|
||||||
|
musicBrainzId: z.string().optional(),
|
||||||
|
similarArtist: z.array(
|
||||||
|
z.object({
|
||||||
|
albumCount: z.string(),
|
||||||
|
artistImageUrl: z.string().optional(),
|
||||||
|
coverArt: z.string().optional(),
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
smallImageUrl: z.string().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topSongsListParameters = z.object({
|
||||||
|
artist: z.string(), // The name of the artist, not the artist ID
|
||||||
|
count: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const topSongsList = z.object({
|
||||||
|
topSongs: z.object({
|
||||||
|
song: z.array(song),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrobbleParameters = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
submission: z.boolean().optional(),
|
||||||
|
time: z.number().optional(), // The time (in milliseconds since 1 Jan 1970) at which the song was listened to.
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrobble = z.null();
|
||||||
|
|
||||||
|
export const ssType = {
|
||||||
|
_parameters: {
|
||||||
|
albumList: albumListParameters,
|
||||||
|
artistInfo: artistInfoParameters,
|
||||||
|
authenticate: authenticateParameters,
|
||||||
|
createFavorite: createFavoriteParameters,
|
||||||
|
removeFavorite: removeFavoriteParameters,
|
||||||
|
scrobble: scrobbleParameters,
|
||||||
|
setRating: setRatingParameters,
|
||||||
|
topSongsList: topSongsListParameters,
|
||||||
|
},
|
||||||
|
_response: {
|
||||||
|
albumArtistList,
|
||||||
|
albumList,
|
||||||
|
artistInfo,
|
||||||
|
authenticate,
|
||||||
|
baseResponse,
|
||||||
|
createFavorite,
|
||||||
|
musicFolderList,
|
||||||
|
removeFavorite,
|
||||||
|
scrobble,
|
||||||
|
setRating,
|
||||||
|
song,
|
||||||
|
topSongsList,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,53 +1,54 @@
|
|||||||
import {
|
import {
|
||||||
JFSortOrder,
|
JFSortOrder,
|
||||||
JFGenreList,
|
|
||||||
JFAlbumList,
|
|
||||||
JFAlbumListSort,
|
JFAlbumListSort,
|
||||||
JFAlbumDetail,
|
|
||||||
JFSongList,
|
|
||||||
JFSongListSort,
|
JFSongListSort,
|
||||||
JFAlbumArtistList,
|
|
||||||
JFAlbumArtistListSort,
|
JFAlbumArtistListSort,
|
||||||
JFAlbumArtistDetail,
|
|
||||||
JFArtistList,
|
|
||||||
JFArtistListSort,
|
JFArtistListSort,
|
||||||
JFPlaylistList,
|
|
||||||
JFPlaylistDetail,
|
|
||||||
JFMusicFolderList,
|
|
||||||
JFPlaylistListSort,
|
JFPlaylistListSort,
|
||||||
} from '/@/renderer/api/jellyfin.types';
|
} from '/@/renderer/api/jellyfin.types';
|
||||||
import {
|
import {
|
||||||
NDSortOrder,
|
NDSortOrder,
|
||||||
NDOrder,
|
NDOrder,
|
||||||
NDGenreList,
|
|
||||||
NDAlbumList,
|
|
||||||
NDAlbumListSort,
|
NDAlbumListSort,
|
||||||
NDAlbumDetail,
|
|
||||||
NDSongList,
|
|
||||||
NDSongDetail,
|
|
||||||
NDAlbumArtistList,
|
|
||||||
NDAlbumArtistListSort,
|
NDAlbumArtistListSort,
|
||||||
NDAlbumArtistDetail,
|
|
||||||
NDDeletePlaylist,
|
|
||||||
NDPlaylistList,
|
|
||||||
NDPlaylistListSort,
|
NDPlaylistListSort,
|
||||||
NDPlaylistDetail,
|
|
||||||
NDSongListSort,
|
NDSongListSort,
|
||||||
|
NDUserListSort,
|
||||||
} from '/@/renderer/api/navidrome.types';
|
} from '/@/renderer/api/navidrome.types';
|
||||||
import {
|
|
||||||
SSAlbumList,
|
export enum LibraryItem {
|
||||||
SSAlbumDetail,
|
ALBUM = 'album',
|
||||||
SSAlbumArtistList,
|
ALBUM_ARTIST = 'albumArtist',
|
||||||
SSAlbumArtistDetail,
|
ARTIST = 'artist',
|
||||||
SSMusicFolderList,
|
PLAYLIST = 'playlist',
|
||||||
SSGenreList,
|
SONG = 'song',
|
||||||
} from '/@/renderer/api/subsonic.types';
|
}
|
||||||
|
|
||||||
|
export type AnyLibraryItem = Album | AlbumArtist | Artist | Playlist | Song | QueueSong;
|
||||||
|
|
||||||
|
export type AnyLibraryItems =
|
||||||
|
| Album[]
|
||||||
|
| AlbumArtist[]
|
||||||
|
| Artist[]
|
||||||
|
| Playlist[]
|
||||||
|
| Song[]
|
||||||
|
| QueueSong[];
|
||||||
|
|
||||||
export enum SortOrder {
|
export enum SortOrder {
|
||||||
ASC = 'ASC',
|
ASC = 'ASC',
|
||||||
DESC = 'DESC',
|
DESC = 'DESC',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
createdAt: string | null;
|
||||||
|
email: string | null;
|
||||||
|
id: string;
|
||||||
|
isAdmin: boolean | null;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
name: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ServerListItem = {
|
export type ServerListItem = {
|
||||||
credential: string;
|
credential: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -128,8 +129,10 @@ export type AuthenticationResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Genre = {
|
export type Genre = {
|
||||||
|
albumCount?: number;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
songCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Album = {
|
export type Album = {
|
||||||
@@ -143,19 +146,21 @@ export type Album = {
|
|||||||
imagePlaceholderUrl: string | null;
|
imagePlaceholderUrl: string | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
isCompilation: boolean | null;
|
isCompilation: boolean | null;
|
||||||
isFavorite: boolean;
|
itemType: LibraryItem.ALBUM;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
playCount: number | null;
|
playCount: number | null;
|
||||||
rating: number | null;
|
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseYear: number | null;
|
releaseYear: number | null;
|
||||||
|
serverId: string;
|
||||||
serverType: ServerType;
|
serverType: ServerType;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
songCount: number | null;
|
songCount: number | null;
|
||||||
songs?: Song[];
|
songs?: Song[];
|
||||||
uniqueId: string;
|
uniqueId: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
userFavorite: boolean;
|
||||||
|
userRating: number | null;
|
||||||
} & { songs?: Song[] };
|
} & { songs?: Song[] };
|
||||||
|
|
||||||
export type Song = {
|
export type Song = {
|
||||||
@@ -167,6 +172,7 @@ export type Song = {
|
|||||||
bitRate: number;
|
bitRate: number;
|
||||||
bpm: number | null;
|
bpm: number | null;
|
||||||
channels: number | null;
|
channels: number | null;
|
||||||
|
comment: string | null;
|
||||||
compilation: boolean | null;
|
compilation: boolean | null;
|
||||||
container: string | null;
|
container: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -176,21 +182,23 @@ export type Song = {
|
|||||||
id: string;
|
id: string;
|
||||||
imagePlaceholderUrl: string | null;
|
imagePlaceholderUrl: string | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
isFavorite: boolean;
|
itemType: LibraryItem.SONG;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
note: string | null;
|
|
||||||
path: string | null;
|
path: string | null;
|
||||||
playCount: number;
|
playCount: number;
|
||||||
|
playlistItemId?: string;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
releaseYear: string | null;
|
releaseYear: string | null;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
serverType: ServerType;
|
||||||
size: number;
|
size: number;
|
||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
trackNumber: number;
|
trackNumber: number;
|
||||||
type: ServerType;
|
|
||||||
uniqueId: string;
|
uniqueId: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
userFavorite: boolean;
|
||||||
|
userRating: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlbumArtist = {
|
export type AlbumArtist = {
|
||||||
@@ -201,12 +209,16 @@ export type AlbumArtist = {
|
|||||||
genres: Genre[];
|
genres: Genre[];
|
||||||
id: string;
|
id: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
isFavorite: boolean;
|
itemType: LibraryItem.ALBUM_ARTIST;
|
||||||
lastPlayedAt: string | null;
|
lastPlayedAt: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
playCount: number | null;
|
playCount: number | null;
|
||||||
rating: number | null;
|
serverId: string;
|
||||||
|
serverType: ServerType;
|
||||||
|
similarArtists: RelatedArtist[] | null;
|
||||||
songCount: number | null;
|
songCount: number | null;
|
||||||
|
userFavorite: boolean;
|
||||||
|
userRating: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RelatedAlbumArtist = {
|
export type RelatedAlbumArtist = {
|
||||||
@@ -218,14 +230,18 @@ export type Artist = {
|
|||||||
biography: string | null;
|
biography: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
itemType: LibraryItem.ARTIST;
|
||||||
name: string;
|
name: string;
|
||||||
remoteCreatedAt: string | null;
|
remoteCreatedAt: string | null;
|
||||||
serverFolderId: string;
|
serverFolderId: string;
|
||||||
|
serverId: string;
|
||||||
|
serverType: ServerType;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RelatedArtist = {
|
export type RelatedArtist = {
|
||||||
id: string;
|
id: string;
|
||||||
|
imageUrl: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,13 +257,17 @@ export type Playlist = {
|
|||||||
id: string;
|
id: string;
|
||||||
imagePlaceholderUrl: string | null;
|
imagePlaceholderUrl: string | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
itemType: LibraryItem.PLAYLIST;
|
||||||
name: string;
|
name: string;
|
||||||
|
owner: string | null;
|
||||||
|
ownerId: string | null;
|
||||||
public: boolean | null;
|
public: boolean | null;
|
||||||
rules?: Record<string, any> | null;
|
rules?: Record<string, any> | null;
|
||||||
|
serverId: string;
|
||||||
|
serverType: ServerType;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
songCount: number | null;
|
songCount: number | null;
|
||||||
userId: string | null;
|
sync?: boolean | null;
|
||||||
username: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GenresResponse = Genre[];
|
export type GenresResponse = Genre[];
|
||||||
@@ -257,13 +277,13 @@ export type MusicFoldersResponse = MusicFolder[];
|
|||||||
export type ListSortOrder = NDOrder | JFSortOrder;
|
export type ListSortOrder = NDOrder | JFSortOrder;
|
||||||
|
|
||||||
type BaseEndpointArgs = {
|
type BaseEndpointArgs = {
|
||||||
server: ServerListItem | null;
|
apiClientProps: {
|
||||||
signal?: AbortSignal;
|
server: ServerListItem | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Genre List
|
// Genre List
|
||||||
export type RawGenreListResponse = NDGenreList | JFGenreList | SSGenreList | undefined;
|
|
||||||
|
|
||||||
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
|
export type GenreListResponse = BasePaginatedResponse<Genre[]> | null | undefined;
|
||||||
|
|
||||||
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
||||||
@@ -271,8 +291,6 @@ export type GenreListArgs = { query: GenreListQuery } & BaseEndpointArgs;
|
|||||||
export type GenreListQuery = null;
|
export type GenreListQuery = null;
|
||||||
|
|
||||||
// Album List
|
// Album List
|
||||||
export type RawAlbumListResponse = NDAlbumList | SSAlbumList | JFAlbumList | undefined;
|
|
||||||
|
|
||||||
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
|
export type AlbumListResponse = BasePaginatedResponse<Album[]> | null | undefined;
|
||||||
|
|
||||||
export enum AlbumListSort {
|
export enum AlbumListSort {
|
||||||
@@ -294,29 +312,33 @@ export enum AlbumListSort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AlbumListQuery = {
|
export type AlbumListQuery = {
|
||||||
jfParams?: {
|
_custom?: {
|
||||||
albumArtistIds?: string;
|
jellyfin?: {
|
||||||
artistIds?: string;
|
albumArtistIds?: string;
|
||||||
filters?: string;
|
artistIds?: string;
|
||||||
genreIds?: string;
|
contributingArtistIds?: string;
|
||||||
genres?: string;
|
filters?: string;
|
||||||
isFavorite?: boolean;
|
genreIds?: string;
|
||||||
maxYear?: number; // Parses to years
|
genres?: string;
|
||||||
minYear?: number; // Parses to years
|
isFavorite?: boolean;
|
||||||
tags?: string;
|
maxYear?: number; // Parses to years
|
||||||
|
minYear?: number; // Parses to years
|
||||||
|
tags?: string;
|
||||||
|
};
|
||||||
|
navidrome?: {
|
||||||
|
artist_id?: string;
|
||||||
|
compilation?: boolean;
|
||||||
|
genre_id?: string;
|
||||||
|
has_rating?: boolean;
|
||||||
|
name?: string;
|
||||||
|
recently_played?: boolean;
|
||||||
|
starred?: boolean;
|
||||||
|
year?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
artistIds?: string[];
|
||||||
limit?: number;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
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;
|
searchTerm?: string;
|
||||||
sortBy: AlbumListSort;
|
sortBy: AlbumListSort;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
@@ -386,8 +408,6 @@ export const albumListSortMap: AlbumListSortMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Album Detail
|
// Album Detail
|
||||||
export type RawAlbumDetailResponse = NDAlbumDetail | SSAlbumDetail | JFAlbumDetail | undefined;
|
|
||||||
|
|
||||||
export type AlbumDetailResponse = Album | null | undefined;
|
export type AlbumDetailResponse = Album | null | undefined;
|
||||||
|
|
||||||
export type AlbumDetailQuery = { id: string };
|
export type AlbumDetailQuery = { id: string };
|
||||||
@@ -395,8 +415,6 @@ export type AlbumDetailQuery = { id: string };
|
|||||||
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
|
export type AlbumDetailArgs = { query: AlbumDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Song List
|
// Song List
|
||||||
export type RawSongListResponse = NDSongList | JFSongList | undefined;
|
|
||||||
|
|
||||||
export type SongListResponse = BasePaginatedResponse<Song[]>;
|
export type SongListResponse = BasePaginatedResponse<Song[]>;
|
||||||
|
|
||||||
export enum SongListSort {
|
export enum SongListSort {
|
||||||
@@ -421,31 +439,35 @@ export enum SongListSort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SongListQuery = {
|
export type SongListQuery = {
|
||||||
|
_custom?: {
|
||||||
|
jellyfin?: {
|
||||||
|
artistIds?: string;
|
||||||
|
contributingArtistIds?: string;
|
||||||
|
filters?: string;
|
||||||
|
genreIds?: string;
|
||||||
|
genres?: string;
|
||||||
|
includeItemTypes: 'Audio';
|
||||||
|
isFavorite?: boolean;
|
||||||
|
maxYear?: number; // Parses to years
|
||||||
|
minYear?: number; // Parses to years
|
||||||
|
sortBy?: JFSongListSort;
|
||||||
|
years?: string;
|
||||||
|
};
|
||||||
|
navidrome?: {
|
||||||
|
album_id?: string[];
|
||||||
|
artist_id?: string[];
|
||||||
|
compilation?: boolean;
|
||||||
|
genre_id?: string;
|
||||||
|
has_rating?: boolean;
|
||||||
|
starred?: boolean;
|
||||||
|
title?: string;
|
||||||
|
year?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
albumIds?: string[];
|
albumIds?: string[];
|
||||||
artistIds?: string[];
|
artistIds?: string[];
|
||||||
jfParams?: {
|
|
||||||
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;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
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;
|
searchTerm?: string;
|
||||||
sortBy: SongListSort;
|
sortBy: SongListSort;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
@@ -496,7 +518,7 @@ export const songListSortMap: SongListSortMap = {
|
|||||||
playCount: NDSongListSort.PLAY_COUNT,
|
playCount: NDSongListSort.PLAY_COUNT,
|
||||||
random: undefined,
|
random: undefined,
|
||||||
rating: NDSongListSort.RATING,
|
rating: NDSongListSort.RATING,
|
||||||
recentlyAdded: NDSongListSort.PLAY_DATE,
|
recentlyAdded: NDSongListSort.RECENTLY_ADDED,
|
||||||
recentlyPlayed: NDSongListSort.PLAY_DATE,
|
recentlyPlayed: NDSongListSort.PLAY_DATE,
|
||||||
releaseDate: undefined,
|
releaseDate: undefined,
|
||||||
year: NDSongListSort.YEAR,
|
year: NDSongListSort.YEAR,
|
||||||
@@ -524,8 +546,6 @@ export const songListSortMap: SongListSortMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Song Detail
|
// Song Detail
|
||||||
export type RawSongDetailResponse = NDSongDetail | undefined;
|
|
||||||
|
|
||||||
export type SongDetailResponse = Song | null | undefined;
|
export type SongDetailResponse = Song | null | undefined;
|
||||||
|
|
||||||
export type SongDetailQuery = { id: string };
|
export type SongDetailQuery = { id: string };
|
||||||
@@ -533,13 +553,7 @@ export type SongDetailQuery = { id: string };
|
|||||||
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
|
export type SongDetailArgs = { query: SongDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Album Artist List
|
// Album Artist List
|
||||||
export type RawAlbumArtistListResponse =
|
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]> | null;
|
||||||
| NDAlbumArtistList
|
|
||||||
| SSAlbumArtistList
|
|
||||||
| JFAlbumArtistList
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
export type AlbumArtistListResponse = BasePaginatedResponse<AlbumArtist[]>;
|
|
||||||
|
|
||||||
export enum AlbumArtistListSort {
|
export enum AlbumArtistListSort {
|
||||||
ALBUM = 'album',
|
ALBUM = 'album',
|
||||||
@@ -556,13 +570,15 @@ export enum AlbumArtistListSort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AlbumArtistListQuery = {
|
export type AlbumArtistListQuery = {
|
||||||
|
_custom?: {
|
||||||
|
navidrome?: {
|
||||||
|
genre_id?: string;
|
||||||
|
name?: string;
|
||||||
|
starred?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
limit?: number;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
musicFolderId?: string;
|
||||||
ndParams?: {
|
|
||||||
genre_id?: string;
|
|
||||||
name?: string;
|
|
||||||
starred?: boolean;
|
|
||||||
};
|
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
sortBy: AlbumArtistListSort;
|
sortBy: AlbumArtistListSort;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
@@ -620,21 +636,14 @@ export const albumArtistListSortMap: AlbumArtistListSortMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Album Artist Detail
|
// 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 AlbumArtistDetailQuery = { id: string };
|
||||||
|
|
||||||
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
|
export type AlbumArtistDetailArgs = { query: AlbumArtistDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Artist List
|
// Artist List
|
||||||
export type RawArtistListResponse = JFArtistList | undefined;
|
|
||||||
|
|
||||||
export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
|
export type ArtistListResponse = BasePaginatedResponse<Artist[]>;
|
||||||
|
|
||||||
export enum ArtistListSort {
|
export enum ArtistListSort {
|
||||||
@@ -652,13 +661,15 @@ export enum ArtistListSort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ArtistListQuery = {
|
export type ArtistListQuery = {
|
||||||
|
_custom?: {
|
||||||
|
navidrome?: {
|
||||||
|
genre_id?: string;
|
||||||
|
name?: string;
|
||||||
|
starred?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
limit?: number;
|
limit?: number;
|
||||||
musicFolderId?: string;
|
musicFolderId?: string;
|
||||||
ndParams?: {
|
|
||||||
genre_id?: string;
|
|
||||||
name?: string;
|
|
||||||
starred?: boolean;
|
|
||||||
};
|
|
||||||
sortBy: ArtistListSort;
|
sortBy: ArtistListSort;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
@@ -717,71 +728,113 @@ export const artistListSortMap: ArtistListSortMap = {
|
|||||||
// Artist Detail
|
// Artist Detail
|
||||||
|
|
||||||
// Favorite
|
// Favorite
|
||||||
export type RawFavoriteResponse = FavoriteResponse | undefined;
|
export type FavoriteResponse = null | undefined;
|
||||||
|
|
||||||
export type FavoriteResponse = { id: string };
|
export type FavoriteQuery = {
|
||||||
|
id: string[];
|
||||||
export type FavoriteQuery = { id: string; type?: 'song' | 'album' | 'albumArtist' };
|
type: LibraryItem;
|
||||||
|
|
||||||
export type FavoriteArgs = { query: FavoriteQuery } & BaseEndpointArgs;
|
|
||||||
|
|
||||||
// Rating
|
|
||||||
export type RawRatingResponse = null | undefined;
|
|
||||||
|
|
||||||
export type RatingResponse = null;
|
|
||||||
|
|
||||||
export type RatingQuery = { id: string; rating: number };
|
|
||||||
|
|
||||||
export type RatingArgs = { query: RatingQuery } & BaseEndpointArgs;
|
|
||||||
|
|
||||||
// Create Playlist
|
|
||||||
export type RawCreatePlaylistResponse = CreatePlaylistResponse | undefined;
|
|
||||||
|
|
||||||
export type CreatePlaylistResponse = { id: string; name: string };
|
|
||||||
|
|
||||||
export type CreatePlaylistQuery = {
|
|
||||||
comment?: string;
|
|
||||||
name: string;
|
|
||||||
public?: boolean;
|
|
||||||
rules?: Record<string, any>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreatePlaylistArgs = { query: CreatePlaylistQuery } & BaseEndpointArgs;
|
export type FavoriteArgs = { query: FavoriteQuery; serverId?: string } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Rating
|
||||||
|
export type RatingResponse = null | undefined;
|
||||||
|
|
||||||
|
export type RatingQuery = {
|
||||||
|
item: AnyLibraryItems;
|
||||||
|
rating: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetRatingArgs = { query: RatingQuery; serverId?: string } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Add to playlist
|
||||||
|
export type AddToPlaylistResponse = null | undefined;
|
||||||
|
|
||||||
|
export type AddToPlaylistQuery = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddToPlaylistBody = {
|
||||||
|
songId: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddToPlaylistArgs = {
|
||||||
|
body: AddToPlaylistBody;
|
||||||
|
query: AddToPlaylistQuery;
|
||||||
|
serverId?: string;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Remove from playlist
|
||||||
|
export type RemoveFromPlaylistResponse = null | undefined;
|
||||||
|
|
||||||
|
export type RemoveFromPlaylistQuery = {
|
||||||
|
id: string;
|
||||||
|
songId: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveFromPlaylistArgs = {
|
||||||
|
query: RemoveFromPlaylistQuery;
|
||||||
|
serverId?: string;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Create Playlist
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePlaylistArgs = { body: CreatePlaylistBody; serverId?: string } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Update Playlist
|
// Update Playlist
|
||||||
export type RawUpdatePlaylistResponse = UpdatePlaylistResponse | undefined;
|
export type UpdatePlaylistResponse = null | undefined;
|
||||||
|
|
||||||
export type UpdatePlaylistResponse = { id: string };
|
|
||||||
|
|
||||||
export type UpdatePlaylistQuery = {
|
export type UpdatePlaylistQuery = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdatePlaylistBody = {
|
export type UpdatePlaylistBody = {
|
||||||
|
_custom?: {
|
||||||
|
navidrome?: {
|
||||||
|
owner?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
public?: boolean;
|
||||||
|
rules?: Record<string, any>;
|
||||||
|
sync?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
comment?: string;
|
comment?: string;
|
||||||
genres?: Genre[];
|
genres?: Genre[];
|
||||||
name: string;
|
name: string;
|
||||||
public?: boolean;
|
|
||||||
rules?: Record<string, any>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdatePlaylistArgs = {
|
export type UpdatePlaylistArgs = {
|
||||||
body: UpdatePlaylistBody;
|
body: UpdatePlaylistBody;
|
||||||
query: UpdatePlaylistQuery;
|
query: UpdatePlaylistQuery;
|
||||||
|
serverId?: string;
|
||||||
} & BaseEndpointArgs;
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
// Delete Playlist
|
// Delete Playlist
|
||||||
export type RawDeletePlaylistResponse = NDDeletePlaylist | undefined;
|
export type DeletePlaylistResponse = null | undefined;
|
||||||
|
|
||||||
export type DeletePlaylistResponse = null;
|
|
||||||
|
|
||||||
export type DeletePlaylistQuery = { id: string };
|
export type DeletePlaylistQuery = { id: string };
|
||||||
|
|
||||||
export type DeletePlaylistArgs = { query: DeletePlaylistQuery } & BaseEndpointArgs;
|
export type DeletePlaylistArgs = {
|
||||||
|
query: DeletePlaylistQuery;
|
||||||
|
serverId?: string;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
// Playlist List
|
// Playlist List
|
||||||
export type RawPlaylistListResponse = NDPlaylistList | JFPlaylistList | undefined;
|
|
||||||
|
|
||||||
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
|
||||||
|
|
||||||
export enum PlaylistListSort {
|
export enum PlaylistListSort {
|
||||||
@@ -794,10 +847,13 @@ export enum PlaylistListSort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PlaylistListQuery = {
|
export type PlaylistListQuery = {
|
||||||
limit?: number;
|
_custom?: {
|
||||||
ndParams?: {
|
navidrome?: {
|
||||||
owner_id?: string;
|
owner_id?: string;
|
||||||
|
smart?: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
limit?: number;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
sortBy: PlaylistListSort;
|
sortBy: PlaylistListSort;
|
||||||
sortOrder: SortOrder;
|
sortOrder: SortOrder;
|
||||||
@@ -840,9 +896,7 @@ export const playlistListSortMap: PlaylistListSortMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Playlist Detail
|
// Playlist Detail
|
||||||
export type RawPlaylistDetailResponse = NDPlaylistDetail | JFPlaylistDetail | undefined;
|
export type PlaylistDetailResponse = Playlist;
|
||||||
|
|
||||||
export type PlaylistDetailResponse = BasePaginatedResponse<Playlist[]>;
|
|
||||||
|
|
||||||
export type PlaylistDetailQuery = {
|
export type PlaylistDetailQuery = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -851,8 +905,6 @@ export type PlaylistDetailQuery = {
|
|||||||
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
|
export type PlaylistDetailArgs = { query: PlaylistDetailQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Playlist Songs
|
// Playlist Songs
|
||||||
export type RawPlaylistSongListResponse = JFSongList | undefined;
|
|
||||||
|
|
||||||
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
|
export type PlaylistSongListResponse = BasePaginatedResponse<Song[]>;
|
||||||
|
|
||||||
export type PlaylistSongListQuery = {
|
export type PlaylistSongListQuery = {
|
||||||
@@ -866,17 +918,84 @@ export type PlaylistSongListQuery = {
|
|||||||
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
|
export type PlaylistSongListArgs = { query: PlaylistSongListQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
// Music Folder List
|
// 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;
|
export type MusicFolderListArgs = BaseEndpointArgs;
|
||||||
|
|
||||||
// Create Favorite
|
// User list
|
||||||
export type RawCreateFavoriteResponse = CreateFavoriteResponse | undefined;
|
// Playlist List
|
||||||
|
export type UserListResponse = BasePaginatedResponse<User[]>;
|
||||||
|
|
||||||
export type CreateFavoriteResponse = { id: string };
|
export enum UserListSort {
|
||||||
|
NAME = 'name',
|
||||||
|
}
|
||||||
|
|
||||||
export type CreateFavoriteQuery = { comment?: string; name: string; public?: boolean };
|
export type UserListQuery = {
|
||||||
|
_custom?: {
|
||||||
|
navidrome?: {
|
||||||
|
owner_id?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
limit?: number;
|
||||||
|
searchTerm?: string;
|
||||||
|
sortBy: UserListSort;
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
startIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateFavoriteArgs = { query: CreateFavoriteQuery } & BaseEndpointArgs;
|
export type UserListArgs = { query: UserListQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
type UserListSortMap = {
|
||||||
|
jellyfin: Record<UserListSort, undefined>;
|
||||||
|
navidrome: Record<UserListSort, NDUserListSort | undefined>;
|
||||||
|
subsonic: Record<UserListSort, undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userListSortMap: UserListSortMap = {
|
||||||
|
jellyfin: {
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
navidrome: {
|
||||||
|
name: NDUserListSort.NAME,
|
||||||
|
},
|
||||||
|
subsonic: {
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Top Songs List
|
||||||
|
export type TopSongListResponse = BasePaginatedResponse<Song[]>;
|
||||||
|
|
||||||
|
export type TopSongListQuery = {
|
||||||
|
artist: string;
|
||||||
|
artistId: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopSongListArgs = { query: TopSongListQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Artist Info
|
||||||
|
export type ArtistInfoQuery = {
|
||||||
|
artistId: string;
|
||||||
|
limit: number;
|
||||||
|
musicFolderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
|
||||||
|
|
||||||
|
// Scrobble
|
||||||
|
export type ScrobbleResponse = null | undefined;
|
||||||
|
|
||||||
|
export type ScrobbleArgs = {
|
||||||
|
query: ScrobbleQuery;
|
||||||
|
serverId?: string;
|
||||||
|
} & BaseEndpointArgs;
|
||||||
|
|
||||||
|
export type ScrobbleQuery = {
|
||||||
|
event?: 'pause' | 'unpause' | 'timeupdate' | 'start';
|
||||||
|
id: string;
|
||||||
|
position?: number;
|
||||||
|
submission: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { AxiosHeaders } from 'axios';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
||||||
|
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
||||||
|
return z.object({
|
||||||
|
data: itemSchema,
|
||||||
|
headers: z.instanceof(AxiosHeaders),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
|
||||||
|
itemSchema: ItemType,
|
||||||
|
) => {
|
||||||
|
return z.object({
|
||||||
|
'subsonic-response': z
|
||||||
|
.object({
|
||||||
|
status: z.string(),
|
||||||
|
version: z.string(),
|
||||||
|
})
|
||||||
|
.extend(itemSchema),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,7 +4,6 @@ import { ModuleRegistry } from '@ag-grid-community/core';
|
|||||||
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
import { InfiniteRowModelModule } from '@ag-grid-community/infinite-row-model';
|
||||||
import { MantineProvider } from '@mantine/core';
|
import { MantineProvider } from '@mantine/core';
|
||||||
import { ModalsProvider } from '@mantine/modals';
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
import { NotificationsProvider } from '@mantine/notifications';
|
|
||||||
import { initSimpleImg } from 'react-simple-img';
|
import { initSimpleImg } from 'react-simple-img';
|
||||||
import { BaseContextModal } from './components';
|
import { BaseContextModal } from './components';
|
||||||
import { useTheme } from './hooks';
|
import { useTheme } from './hooks';
|
||||||
@@ -15,11 +14,17 @@ import '@ag-grid-community/styles/ag-grid.css';
|
|||||||
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
import { ContextMenuProvider } from '/@/renderer/features/context-menu';
|
||||||
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
import { useHandlePlayQueueAdd } from '/@/renderer/features/player/hooks/use-handle-playqueue-add';
|
||||||
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
import { PlayQueueHandlerContext } from '/@/renderer/features/player';
|
||||||
|
import { AddToPlaylistContextModal } from '/@/renderer/features/playlists';
|
||||||
|
import isElectron from 'is-electron';
|
||||||
|
import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings';
|
||||||
|
import { usePlayerStore } from '/@/renderer/store';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]);
|
||||||
|
|
||||||
initSimpleImg({ threshold: 0.05 }, true);
|
initSimpleImg({ threshold: 0.05 }, true);
|
||||||
|
|
||||||
|
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
const contentFont = useSettingsStore((state) => state.general.fontContent);
|
||||||
@@ -31,20 +36,41 @@ export const App = () => {
|
|||||||
root.style.setProperty('--content-font-family', contentFont);
|
root.style.setProperty('--content-font-family', contentFont);
|
||||||
}, [contentFont]);
|
}, [contentFont]);
|
||||||
|
|
||||||
|
// Start the mpv instance on startup
|
||||||
|
useEffect(() => {
|
||||||
|
const extraParameters = useSettingsStore.getState().playback.mpvExtraParameters;
|
||||||
|
const properties = {
|
||||||
|
...getMpvProperties(useSettingsStore.getState().playback.mpvProperties),
|
||||||
|
volume: usePlayerStore.getState().volume,
|
||||||
|
};
|
||||||
|
|
||||||
|
mpvPlayer?.restart({
|
||||||
|
extraParameters,
|
||||||
|
properties,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
withGlobalStyles
|
withGlobalStyles
|
||||||
withNormalizeCSS
|
withNormalizeCSS
|
||||||
theme={{
|
theme={{
|
||||||
breakpoints: {
|
|
||||||
lg: 1200,
|
|
||||||
md: 1000,
|
|
||||||
sm: 800,
|
|
||||||
xl: 1400,
|
|
||||||
xs: 500,
|
|
||||||
},
|
|
||||||
colorScheme: theme as 'light' | 'dark',
|
colorScheme: theme as 'light' | 'dark',
|
||||||
components: { Modal: { styles: { body: { padding: '.5rem' } } } },
|
components: {
|
||||||
|
Modal: {
|
||||||
|
styles: {
|
||||||
|
body: { background: 'var(--modal-bg)', padding: '1rem !important' },
|
||||||
|
close: { marginRight: '0.5rem' },
|
||||||
|
content: { borderRadius: '10px' },
|
||||||
|
header: {
|
||||||
|
background: 'var(--modal-bg)',
|
||||||
|
borderBottom: '1px solid var(--generic-border-color)',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
},
|
||||||
|
title: { fontSize: 'medium', fontWeight: 'bold' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
defaultRadius: 'xs',
|
defaultRadius: 'xs',
|
||||||
dir: 'ltr',
|
dir: 'ltr',
|
||||||
focusRing: 'auto',
|
focusRing: 'auto',
|
||||||
@@ -60,53 +86,51 @@ export const App = () => {
|
|||||||
},
|
},
|
||||||
fontFamily: 'var(--content-font-family)',
|
fontFamily: 'var(--content-font-family)',
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
lg: 16,
|
lg: '1.1rem',
|
||||||
md: 14,
|
md: '1rem',
|
||||||
sm: 12,
|
sm: '0.9rem',
|
||||||
xl: 18,
|
xl: '1.5rem',
|
||||||
xs: 10,
|
xs: '0.8rem',
|
||||||
|
},
|
||||||
|
headings: {
|
||||||
|
fontFamily: 'var(--content-font-family)',
|
||||||
|
fontWeight: 700,
|
||||||
|
sizes: {
|
||||||
|
h1: '6rem',
|
||||||
|
h2: '4rem',
|
||||||
|
h3: '3rem',
|
||||||
|
h4: '1.5rem',
|
||||||
|
h5: '1.2rem',
|
||||||
|
h6: '1rem',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
headings: { fontFamily: 'var(--content-font-family)' },
|
|
||||||
other: {},
|
other: {},
|
||||||
spacing: {
|
spacing: {
|
||||||
lg: 12,
|
lg: '2rem',
|
||||||
md: 8,
|
md: '1rem',
|
||||||
sm: 4,
|
sm: '0.5rem',
|
||||||
xl: 16,
|
xl: '4rem',
|
||||||
xs: 2,
|
xs: '0rem',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<NotificationsProvider
|
<ModalsProvider
|
||||||
autoClose={1500}
|
modalProps={{
|
||||||
position="bottom-center"
|
centered: true,
|
||||||
style={{
|
transitionProps: {
|
||||||
marginBottom: '85px',
|
duration: 300,
|
||||||
opacity: '.8',
|
exitDuration: 300,
|
||||||
userSelect: 'none',
|
|
||||||
width: '300px',
|
|
||||||
}}
|
|
||||||
transitionDuration={200}
|
|
||||||
>
|
|
||||||
<ModalsProvider
|
|
||||||
modalProps={{
|
|
||||||
centered: true,
|
|
||||||
exitTransitionDuration: 300,
|
|
||||||
overflow: 'inside',
|
|
||||||
overlayBlur: 0,
|
|
||||||
overlayOpacity: 0.8,
|
|
||||||
transition: 'slide-down',
|
transition: 'slide-down',
|
||||||
transitionDuration: 300,
|
},
|
||||||
}}
|
}}
|
||||||
modals={{ base: BaseContextModal }}
|
modals={{ addToPlaylist: AddToPlaylistContextModal, base: BaseContextModal }}
|
||||||
>
|
>
|
||||||
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
<PlayQueueHandlerContext.Provider value={{ handlePlayQueueAdd }}>
|
||||||
<ContextMenuProvider>
|
<ContextMenuProvider>
|
||||||
<AppRouter />
|
<AppRouter />
|
||||||
</ContextMenuProvider>
|
</ContextMenuProvider>
|
||||||
</PlayQueueHandlerContext.Provider>
|
</PlayQueueHandlerContext.Provider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</NotificationsProvider>
|
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const AudioPlayer = forwardRef(
|
|||||||
const player1Ref = useRef<any>(null);
|
const player1Ref = useRef<any>(null);
|
||||||
const player2Ref = useRef<any>(null);
|
const player2Ref = useRef<any>(null);
|
||||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
const audioDeviceId = useSettingsStore((state) => state.player.audioDeviceId);
|
const audioDeviceId = useSettingsStore((state) => state.playback.audioDeviceId);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
get player1() {
|
get player1() {
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import styled from 'styled-components';
|
|||||||
export type BadgeProps = MantineBadgeProps;
|
export type BadgeProps = MantineBadgeProps;
|
||||||
|
|
||||||
const StyledBadge = styled(MantineBadge)<BadgeProps>`
|
const StyledBadge = styled(MantineBadge)<BadgeProps>`
|
||||||
|
border-radius: var(--badge-radius);
|
||||||
|
|
||||||
.mantine-Badge-root {
|
.mantine-Badge-root {
|
||||||
color: var(--badge-fg);
|
color: var(--badge-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mantine-Badge-inner {
|
.mantine-Badge-inner {
|
||||||
padding: 0 0.5rem;
|
|
||||||
color: var(--badge-fg);
|
color: var(--badge-fg);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -20,145 +20,36 @@ interface StyledButtonProps extends ButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
const StyledButton = styled(MantineButton)<StyledButtonProps>`
|
||||||
color: ${(props) => {
|
color: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||||
switch (props.variant) {
|
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||||
case 'default':
|
border: ${(props) => `var(--btn-${props.variant}-border)`};
|
||||||
return 'var(--btn-default-fg)';
|
border-radius: ${(props) => `var(--btn-${props.variant}-radius)`};
|
||||||
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;
|
transition: background 0.2s ease-in-out, color 0.2s ease-in-out;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
transition: fill 0.2s ease-in-out;
|
transition: fill 0.2s ease-in-out;
|
||||||
fill: ${(props) => {
|
fill: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
color: ${(props) => {
|
color: ${(props) => `var(--btn-${props.variant}-fg)`};
|
||||||
switch (props.variant) {
|
background: ${(props) => `var(--btn-${props.variant}-bg)`};
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:not([data-disabled])&:hover {
|
||||||
color: ${(props) => {
|
color: ${(props) => `var(--btn-${props.variant}-fg-hover) !important`};
|
||||||
switch (props.variant) {
|
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
fill: ${(props) => {
|
fill: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:not([data-disabled])&:focus-visible {
|
||||||
color: ${(props) => {
|
color: ${(props) => `var(--btn-${props.variant}-fg-hover)`};
|
||||||
switch (props.variant) {
|
background: ${(props) => `var(--btn-${props.variant}-bg-hover)`};
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import React, { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Center } from '@mantine/core';
|
import { Center } from '@mantine/core';
|
||||||
import { RiAlbumFill } from 'react-icons/ri';
|
import { RiAlbumFill } from 'react-icons/ri';
|
||||||
import { generatePath, useNavigate } from 'react-router';
|
import { generatePath, useNavigate } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { SimpleImg } from 'react-simple-img';
|
import { SimpleImg } from 'react-simple-img';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { Text } from '/@/renderer/components/text';
|
import type { CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
||||||
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
|
||||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||||
import { CardControls } from '/@/renderer/components/card/card-controls';
|
import { CardControls } from '/@/renderer/components/card/card-controls';
|
||||||
import { Album } from '/@/renderer/api/types';
|
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { CardRows } from '/@/renderer/components/card/card-rows';
|
||||||
|
|
||||||
const CardWrapper = styled.div<{
|
const CardWrapper = styled.div<{
|
||||||
link?: boolean;
|
link?: boolean;
|
||||||
@@ -103,7 +102,7 @@ const Row = styled.div<{ $secondary?: boolean }>`
|
|||||||
|
|
||||||
interface BaseGridCardProps {
|
interface BaseGridCardProps {
|
||||||
controls: {
|
controls: {
|
||||||
cardRows: CardRow<Album>[];
|
cardRows: CardRow<Album | Artist | AlbumArtist>[];
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
playButtonBehavior: Play;
|
playButtonBehavior: Play;
|
||||||
route: CardRoute;
|
route: CardRoute;
|
||||||
@@ -179,104 +178,10 @@ export const AlbumCard = ({
|
|||||||
</ControlsContainer>
|
</ControlsContainer>
|
||||||
</ImageSection>
|
</ImageSection>
|
||||||
<DetailSection>
|
<DetailSection>
|
||||||
{cardRows.map((row: CardRow<Album>, index: number) => {
|
<CardRows
|
||||||
if (row.arrayProperty && row.route) {
|
data={data}
|
||||||
return (
|
rows={cardRows}
|
||||||
<Row
|
/>
|
||||||
key={`row-${row.property}-${index}`}
|
|
||||||
$secondary={index > 0}
|
|
||||||
>
|
|
||||||
{data[row.property].map((item: any, itemIndex: number) => (
|
|
||||||
<React.Fragment key={`${data.id}-${item.id}`}>
|
|
||||||
{itemIndex > 0 && (
|
|
||||||
<Text
|
|
||||||
$noSelect
|
|
||||||
sx={{
|
|
||||||
display: 'inline-block',
|
|
||||||
padding: '0 2px 0 1px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
,
|
|
||||||
</Text>
|
|
||||||
)}{' '}
|
|
||||||
<Text
|
|
||||||
$link
|
|
||||||
$noSelect
|
|
||||||
$secondary={index > 0}
|
|
||||||
component={Link}
|
|
||||||
overflow="hidden"
|
|
||||||
size={index > 0 ? 'xs' : 'md'}
|
|
||||||
to={generatePath(
|
|
||||||
row.route!.route,
|
|
||||||
row.route!.slugs?.reduce((acc, slug) => {
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[slug.slugProperty]: data[slug.idProperty],
|
|
||||||
};
|
|
||||||
}, {}),
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{row.arrayProperty && item[row.arrayProperty]}
|
|
||||||
</Text>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.arrayProperty) {
|
|
||||||
return (
|
|
||||||
<Row key={`row-${row.property}`}>
|
|
||||||
{data[row.property].map((item: any) => (
|
|
||||||
<Text
|
|
||||||
key={`${data.id}-${item.id}`}
|
|
||||||
$noSelect
|
|
||||||
$secondary={index > 0}
|
|
||||||
overflow="hidden"
|
|
||||||
size={index > 0 ? 'xs' : 'md'}
|
|
||||||
>
|
|
||||||
{row.arrayProperty && item[row.arrayProperty]}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row key={`row-${row.property}`}>
|
|
||||||
{row.route ? (
|
|
||||||
<Text
|
|
||||||
$link
|
|
||||||
$noSelect
|
|
||||||
component={Link}
|
|
||||||
overflow="hidden"
|
|
||||||
to={generatePath(
|
|
||||||
row.route.route,
|
|
||||||
row.route.slugs?.reduce((acc, slug) => {
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[slug.slugProperty]: data[slug.idProperty],
|
|
||||||
};
|
|
||||||
}, {}),
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{data && data[row.property]}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
$noSelect
|
|
||||||
$secondary={index > 0}
|
|
||||||
overflow="hidden"
|
|
||||||
size={index > 0 ? 'xs' : 'md'}
|
|
||||||
>
|
|
||||||
{data && data[row.property]}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</StyledCard>
|
</StyledCard>
|
||||||
</CardWrapper>
|
</CardWrapper>
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import { Group } from '@mantine/core';
|
|||||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { _Button } from '/@/renderer/components/button';
|
import { _Button } from '/@/renderer/components/button';
|
||||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
import type { PlayQueueAddOptions } from '/@/renderer/types';
|
||||||
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
|
|
||||||
import { Play } 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 {
|
||||||
|
ALBUM_CONTEXT_MENU_ITEMS,
|
||||||
|
ARTIST_CONTEXT_MENU_ITEMS,
|
||||||
|
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
|
|
||||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||||
|
|
||||||
@@ -98,21 +103,6 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PLAY_TYPES = [
|
|
||||||
{
|
|
||||||
label: 'Play',
|
|
||||||
play: Play.NOW,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Add to queue (last)',
|
|
||||||
play: Play.LAST,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Add to queue (next)',
|
|
||||||
play: Play.NEXT,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CardControls = ({
|
export const CardControls = ({
|
||||||
itemData,
|
itemData,
|
||||||
itemType,
|
itemType,
|
||||||
@@ -122,7 +112,7 @@ export const CardControls = ({
|
|||||||
itemData: any;
|
itemData: any;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
}) => {
|
}) => {
|
||||||
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
|
|
||||||
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
const handlePlay = (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -136,6 +126,11 @@ export const CardControls = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = useHandleGeneralContextMenu(
|
||||||
|
itemType,
|
||||||
|
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCardControlsContainer>
|
<GridCardControlsContainer>
|
||||||
<BottomControls>
|
<BottomControls>
|
||||||
@@ -160,39 +155,21 @@ export const CardControls = ({
|
|||||||
)}
|
)}
|
||||||
</FavoriteWrapper>
|
</FavoriteWrapper>
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<DropdownMenu
|
<SecondaryButton
|
||||||
withinPortal
|
p={5}
|
||||||
position="bottom-start"
|
sx={{ svg: { fill: 'white !important' } }}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleContextMenu(e, [itemData]);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Target>
|
<RiMore2Fill
|
||||||
<SecondaryButton
|
color="white"
|
||||||
p={5}
|
size={20}
|
||||||
sx={{ svg: { fill: 'white !important' } }}
|
/>
|
||||||
variant="subtle"
|
</SecondaryButton>
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiMore2Fill
|
|
||||||
color="white"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
</SecondaryButton>
|
|
||||||
</DropdownMenu.Target>
|
|
||||||
<DropdownMenu.Dropdown>
|
|
||||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={`playtype-${type.play}`}
|
|
||||||
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
|
|
||||||
>
|
|
||||||
{type.label}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Dropdown>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Group>
|
</Group>
|
||||||
</BottomControls>
|
</BottomControls>
|
||||||
</GridCardControlsContainer>
|
</GridCardControlsContainer>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||||||
{itemIndex > 0 && (
|
{itemIndex > 0 && (
|
||||||
<Text
|
<Text
|
||||||
$noSelect
|
$noSelect
|
||||||
|
$secondary
|
||||||
sx={{
|
sx={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
padding: '0 2px 0 1px',
|
padding: '0 2px 0 1px',
|
||||||
@@ -59,7 +60,7 @@ export const CardRows = ({ data, rows }: CardRowsProps) => {
|
|||||||
row.route!.slugs?.reduce((acc, slug) => {
|
row.route!.slugs?.reduce((acc, slug) => {
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[slug.slugProperty]: data[slug.idProperty],
|
[slug.slugProperty]: data[row.property][itemIndex][slug.idProperty],
|
||||||
};
|
};
|
||||||
}, {}),
|
}, {}),
|
||||||
)}
|
)}
|
||||||
@@ -134,7 +135,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
|||||||
arrayProperty: 'name',
|
arrayProperty: 'name',
|
||||||
property: 'albumArtists',
|
property: 'albumArtists',
|
||||||
route: {
|
route: {
|
||||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -142,7 +143,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
|||||||
arrayProperty: 'name',
|
arrayProperty: 'name',
|
||||||
property: 'artists',
|
property: 'artists',
|
||||||
route: {
|
route: {
|
||||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -166,7 +167,7 @@ export const ALBUM_CARD_ROWS: { [key: string]: CardRow<Album> } = {
|
|||||||
property: 'playCount',
|
property: 'playCount',
|
||||||
},
|
},
|
||||||
rating: {
|
rating: {
|
||||||
property: 'rating',
|
property: 'userRating',
|
||||||
},
|
},
|
||||||
releaseDate: {
|
releaseDate: {
|
||||||
property: 'releaseDate',
|
property: 'releaseDate',
|
||||||
@@ -195,7 +196,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
|||||||
name: {
|
name: {
|
||||||
property: 'name',
|
property: 'name',
|
||||||
route: {
|
route: {
|
||||||
route: AppRoute.LIBRARY_ALBUMARTISTS_DETAIL,
|
route: AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL,
|
||||||
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
slugs: [{ idProperty: 'id', slugProperty: 'albumArtistId' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -203,7 +204,7 @@ export const ALBUMARTIST_CARD_ROWS: { [key: string]: CardRow<AlbumArtist> } = {
|
|||||||
property: 'playCount',
|
property: 'playCount',
|
||||||
},
|
},
|
||||||
rating: {
|
rating: {
|
||||||
property: 'rating',
|
property: 'userRating',
|
||||||
},
|
},
|
||||||
songCount: {
|
songCount: {
|
||||||
property: 'songCount',
|
property: 'songCount',
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { forwardRef, ReactNode, Ref } from 'react';
|
import { forwardRef, ReactNode, Ref } from 'react';
|
||||||
import { Portal } from '@mantine/core';
|
import { Box, Group, UnstyledButton, UnstyledButtonProps } from '@mantine/core';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, Variants } from 'framer-motion';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { _Button } from '/@/renderer/components/button';
|
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -19,40 +18,110 @@ const ContextMenuContainer = styled(motion.div)<Omit<ContextMenuProps, 'children
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
min-width: ${({ minWidth }) => minWidth}px;
|
min-width: ${({ minWidth }) => minWidth}px;
|
||||||
max-width: ${({ maxWidth }) => maxWidth}px;
|
max-width: ${({ maxWidth }) => maxWidth}px;
|
||||||
padding: 0.5rem;
|
|
||||||
background: var(--dropdown-menu-bg);
|
background: var(--dropdown-menu-bg);
|
||||||
border-radius: 5px;
|
border-radius: var(--dropdown-menu-border-radius);
|
||||||
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
box-shadow: 2px 2px 10px 2px rgba(0, 0, 0, 40%);
|
||||||
|
|
||||||
|
button:first-child {
|
||||||
|
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);
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ContextMenuButton = styled(_Button)`
|
export const StyledContextMenuButton = styled(UnstyledButton)`
|
||||||
padding: 0.5rem;
|
padding: var(--dropdown-menu-item-padding);
|
||||||
|
color: var(--dropdown-menu-fg);
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--content-font-family);
|
||||||
|
text-align: left;
|
||||||
background: var(--dropdown-menu-bg);
|
background: var(--dropdown-menu-bg);
|
||||||
|
border: none;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
& .mantine-Button-inner {
|
& .mantine-Button-inner {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--dropdown-menu-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const ContextMenuButton = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
rightIcon,
|
||||||
|
leftIcon,
|
||||||
|
...props
|
||||||
|
}: UnstyledButtonProps &
|
||||||
|
React.ComponentPropsWithoutRef<'button'> & {
|
||||||
|
leftIcon?: ReactNode;
|
||||||
|
rightIcon?: ReactNode;
|
||||||
|
},
|
||||||
|
ref: any,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<StyledContextMenuButton
|
||||||
|
{...props}
|
||||||
|
key={props.key}
|
||||||
|
ref={ref}
|
||||||
|
as="button"
|
||||||
|
disabled={props.disabled}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<Group position="apart">
|
||||||
|
<Group spacing="md">
|
||||||
|
<Box>{leftIcon}</Box>
|
||||||
|
<Box mr="2rem">{children}</Box>
|
||||||
|
</Group>
|
||||||
|
<Box>{rightIcon}</Box>
|
||||||
|
</Group>
|
||||||
|
</StyledContextMenuButton>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const variants: Variants = {
|
||||||
|
closed: {
|
||||||
|
opacity: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const ContextMenu = forwardRef(
|
export const ContextMenu = forwardRef(
|
||||||
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
({ yPos, xPos, minWidth, maxWidth, children }: ContextMenuProps, ref: Ref<HTMLDivElement>) => {
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<ContextMenuContainer
|
||||||
<ContextMenuContainer
|
ref={ref}
|
||||||
ref={ref}
|
animate="open"
|
||||||
maxWidth={maxWidth}
|
initial="closed"
|
||||||
minWidth={minWidth}
|
maxWidth={maxWidth}
|
||||||
xPos={xPos}
|
minWidth={minWidth}
|
||||||
yPos={yPos}
|
variants={variants}
|
||||||
>
|
xPos={xPos}
|
||||||
{children}
|
yPos={yPos}
|
||||||
</ContextMenuContainer>
|
>
|
||||||
</Portal>
|
{children}
|
||||||
|
</ContextMenuContainer>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const StyledDatePicker = styled(MantineDatePicker)<DatePickerProps>`
|
|||||||
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
|
export const DatePicker = ({ width, maxWidth, ...props }: DatePickerProps) => {
|
||||||
return (
|
return (
|
||||||
<StyledDatePicker
|
<StyledDatePicker
|
||||||
withinPortal
|
|
||||||
{...props}
|
{...props}
|
||||||
sx={{ maxWidth, width }}
|
sx={{ maxWidth, width }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import type {
|
|||||||
MenuDropdownProps as MantineMenuDropdownProps,
|
MenuDropdownProps as MantineMenuDropdownProps,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
|
import { Menu as MantineMenu, createPolymorphicComponent } from '@mantine/core';
|
||||||
import { RiArrowLeftSFill } from 'react-icons/ri';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
type MenuProps = MantineMenuProps;
|
type MenuProps = MantineMenuProps;
|
||||||
type MenuLabelProps = MantineMenuLabelProps;
|
type MenuLabelProps = MantineMenuLabelProps;
|
||||||
interface MenuItemProps extends MantineMenuItemProps {
|
interface MenuItemProps extends MantineMenuItemProps {
|
||||||
|
$danger?: boolean;
|
||||||
$isActive?: boolean;
|
$isActive?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -21,17 +21,32 @@ type MenuDropdownProps = MantineMenuDropdownProps;
|
|||||||
const StyledMenu = styled(MantineMenu)<MenuProps>``;
|
const StyledMenu = styled(MantineMenu)<MenuProps>``;
|
||||||
|
|
||||||
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
|
const StyledMenuLabel = styled(MantineMenu.Label)<MenuLabelProps>`
|
||||||
|
padding: 0.5rem;
|
||||||
font-family: var(--content-font-family);
|
font-family: var(--content-font-family);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
||||||
padding: 0.8rem;
|
position: relative;
|
||||||
font-size: 0.9em;
|
padding: var(--dropdown-menu-item-padding);
|
||||||
|
font-size: var(--dropdown-menu-item-font-size);
|
||||||
font-family: var(--content-font-family);
|
font-family: var(--content-font-family);
|
||||||
background-color: ${({ $isActive }) => {
|
|
||||||
if (!$isActive) return undefined;
|
${(props) =>
|
||||||
return 'var(--dropdown-menu-bg-hover)';
|
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 {
|
&:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@@ -41,44 +56,48 @@ const StyledMenuItem = styled(MantineMenu.Item)<MenuItemProps>`
|
|||||||
background-color: var(--dropdown-menu-bg-hover);
|
background-color: var(--dropdown-menu-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .mantine-Menu-itemIcon {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .mantine-Menu-itemLabel {
|
& .mantine-Menu-itemLabel {
|
||||||
color: var(--dropdown-menu-fg);
|
margin-right: 2rem;
|
||||||
font-weight: 500;
|
margin-left: 1rem;
|
||||||
font-size: 1em;
|
color: ${(props) => (props.$danger ? 'var(--danger-color)' : 'var(--dropdown-menu-fg)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
& .mantine-Menu-itemRightSection {
|
cursor: default;
|
||||||
display: flex;
|
|
||||||
margin-left: 2rem !important;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
const StyledMenuDropdown = styled(MantineMenu.Dropdown)`
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
background: var(--dropdown-menu-bg);
|
background: var(--dropdown-menu-bg);
|
||||||
border: var(--dropdown-menu-border);
|
border: var(--dropdown-menu-border);
|
||||||
border-radius: var(--dropdown-menu-border-radius);
|
border-radius: var(--dropdown-menu-border-radius);
|
||||||
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
filter: drop-shadow(0 0 5px rgb(0, 0, 0, 50%));
|
||||||
|
|
||||||
|
/* *:first-child {
|
||||||
|
border-top-left-radius: var(--dropdown-menu-border-radius);
|
||||||
|
border-top-right-radius: var(--dropdown-menu-border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
*: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)`
|
const StyledMenuDivider = styled(MantineMenu.Divider)`
|
||||||
margin: 0.3rem 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
export const DropdownMenu = ({ children, ...props }: MenuProps) => {
|
||||||
return (
|
return (
|
||||||
<StyledMenu
|
<StyledMenu
|
||||||
withinPortal
|
withinPortal
|
||||||
radius="sm"
|
|
||||||
styles={{
|
styles={{
|
||||||
dropdown: {
|
dropdown: {
|
||||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
transition="scale-y"
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -90,11 +109,12 @@ const MenuLabel = ({ children, ...props }: MenuLabelProps) => {
|
|||||||
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
|
return <StyledMenuLabel {...props}>{children}</StyledMenuLabel>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pMenuItem = ({ $isActive, children, ...props }: MenuItemProps) => {
|
const pMenuItem = ({ $isActive, $danger, children, ...props }: MenuItemProps) => {
|
||||||
return (
|
return (
|
||||||
<StyledMenuItem
|
<StyledMenuItem
|
||||||
|
$danger={$danger}
|
||||||
$isActive={$isActive}
|
$isActive={$isActive}
|
||||||
rightSection={$isActive && <RiArrowLeftSFill size={20} />}
|
// rightSection={$isActive && <RiArrowLeftSFill size={20} />}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ const Grid = styled.div`
|
|||||||
grid-auto-columns: 1fr;
|
grid-auto-columns: 1fr;
|
||||||
grid-template-areas: 'image info';
|
grid-template-areas: 'image info';
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
grid-template-columns: 225px 1fr;
|
grid-template-columns: 200px 1fr;
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -152,16 +151,27 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||||||
/>
|
/>
|
||||||
</ImageColumn>
|
</ImageColumn>
|
||||||
<InfoColumn>
|
<InfoColumn>
|
||||||
<Stack sx={{ width: '100%' }}>
|
<Stack
|
||||||
|
spacing="md"
|
||||||
|
sx={{ width: '100%' }}
|
||||||
|
>
|
||||||
<TitleWrapper>
|
<TitleWrapper>
|
||||||
<TextTitle fw="bold">{currentItem?.name}</TextTitle>
|
<TextTitle
|
||||||
|
lh="4rem"
|
||||||
|
lineClamp={2}
|
||||||
|
order={1}
|
||||||
|
sx={{ fontSize: '4rem' }}
|
||||||
|
weight={900}
|
||||||
|
>
|
||||||
|
{currentItem?.name}
|
||||||
|
</TextTitle>
|
||||||
</TitleWrapper>
|
</TitleWrapper>
|
||||||
<TitleWrapper>
|
<TitleWrapper>
|
||||||
{currentItem?.albumArtists.map((artist) => (
|
{currentItem?.albumArtists.map((artist) => (
|
||||||
<TextTitle
|
<TextTitle
|
||||||
key={`carousel-artist-${artist.id}`}
|
key={`carousel-artist-${artist.id}`}
|
||||||
fw="600"
|
order={2}
|
||||||
order={3}
|
weight={600}
|
||||||
>
|
>
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</TextTitle>
|
</TextTitle>
|
||||||
@@ -169,10 +179,15 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||||||
</TitleWrapper>
|
</TitleWrapper>
|
||||||
<Group>
|
<Group>
|
||||||
{currentItem?.genres?.map((genre) => (
|
{currentItem?.genres?.map((genre) => (
|
||||||
<Badge key={`carousel-genre-${genre.id}`}>{genre.name}</Badge>
|
<Badge
|
||||||
|
key={`carousel-genre-${genre.id}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{genre.name}
|
||||||
|
</Badge>
|
||||||
))}
|
))}
|
||||||
<Badge>{currentItem?.releaseYear}</Badge>
|
<Badge size="lg">{currentItem?.releaseYear}</Badge>
|
||||||
<Badge>{currentItem?.songCount} tracks</Badge>
|
<Badge size="lg">{currentItem?.songCount} tracks</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</InfoColumn>
|
</InfoColumn>
|
||||||
@@ -187,25 +202,25 @@ export const FeatureCarousel = ({ data }: FeatureCarouselProps) => {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<Group
|
<Group
|
||||||
spacing="xs"
|
spacing="xs"
|
||||||
sx={{ bottom: 0, position: 'absolute', right: 0, zIndex: 20 }}
|
sx={{ bottom: '1rem', position: 'absolute', right: 0, zIndex: 20 }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
disabled={itemIndex === 0}
|
disabled={itemIndex === 0}
|
||||||
px="lg"
|
|
||||||
radius={100}
|
radius={100}
|
||||||
variant="subtle"
|
size="md"
|
||||||
|
variant="default"
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
>
|
>
|
||||||
<RiArrowLeftSLine size={15} />
|
<RiArrowLeftSLine size="2rem" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={itemIndex === (data?.length || 1) - 1}
|
disabled={itemIndex === (data?.length || 1) - 1}
|
||||||
px="lg"
|
|
||||||
radius={100}
|
radius={100}
|
||||||
variant="subtle"
|
size="md"
|
||||||
|
variant="default"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
>
|
>
|
||||||
<RiArrowRightSLine size={15} />
|
<RiArrowRightSLine size="2rem" />
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ import { RiArrowLeftSLine, RiArrowRightSLine } from 'react-icons/ri';
|
|||||||
import { Button } from '/@/renderer/components/button';
|
import { Button } from '/@/renderer/components/button';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
import type { CardRow } from '/@/renderer/types';
|
import type { CardRow } from '/@/renderer/types';
|
||||||
import { LibraryItem, Play } from '/@/renderer/types';
|
import { Play } from '/@/renderer/types';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { AlbumCard } from '/@/renderer/components/card';
|
import { AlbumCard } from '/@/renderer/components/card';
|
||||||
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
import { usePlayQueueAdd } from '/@/renderer/features/player/hooks/use-playqueue-add';
|
||||||
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||||
|
|
||||||
interface GridCarouselProps {
|
interface GridCarouselProps {
|
||||||
cardRows: CardRow<any>[];
|
cardRows: CardRow<any>[];
|
||||||
children: React.ReactElement;
|
children: React.ReactElement;
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
data: any[] | undefined;
|
data: any[] | undefined;
|
||||||
|
itemType: LibraryItem;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
pagination?: {
|
pagination?: {
|
||||||
handleNextPage?: () => void;
|
handleNextPage?: () => void;
|
||||||
@@ -77,9 +80,11 @@ const variants: Variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Carousel = ({ data, cardRows }: any) => {
|
const Carousel = ({ data, cardRows }: any) => {
|
||||||
const { loading, pagination, gridHeight, imageSize, direction, uniqueId } =
|
const { loading, pagination, gridHeight, imageSize, direction, uniqueId, itemType } =
|
||||||
useContext(GridCarouselContext);
|
useContext(GridCarouselContext);
|
||||||
|
|
||||||
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
|
|
||||||
const handlePlayQueueAdd = usePlayQueueAdd();
|
const handlePlayQueueAdd = usePlayQueueAdd();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,9 +109,9 @@ const Carousel = ({ data, cardRows }: any) => {
|
|||||||
key={`card-${uniqueId}-${index}`}
|
key={`card-${uniqueId}-${index}`}
|
||||||
controls={{
|
controls={{
|
||||||
cardRows,
|
cardRows,
|
||||||
itemType: LibraryItem.ALBUM,
|
itemType: itemType || LibraryItem.ALBUM,
|
||||||
playButtonBehavior: Play.NOW,
|
playButtonBehavior: playButtonBehavior || Play.NOW,
|
||||||
route: {
|
route: cardRows[0]?.route || {
|
||||||
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
route: AppRoute.LIBRARY_ALBUMS_DETAIL,
|
||||||
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
slugs: [{ idProperty: 'id', slugProperty: 'albumId' }],
|
||||||
},
|
},
|
||||||
@@ -130,6 +135,7 @@ export const GridCarousel = ({
|
|||||||
children,
|
children,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
uniqueId,
|
uniqueId,
|
||||||
|
itemType,
|
||||||
}: GridCarouselProps) => {
|
}: GridCarouselProps) => {
|
||||||
const [direction, setDirection] = useState(0);
|
const [direction, setDirection] = useState(0);
|
||||||
|
|
||||||
@@ -147,12 +153,13 @@ export const GridCarousel = ({
|
|||||||
direction,
|
direction,
|
||||||
gridHeight,
|
gridHeight,
|
||||||
imageSize,
|
imageSize,
|
||||||
|
itemType,
|
||||||
loading,
|
loading,
|
||||||
pagination,
|
pagination,
|
||||||
setDirection,
|
setDirection,
|
||||||
uniqueId,
|
uniqueId,
|
||||||
}),
|
}),
|
||||||
[cardRows, data, direction, gridHeight, imageSize, loading, pagination, uniqueId],
|
[cardRows, data, direction, gridHeight, imageSize, itemType, loading, pagination, uniqueId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,6 +183,7 @@ interface TitleProps {
|
|||||||
|
|
||||||
const Title = ({ children }: TitleProps) => {
|
const Title = ({ children }: TitleProps) => {
|
||||||
const { pagination, setDirection } = useContext(GridCarouselContext);
|
const { pagination, setDirection } = useContext(GridCarouselContext);
|
||||||
|
const showPaginationButtons = pagination?.handleNextPage && pagination?.handlePreviousPage;
|
||||||
|
|
||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
setDirection(1);
|
setDirection(1);
|
||||||
@@ -190,23 +198,27 @@ const Title = ({ children }: TitleProps) => {
|
|||||||
return (
|
return (
|
||||||
<Group position="apart">
|
<Group position="apart">
|
||||||
{children}
|
{children}
|
||||||
<Group>
|
{showPaginationButtons && (
|
||||||
<Button
|
<Group spacing="sm">
|
||||||
compact
|
<Button
|
||||||
disabled={!pagination?.hasPreviousPage}
|
compact
|
||||||
variant="default"
|
disabled={!pagination?.hasPreviousPage}
|
||||||
onClick={handlePreviousPage}
|
size="md"
|
||||||
>
|
variant="default"
|
||||||
<RiArrowLeftSLine size={15} />
|
onClick={handlePreviousPage}
|
||||||
</Button>
|
>
|
||||||
<Button
|
<RiArrowLeftSLine size={15} />
|
||||||
compact
|
</Button>
|
||||||
variant="default"
|
<Button
|
||||||
onClick={handleNextPage}
|
compact
|
||||||
>
|
size="md"
|
||||||
<RiArrowRightSLine size={15} />
|
variant="default"
|
||||||
</Button>
|
onClick={handleNextPage}
|
||||||
</Group>
|
>
|
||||||
|
<RiArrowRightSLine size={15} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { HoverCard as MantineHoverCard, HoverCardProps } from '@mantine/core';
|
||||||
|
|
||||||
|
export const HoverCard = ({ children, ...props }: HoverCardProps) => {
|
||||||
|
return (
|
||||||
|
<MantineHoverCard
|
||||||
|
styles={{
|
||||||
|
dropdown: {
|
||||||
|
background: 'var(--dropdown-menu-bg)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--dropdown-menu-border-radius)',
|
||||||
|
boxShadow: '2px 2px 10px 2px rgba(0, 0, 0, 40%)',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MantineHoverCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
HoverCard.Target = MantineHoverCard.Target;
|
||||||
|
HoverCard.Dropdown = MantineHoverCard.Dropdown;
|
||||||
@@ -27,7 +27,9 @@ export * from './text';
|
|||||||
export * from './text-title';
|
export * from './text-title';
|
||||||
export * from './toast';
|
export * from './toast';
|
||||||
export * from './tooltip';
|
export * from './tooltip';
|
||||||
export * from './virtual-grid';
|
|
||||||
export * from './virtual-table';
|
|
||||||
export * from './motion';
|
export * from './motion';
|
||||||
export * from './context-menu';
|
export * from './context-menu';
|
||||||
|
export * from './query-builder';
|
||||||
|
export * from './rating';
|
||||||
|
export * from './hover-card';
|
||||||
|
export * from './option';
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ const StyledTextInput = styled(MantineTextInput)<TextInputProps>`
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& [data-disabled='true'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -130,6 +134,10 @@ const StyledNumberInput = styled(MantineNumberInput)<NumberInputProps>`
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& [data-disabled='true'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -159,6 +167,10 @@ const StyledPasswordInput = styled(MantinePasswordInput)<PasswordInputProps>`
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& [data-disabled='true'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -188,6 +200,10 @@ const StyledFileInput = styled(MantineFileInput)<FileInputProps>`
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& [data-disabled='true'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -217,6 +233,10 @@ const StyledJsonInput = styled(MantineJsonInput)<JsonInputProps>`
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& [data-disabled='true'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -242,6 +262,10 @@ const StyledTextarea = styled(MantineTextarea)<TextareaProps>`
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& [data-disabled='true'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
transition: width 0.3s ease-in-out;
|
transition: width 0.3s ease-in-out;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ export interface ModalProps extends Omit<MantineModalProps, 'onClose'> {
|
|||||||
export const Modal = ({ children, handlers, ...rest }: ModalProps) => {
|
export const Modal = ({ children, handlers, ...rest }: ModalProps) => {
|
||||||
return (
|
return (
|
||||||
<MantineModal
|
<MantineModal
|
||||||
overlayBlur={2}
|
|
||||||
overlayOpacity={0.2}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
onClose={handlers.close}
|
onClose={handlers.close}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 { Flex, FlexProps } from '@mantine/core';
|
||||||
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||||
import { useRef } from 'react';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
|
import { useShouldPadTitlebar, useTheme } from '/@/renderer/hooks';
|
||||||
|
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||||
|
import { Platform } from '/@/renderer/types';
|
||||||
|
|
||||||
const Container = styled(motion(Flex))<{
|
const Container = styled(motion(Flex))<{
|
||||||
height?: string;
|
height?: string;
|
||||||
@@ -11,18 +13,23 @@ const Container = styled(motion(Flex))<{
|
|||||||
position: ${(props) => props.position || 'relative'};
|
position: ${(props) => props.position || 'relative'};
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: ${(props) => props.height || '60px'};
|
height: ${(props) => props.height || '65px'};
|
||||||
|
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;
|
position: relative;
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-right: ${(props) => props.$padRight && '170px'};
|
margin-right: ${(props) => (props.$padRight ? '140px' : '1rem')};
|
||||||
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
user-select: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||||
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
pointer-events: ${(props) => (props.$isHidden ? 'none' : 'auto')};
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: ${(props) => props.$isDraggable && 'drag'};
|
||||||
|
|
||||||
button {
|
button {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
@@ -51,7 +58,7 @@ const BackgroundImageOverlay = styled.div<{ theme: 'light' | 'dark' }>`
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: ${(props) =>
|
background: ${(props) =>
|
||||||
props.theme === 'light'
|
props.theme === 'light'
|
||||||
? 'linear-gradient(rgba(0, 0, 0, 20%), rgba(0, 0, 0, 20%))'
|
? 'linear-gradient(rgba(255, 255, 255, 25%), rgba(255, 255, 255, 25%))'
|
||||||
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
|
: 'linear-gradient(rgba(0, 0, 0, 50%), rgba(0, 0, 0, 50%))'};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -65,6 +72,8 @@ export interface PageHeaderProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TitleWrapper = styled(motion.div)`
|
const TitleWrapper = styled(motion.div)`
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
`;
|
`;
|
||||||
@@ -85,6 +94,7 @@ export const PageHeader = ({
|
|||||||
}: PageHeaderProps) => {
|
}: PageHeaderProps) => {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const padRight = useShouldPadTitlebar();
|
const padRight = useShouldPadTitlebar();
|
||||||
|
const { windowBarStyle } = useWindowSettings();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,6 +105,7 @@ export const PageHeader = ({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Header
|
<Header
|
||||||
|
$isDraggable={windowBarStyle === Platform.WEB}
|
||||||
$isHidden={isHidden}
|
$isHidden={isHidden}
|
||||||
$padRight={padRight}
|
$padRight={padRight}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const Pagination = ({ $hideDividers, ...props }: PaginationProps) => {
|
|||||||
<StyledPagination
|
<StyledPagination
|
||||||
$hideDividers={$hideDividers}
|
$hideDividers={$hideDividers}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="md"
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const Popover = ({ children, ...props }: PopoverProps) => {
|
|||||||
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
filter: 'drop-shadow(0 0 5px rgb(0, 0, 0, 50%))',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
transition="scale-y"
|
transitionProps={{ transition: 'fade' }}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Group, Stack } from '@mantine/core';
|
||||||
|
import { Select } from '/@/renderer/components/select';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { RiAddFill, RiAddLine, RiDeleteBinFill, RiMore2Line, RiRestartLine } from 'react-icons/ri';
|
||||||
|
import { Button } from '/@/renderer/components/button';
|
||||||
|
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
||||||
|
import { QueryBuilderOption } from '/@/renderer/components/query-builder/query-builder-option';
|
||||||
|
import { QueryBuilderGroup, QueryBuilderRule } from '/@/renderer/types';
|
||||||
|
|
||||||
|
const FILTER_GROUP_OPTIONS_DATA = [
|
||||||
|
{
|
||||||
|
label: 'Match all',
|
||||||
|
value: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Match any',
|
||||||
|
value: 'any',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type AddArgs = {
|
||||||
|
groupIndex: number[];
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeleteArgs = {
|
||||||
|
groupIndex: number[];
|
||||||
|
level: number;
|
||||||
|
uniqueId: string;
|
||||||
|
};
|
||||||
|
interface QueryBuilderProps {
|
||||||
|
data: Record<string, any>;
|
||||||
|
filters: { label: string; type: string; value: string }[];
|
||||||
|
groupIndex: number[];
|
||||||
|
level: number;
|
||||||
|
onAddRule: (args: AddArgs) => void;
|
||||||
|
onAddRuleGroup: (args: AddArgs) => void;
|
||||||
|
onChangeField: (args: any) => void;
|
||||||
|
onChangeOperator: (args: any) => void;
|
||||||
|
onChangeType: (args: any) => void;
|
||||||
|
onChangeValue: (args: any) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
onDeleteRule: (args: DeleteArgs) => void;
|
||||||
|
onDeleteRuleGroup: (args: DeleteArgs) => void;
|
||||||
|
onResetFilters: () => void;
|
||||||
|
operators: {
|
||||||
|
boolean: { label: string; value: string }[];
|
||||||
|
date: { label: string; value: string }[];
|
||||||
|
number: { label: string; value: string }[];
|
||||||
|
string: { label: string; value: string }[];
|
||||||
|
};
|
||||||
|
uniqueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryBuilder = ({
|
||||||
|
data,
|
||||||
|
level,
|
||||||
|
onAddRule,
|
||||||
|
onDeleteRuleGroup,
|
||||||
|
onDeleteRule,
|
||||||
|
onAddRuleGroup,
|
||||||
|
onChangeType,
|
||||||
|
onChangeField,
|
||||||
|
operators,
|
||||||
|
onChangeOperator,
|
||||||
|
onChangeValue,
|
||||||
|
onClearFilters,
|
||||||
|
onResetFilters,
|
||||||
|
groupIndex,
|
||||||
|
uniqueId,
|
||||||
|
filters,
|
||||||
|
}: QueryBuilderProps) => {
|
||||||
|
const handleAddRule = () => {
|
||||||
|
onAddRule({ groupIndex, level });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRuleGroup = () => {
|
||||||
|
onAddRuleGroup({ groupIndex, level });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRuleGroup = () => {
|
||||||
|
onDeleteRuleGroup({ groupIndex, level, uniqueId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeType = (value: string | null) => {
|
||||||
|
onChangeType({ groupIndex, level, value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
ml={`${level * 10}px`}
|
||||||
|
spacing="sm"
|
||||||
|
>
|
||||||
|
<Group spacing="sm">
|
||||||
|
<Select
|
||||||
|
data={FILTER_GROUP_OPTIONS_DATA}
|
||||||
|
maxWidth={175}
|
||||||
|
size="sm"
|
||||||
|
value={data.type}
|
||||||
|
width="20%"
|
||||||
|
onChange={handleChangeType}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
px={5}
|
||||||
|
size="sm"
|
||||||
|
tooltip={{ label: 'Add rule' }}
|
||||||
|
variant="default"
|
||||||
|
onClick={handleAddRule}
|
||||||
|
>
|
||||||
|
<RiAddLine size={20} />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu position="bottom-start">
|
||||||
|
<DropdownMenu.Target>
|
||||||
|
<Button
|
||||||
|
p={0}
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<RiMore2Line size={20} />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Target>
|
||||||
|
<DropdownMenu.Dropdown>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
icon={<RiAddFill />}
|
||||||
|
onClick={handleAddRuleGroup}
|
||||||
|
>
|
||||||
|
Add rule group
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
|
{level > 0 && (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
icon={<RiDeleteBinFill />}
|
||||||
|
onClick={handleDeleteRuleGroup}
|
||||||
|
>
|
||||||
|
Remove rule group
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
)}
|
||||||
|
{level === 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenu.Divider />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
$danger
|
||||||
|
icon={<RiRestartLine color="var(--danger-color)" />}
|
||||||
|
onClick={onResetFilters}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
$danger
|
||||||
|
icon={<RiDeleteBinFill color="var(--danger-color)" />}
|
||||||
|
onClick={onClearFilters}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Dropdown>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Group>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{data?.rules?.map((rule: QueryBuilderRule) => (
|
||||||
|
<motion.div
|
||||||
|
key={rule.uniqueId}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -25 }}
|
||||||
|
initial={{ opacity: 0, x: -25 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<QueryBuilderOption
|
||||||
|
data={rule}
|
||||||
|
filters={filters}
|
||||||
|
groupIndex={groupIndex || []}
|
||||||
|
level={level}
|
||||||
|
noRemove={data?.rules?.length === 1}
|
||||||
|
operators={operators}
|
||||||
|
onChangeField={onChangeField}
|
||||||
|
onChangeOperator={onChangeOperator}
|
||||||
|
onChangeValue={onChangeValue}
|
||||||
|
onDeleteRule={onDeleteRule}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
{data?.group && (
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{data.group?.map((group: QueryBuilderGroup, index: number) => (
|
||||||
|
<motion.div
|
||||||
|
key={group.uniqueId}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -25 }}
|
||||||
|
initial={{ opacity: 0, x: -25 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
>
|
||||||
|
<QueryBuilder
|
||||||
|
data={group}
|
||||||
|
filters={filters}
|
||||||
|
groupIndex={[...(groupIndex || []), index]}
|
||||||
|
level={level + 1}
|
||||||
|
operators={operators}
|
||||||
|
uniqueId={group.uniqueId}
|
||||||
|
onAddRule={onAddRule}
|
||||||
|
onAddRuleGroup={onAddRuleGroup}
|
||||||
|
onChangeField={onChangeField}
|
||||||
|
onChangeOperator={onChangeOperator}
|
||||||
|
onChangeType={onChangeType}
|
||||||
|
onChangeValue={onChangeValue}
|
||||||
|
onClearFilters={onClearFilters}
|
||||||
|
onDeleteRule={onDeleteRule}
|
||||||
|
onDeleteRuleGroup={onDeleteRuleGroup}
|
||||||
|
onResetFilters={onResetFilters}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { Group } from '@mantine/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { RiSubtractLine } from 'react-icons/ri';
|
||||||
|
import { Button } from '/@/renderer/components/button';
|
||||||
|
import { NumberInput, TextInput } from '/@/renderer/components/input';
|
||||||
|
import { Select } from '/@/renderer/components/select';
|
||||||
|
import { QueryBuilderRule } from '/@/renderer/types';
|
||||||
|
|
||||||
|
type DeleteArgs = {
|
||||||
|
groupIndex: number[];
|
||||||
|
level: number;
|
||||||
|
uniqueId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QueryOptionProps {
|
||||||
|
data: QueryBuilderRule;
|
||||||
|
filters: { label: string; type: string; value: string }[];
|
||||||
|
groupIndex: number[];
|
||||||
|
level: number;
|
||||||
|
noRemove: boolean;
|
||||||
|
onChangeField: (args: any) => void;
|
||||||
|
onChangeOperator: (args: any) => void;
|
||||||
|
onChangeValue: (args: any) => void;
|
||||||
|
onDeleteRule: (args: DeleteArgs) => void;
|
||||||
|
operators: {
|
||||||
|
boolean: { label: string; value: string }[];
|
||||||
|
date: { label: string; value: string }[];
|
||||||
|
number: { label: string; value: string }[];
|
||||||
|
string: { label: string; value: string }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QueryValueInput = ({ onChange, type, ...props }: any) => {
|
||||||
|
const [numberRange, setNumberRange] = useState([0, 0]);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
onChange={onChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
size="sm"
|
||||||
|
onChange={onChange}
|
||||||
|
{...props}
|
||||||
|
defaultValue={props.defaultValue && Number(props.defaultValue)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
size="sm"
|
||||||
|
onChange={onChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'dateRange':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NumberInput
|
||||||
|
{...props}
|
||||||
|
defaultValue={props.defaultValue && Number(props.defaultValue?.[0])}
|
||||||
|
maxWidth={81}
|
||||||
|
width="10%"
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRange = [e || 0, numberRange[1]];
|
||||||
|
setNumberRange(newRange);
|
||||||
|
onChange(newRange);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
{...props}
|
||||||
|
defaultValue={props.defaultValue && Number(props.defaultValue?.[1])}
|
||||||
|
maxWidth={81}
|
||||||
|
width="10%"
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRange = [numberRange[0], e || 0];
|
||||||
|
setNumberRange(newRange);
|
||||||
|
onChange(newRange);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'boolean':
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
data={[
|
||||||
|
{ label: 'true', value: 'true' },
|
||||||
|
{ label: 'false', value: 'false' },
|
||||||
|
]}
|
||||||
|
onChange={onChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QueryBuilderOption = ({
|
||||||
|
data,
|
||||||
|
filters,
|
||||||
|
level,
|
||||||
|
onDeleteRule,
|
||||||
|
operators,
|
||||||
|
groupIndex,
|
||||||
|
noRemove,
|
||||||
|
onChangeField,
|
||||||
|
onChangeOperator,
|
||||||
|
onChangeValue,
|
||||||
|
}: QueryOptionProps) => {
|
||||||
|
const { field, operator, uniqueId, value } = data;
|
||||||
|
|
||||||
|
const handleDeleteRule = () => {
|
||||||
|
onDeleteRule({ groupIndex, level, uniqueId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeField = (e: any) => {
|
||||||
|
onChangeField({ groupIndex, level, uniqueId, value: e });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeOperator = (e: any) => {
|
||||||
|
onChangeOperator({ groupIndex, level, uniqueId, value: e });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeValue = (e: any) => {
|
||||||
|
const isDirectValue =
|
||||||
|
typeof e === 'string' ||
|
||||||
|
typeof e === 'number' ||
|
||||||
|
typeof e === 'undefined' ||
|
||||||
|
typeof e === null;
|
||||||
|
|
||||||
|
if (isDirectValue) {
|
||||||
|
return onChangeValue({
|
||||||
|
groupIndex,
|
||||||
|
level,
|
||||||
|
uniqueId,
|
||||||
|
value: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// const isDate = e instanceof Date;
|
||||||
|
|
||||||
|
// if (isDate) {
|
||||||
|
// return onChangeValue({
|
||||||
|
// groupIndex,
|
||||||
|
// level,
|
||||||
|
// uniqueId,
|
||||||
|
// value: dayjs(e).format('YYYY-MM-DD'),
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
const isArray = Array.isArray(e);
|
||||||
|
|
||||||
|
if (isArray) {
|
||||||
|
return onChangeValue({
|
||||||
|
groupIndex,
|
||||||
|
level,
|
||||||
|
uniqueId,
|
||||||
|
value: e,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return onChangeValue({
|
||||||
|
groupIndex,
|
||||||
|
level,
|
||||||
|
uniqueId,
|
||||||
|
value: e.currentTarget.value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldType = filters.find((f) => f.value === field)?.type;
|
||||||
|
const operatorsByFieldType = operators[fieldType as keyof typeof operators];
|
||||||
|
const ml = (level + 1) * 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
ml={ml}
|
||||||
|
spacing="sm"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
searchable
|
||||||
|
data={filters}
|
||||||
|
maxWidth={170}
|
||||||
|
size="sm"
|
||||||
|
value={field}
|
||||||
|
width="25%"
|
||||||
|
onChange={handleChangeField}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
searchable
|
||||||
|
data={operatorsByFieldType || []}
|
||||||
|
disabled={!field}
|
||||||
|
maxWidth={170}
|
||||||
|
size="sm"
|
||||||
|
value={operator}
|
||||||
|
width="25%"
|
||||||
|
onChange={handleChangeOperator}
|
||||||
|
/>
|
||||||
|
{field ? (
|
||||||
|
<QueryValueInput
|
||||||
|
defaultValue={value}
|
||||||
|
maxWidth={170}
|
||||||
|
size="sm"
|
||||||
|
type={operator === 'inTheRange' ? 'dateRange' : fieldType}
|
||||||
|
width="25%"
|
||||||
|
onChange={handleChangeValue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
disabled
|
||||||
|
defaultValue={value}
|
||||||
|
maxWidth={170}
|
||||||
|
size="sm"
|
||||||
|
width="25%"
|
||||||
|
onChange={handleChangeValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={noRemove}
|
||||||
|
px={5}
|
||||||
|
size="sm"
|
||||||
|
tooltip={{ label: 'Remove rule' }}
|
||||||
|
variant="default"
|
||||||
|
onClick={handleDeleteRule}
|
||||||
|
>
|
||||||
|
<RiSubtractLine size={20} />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||||
|
import { MouseEvent } from 'react';
|
||||||
|
import { Rating as MantineRating, RatingProps as MantineRatingProps } from '@mantine/core';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { Tooltip } from '/@/renderer/components/tooltip';
|
||||||
|
|
||||||
|
interface RatingProps extends Omit<MantineRatingProps, 'onClick'> {
|
||||||
|
onClick: (e: MouseEvent<HTMLDivElement>, value: number | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledRating = styled(MantineRating)`
|
||||||
|
& .mantine-Rating-symbolBody {
|
||||||
|
svg {
|
||||||
|
stroke: var(--main-fg-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const Rating = ({ onClick, ...props }: RatingProps) => {
|
||||||
|
// const debouncedOnClick = debounce(onClick, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
label="Double click to clear"
|
||||||
|
openDelay={1000}
|
||||||
|
>
|
||||||
|
<StyledRating
|
||||||
|
{...props}
|
||||||
|
onDoubleClick={(e) => onClick(e, props.value)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ import { useMergedRef, useTimeout } from '@mantine/hooks';
|
|||||||
import { motion, useScroll } from 'framer-motion';
|
import { motion, useScroll } from 'framer-motion';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
|
import { PageHeader, PageHeaderProps } from '/@/renderer/components/page-header';
|
||||||
|
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||||
|
import { Platform } from '/@/renderer/types';
|
||||||
|
|
||||||
interface ScrollAreaProps extends MantineScrollAreaProps {
|
interface ScrollAreaProps extends MantineScrollAreaProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -26,16 +28,18 @@ const StyledScrollArea = styled(MantineScrollArea)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledNativeScrollArea = styled.div`
|
const StyledNativeScrollArea = styled.div<{ scrollBarOffset?: string; windowBarStyle?: Platform }>`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: overlay;
|
overflow-y: overlay !important;
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
&::-webkit-scrollbar-track {
|
||||||
margin-top: 35px;
|
margin-top: ${(props) =>
|
||||||
|
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
margin-top: 35px;
|
margin-top: ${(props) =>
|
||||||
|
props.windowBarStyle !== Platform.WEB ? '0px' : props.scrollBarOffset || '65px'};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -54,17 +58,33 @@ export const ScrollArea = forwardRef(({ children, ...props }: ScrollAreaProps, r
|
|||||||
interface NativeScrollAreaProps {
|
interface NativeScrollAreaProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
debugScrollPosition?: boolean;
|
debugScrollPosition?: boolean;
|
||||||
|
noHeader?: boolean;
|
||||||
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
|
pageHeaderProps?: PageHeaderProps & { offset?: any; target?: any };
|
||||||
|
scrollBarOffset?: string;
|
||||||
|
scrollHideDelay?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NativeScrollArea = forwardRef(
|
export const NativeScrollArea = forwardRef(
|
||||||
(
|
(
|
||||||
{ children, pageHeaderProps, debugScrollPosition, ...props }: NativeScrollAreaProps,
|
{
|
||||||
|
children,
|
||||||
|
pageHeaderProps,
|
||||||
|
debugScrollPosition,
|
||||||
|
scrollBarOffset,
|
||||||
|
scrollHideDelay,
|
||||||
|
noHeader,
|
||||||
|
...props
|
||||||
|
}: NativeScrollAreaProps,
|
||||||
ref: Ref<HTMLDivElement>,
|
ref: Ref<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
|
const { windowBarStyle } = useWindowSettings();
|
||||||
const [hideScrollbar, setHideScrollbar] = useState(false);
|
const [hideScrollbar, setHideScrollbar] = useState(false);
|
||||||
const [hideHeader, setHideHeader] = useState(true);
|
const [hideHeader, setHideHeader] = useState(true);
|
||||||
const { start, clear } = useTimeout(() => setHideScrollbar(true), 1000);
|
const { start, clear } = useTimeout(
|
||||||
|
() => setHideScrollbar(true),
|
||||||
|
scrollHideDelay !== undefined ? scrollHideDelay * 1000 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const mergedRef = useMergedRef(ref, containerRef);
|
const mergedRef = useMergedRef(ref, containerRef);
|
||||||
@@ -103,15 +123,19 @@ export const NativeScrollArea = forwardRef(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
{!noHeader && (
|
||||||
isHidden={hideHeader}
|
<PageHeader
|
||||||
position="absolute"
|
isHidden={hideHeader}
|
||||||
style={{ opacity: scrollYProgress as any }}
|
position="absolute"
|
||||||
{...pageHeaderProps}
|
style={{ opacity: scrollYProgress as any }}
|
||||||
/>
|
{...pageHeaderProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<StyledNativeScrollArea
|
<StyledNativeScrollArea
|
||||||
ref={mergedRef}
|
ref={mergedRef}
|
||||||
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
className={hideScrollbar ? 'hide-scrollbar' : undefined}
|
||||||
|
scrollBarOffset={scrollBarOffset}
|
||||||
|
windowBarStyle={windowBarStyle}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setHideScrollbar(false);
|
setHideScrollbar(false);
|
||||||
clear();
|
clear();
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ export const SearchInput = ({
|
|||||||
<TextInput
|
<TextInput
|
||||||
ref={mergedRef}
|
ref={mergedRef}
|
||||||
{...props}
|
{...props}
|
||||||
icon={showIcon && <RiSearchLine size={15} />}
|
icon={showIcon && <RiSearchLine />}
|
||||||
|
size="md"
|
||||||
styles={{
|
styles={{
|
||||||
icon: { svg: { fill: 'var(--btn-default-fg)' } },
|
icon: { svg: { fill: 'var(--titlebar-fg)' } },
|
||||||
input: {
|
input: {
|
||||||
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
|
backgroundColor: isOpened ? 'inherit' : 'transparent !important',
|
||||||
border: 'none !important',
|
border: 'none !important',
|
||||||
@@ -53,7 +54,7 @@ export const SearchInput = ({
|
|||||||
padding: isOpened ? '10px' : 0,
|
padding: isOpened ? '10px' : 0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
width={isOpened ? openedWidth || 150 : initialWidth || 50}
|
width={isOpened ? openedWidth || 150 : initialWidth || 35}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={handleEscape}
|
onKeyDown={handleEscape}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const StyledSegmentedControl = styled(MantineSegmentedControl)<MantineSegmentedC
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& [data-disabled='true'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
& .mantine-SegmentedControl-active {
|
& .mantine-SegmentedControl-active {
|
||||||
color: var(--input-active-fg);
|
color: var(--input-active-fg);
|
||||||
background-color: var(--input-active-bg);
|
background-color: var(--input-active-bg);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const StyledSelect = styled(MantineSelect)`
|
|||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .mantine-Select-disabled {
|
& [data-disabled='true'] {
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
@@ -64,8 +64,7 @@ export const Select = ({ width, maxWidth, ...props }: SelectProps) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ maxWidth, width }}
|
sx={{ maxWidth, width }}
|
||||||
transition="pop"
|
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||||
transitionDuration={100}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -76,8 +75,8 @@ const StyledMultiSelect = styled(MantineMultiSelect)`
|
|||||||
background: var(--input-select-bg);
|
background: var(--input-select-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .mantine-MultiSelect-disabled {
|
& [data-disabled='true'] {
|
||||||
background: var(--input-select-bg);
|
background: var(--input-bg);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +125,7 @@ export const MultiSelect = ({ width, maxWidth, ...props }: MultiSelectProps) =>
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ maxWidth, width }}
|
sx={{ maxWidth, width }}
|
||||||
transition="pop"
|
transitionProps={{ duration: 100, transition: 'fade' }}
|
||||||
transitionDuration={100}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const StyledSlider = styled(MantineSlider)`
|
|||||||
background-color: var(--slider-track-bg);
|
background-color: var(--slider-track-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .mantine-Slider-bar {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
& .mantine-Slider-thumb {
|
& .mantine-Slider-thumb {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TabsProps as MantineTabsProps } from '@mantine/core';
|
import { Suspense } from 'react';
|
||||||
import { Tabs as MantineTabs } from '@mantine/core';
|
import { TabsPanelProps, TabsProps as MantineTabsProps, Tabs as MantineTabs } from '@mantine/core';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
type TabsProps = MantineTabsProps;
|
type TabsProps = MantineTabsProps;
|
||||||
@@ -12,16 +12,20 @@ const StyledTabs = styled(MantineTabs)`
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.mantine-Tabs-tab {
|
&.mantine-Tabs-tab {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
background-color: var(--main-bg);
|
background-color: var(--main-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .mantine-Tabs-panel {
|
& .mantine-Tabs-panel {
|
||||||
padding: 0 1rem;
|
padding: 1.5rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: var(--btn-subtle-fg);
|
color: var(--btn-subtle-fg);
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--btn-subtle-fg-hover);
|
color: var(--btn-subtle-fg-hover);
|
||||||
@@ -32,13 +36,12 @@ const StyledTabs = styled(MantineTabs)`
|
|||||||
}
|
}
|
||||||
|
|
||||||
button[data-active] {
|
button[data-active] {
|
||||||
color: var(--btn-primary-fg);
|
color: var(--btn-subtle-fg);
|
||||||
background: var(--primary-color);
|
background: none;
|
||||||
border-color: var(--primary-color);
|
border-color: var(--primary-color);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--btn-primary-bg-hover);
|
background: none;
|
||||||
border-color: var(--primary-color);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -47,6 +50,14 @@ export const Tabs = ({ children, ...props }: TabsProps) => {
|
|||||||
return <StyledTabs {...props}>{children}</StyledTabs>;
|
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.List = StyledTabs.List;
|
||||||
Tabs.Panel = StyledTabs.Panel;
|
Tabs.Panel = Panel;
|
||||||
Tabs.Tab = StyledTabs.Tab;
|
Tabs.Tab = StyledTabs.Tab;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface TextTitleProps extends MantineTextTitleDivProps {
|
|||||||
$link?: boolean;
|
$link?: boolean;
|
||||||
$noSelect?: boolean;
|
$noSelect?: boolean;
|
||||||
$secondary?: boolean;
|
$secondary?: boolean;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
overflow?: 'hidden' | 'visible';
|
overflow?: 'hidden' | 'visible';
|
||||||
to?: string;
|
to?: string;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface TextProps extends MantineTextDivProps {
|
|||||||
$link?: boolean;
|
$link?: boolean;
|
||||||
$noSelect?: boolean;
|
$noSelect?: boolean;
|
||||||
$secondary?: boolean;
|
$secondary?: boolean;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
font?: Font;
|
font?: Font;
|
||||||
overflow?: 'hidden' | 'visible';
|
overflow?: 'hidden' | 'visible';
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -33,7 +33,7 @@ const StyledText = styled(MantineText)<TextProps>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
|
export const _Text = ({ children, $secondary, overflow, font, $noSelect, ...rest }: TextProps) => {
|
||||||
return (
|
return (
|
||||||
<StyledText
|
<StyledText
|
||||||
$noSelect={$noSelect}
|
$noSelect={$noSelect}
|
||||||
|
|||||||
@@ -30,16 +30,19 @@ const showToast = ({ type, ...props }: NotificationProps) => {
|
|||||||
? 'Error'
|
? 'Error'
|
||||||
: 'Info';
|
: 'Info';
|
||||||
|
|
||||||
const defaultDuration = type === 'error' ? 3500 : 2000;
|
const defaultDuration = type === 'error' ? 4000 : 2000;
|
||||||
|
|
||||||
return showNotification({
|
return showNotification({
|
||||||
autoClose: defaultDuration,
|
autoClose: defaultDuration,
|
||||||
disallowClose: true,
|
|
||||||
styles: () => ({
|
styles: () => ({
|
||||||
closeButton: {},
|
closeButton: {
|
||||||
|
'&:hover': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
color: 'var(--toast-description-fg)',
|
color: 'var(--toast-description-fg)',
|
||||||
fontSize: '.9em',
|
fontSize: '1rem',
|
||||||
},
|
},
|
||||||
loader: {
|
loader: {
|
||||||
margin: '1rem',
|
margin: '1rem',
|
||||||
@@ -47,10 +50,12 @@ const showToast = ({ type, ...props }: NotificationProps) => {
|
|||||||
root: {
|
root: {
|
||||||
'&::before': { backgroundColor: color },
|
'&::before': { backgroundColor: color },
|
||||||
background: 'var(--toast-bg)',
|
background: 'var(--toast-bg)',
|
||||||
|
border: '2px solid var(--generic-border-color)',
|
||||||
|
bottom: '90px',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: 'var(--toast-title-fg)',
|
color: 'var(--toast-title-fg)',
|
||||||
fontSize: '1em',
|
fontSize: '1.3rem',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
title: defaultTitle,
|
title: defaultTitle,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
|
|||||||
maxWidth: '250px',
|
maxWidth: '250px',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
transitionProps={{
|
||||||
|
duration: 250,
|
||||||
|
transition: 'fade',
|
||||||
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -37,8 +41,6 @@ export const Tooltip = ({ children, ...rest }: TooltipProps) => {
|
|||||||
Tooltip.defaultProps = {
|
Tooltip.defaultProps = {
|
||||||
openDelay: 0,
|
openDelay: 0,
|
||||||
position: 'top',
|
position: 'top',
|
||||||
transition: 'fade',
|
|
||||||
transitionDuration: 250,
|
|
||||||
withArrow: true,
|
withArrow: true,
|
||||||
withinPortal: true,
|
withinPortal: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +1,58 @@
|
|||||||
import { Center } from '@mantine/core';
|
import { generatePath, useNavigate } from 'react-router-dom';
|
||||||
import { RiAlbumFill } from 'react-icons/ri';
|
import { ListChildComponentProps } from 'react-window';
|
||||||
import { generatePath, useNavigate } from 'react-router';
|
|
||||||
import { SimpleImg } from 'react-simple-img';
|
|
||||||
import type { ListChildComponentProps } from 'react-window';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||||
|
import { CardRows } from '/@/renderer/components/card';
|
||||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
import { Skeleton } from '/@/renderer/components/skeleton';
|
||||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
||||||
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
|
import { CardRow, PlayQueueAddOptions, Play, CardRoute } from '/@/renderer/types';
|
||||||
import { CardRows } from '/@/renderer/components/card';
|
|
||||||
|
|
||||||
const CardWrapper = styled.div<{
|
interface BaseGridCardProps {
|
||||||
itemGap: number;
|
columnIndex: number;
|
||||||
itemHeight: number;
|
controls: {
|
||||||
itemWidth: number;
|
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||||
link?: boolean;
|
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||||
}>`
|
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth - 12}px`};
|
itemType: LibraryItem;
|
||||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
playButtonBehavior: Play;
|
||||||
height: ${({ itemHeight, itemGap }) => `${itemHeight - 12 - itemGap}px`};
|
route: CardRoute;
|
||||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
};
|
||||||
padding: 12px 12px 0;
|
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);
|
background: var(--card-default-bg);
|
||||||
border-radius: var(--card-default-radius);
|
border-radius: var(--card-default-radius);
|
||||||
cursor: ${({ link }) => link && 'pointer'};
|
cursor: pointer;
|
||||||
transition: border 0.2s ease-in-out, background 0.2s ease-in-out;
|
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||||
user-select: none;
|
pointer-events: auto;
|
||||||
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--card-default-bg-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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,26 +61,16 @@ const CardWrapper = styled.div<{
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 1px solid #fff;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledCard = styled.div`
|
const ImageContainer = 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 }>`
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: ${({ size }) => size && `${size - 24}px`};
|
display: flex;
|
||||||
height: ${({ size }) => size && `${size - 24}px`};
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--placeholder-bg);
|
||||||
border-radius: var(--card-default-radius);
|
border-radius: var(--card-default-radius);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@@ -78,151 +88,80 @@ const ImageSection = styled.div<{ size?: number }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Image = styled(SimpleImg)`
|
const Image = styled.img`
|
||||||
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;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
opacity: 0;
|
max-width: 100%;
|
||||||
transition: all 0.2s ease-in-out;
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DetailSection = styled.div`
|
const DetailContainer = styled.div`
|
||||||
display: flex;
|
margin-top: 0.5rem;
|
||||||
flex-direction: column;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface BaseGridCardProps {
|
|
||||||
columnIndex: number;
|
|
||||||
controls: {
|
|
||||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
|
||||||
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 = ({
|
export const DefaultCard = ({
|
||||||
listChildProps,
|
listChildProps,
|
||||||
data,
|
data,
|
||||||
columnIndex,
|
columnIndex,
|
||||||
controls,
|
controls,
|
||||||
sizes,
|
isHidden,
|
||||||
}: BaseGridCardProps) => {
|
}: BaseGridCardProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { index } = listChildProps;
|
|
||||||
const { itemGap, itemHeight, itemWidth } = sizes;
|
|
||||||
const { itemType, cardRows, route, handlePlayQueueAdd } = controls;
|
|
||||||
|
|
||||||
const cardSize = itemWidth - 24;
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
|
const path = generatePath(
|
||||||
|
controls.route.route,
|
||||||
|
controls.route.slugs?.reduce((acc, slug) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[slug.slugProperty]: data[slug.idProperty],
|
||||||
|
};
|
||||||
|
}, {}),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardWrapper
|
<DefaultCardContainer
|
||||||
key={`card-${columnIndex}-${index}`}
|
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||||
link
|
onClick={() => navigate(path)}
|
||||||
itemGap={itemGap}
|
|
||||||
itemHeight={itemHeight}
|
|
||||||
itemWidth={itemWidth}
|
|
||||||
onClick={() =>
|
|
||||||
navigate(
|
|
||||||
generatePath(
|
|
||||||
route.route,
|
|
||||||
route.slugs?.reduce((acc, slug) => {
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[slug.slugProperty]: data[slug.idProperty],
|
|
||||||
};
|
|
||||||
}, {}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<StyledCard>
|
<InnerCardContainer>
|
||||||
<ImageSection size={itemWidth}>
|
<ImageContainer>
|
||||||
{data?.imageUrl ? (
|
<Image
|
||||||
<Image
|
placeholder={data?.imagePlaceholderUrl || 'var(--placeholder-bg)'}
|
||||||
animationDuration={0.3}
|
src={data?.imageUrl}
|
||||||
height={cardSize}
|
/>
|
||||||
imgStyle={{ objectFit: 'cover' }}
|
<GridCardControls
|
||||||
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
handleFavorite={controls.handleFavorite}
|
||||||
src={data?.imageUrl}
|
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||||
width={cardSize}
|
itemData={data}
|
||||||
/>
|
itemType={controls.itemType}
|
||||||
) : (
|
/>
|
||||||
<Center
|
</ImageContainer>
|
||||||
sx={{
|
<DetailContainer>
|
||||||
background: 'var(--placeholder-bg)',
|
|
||||||
borderRadius: 'var(--card-default-radius)',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiAlbumFill
|
|
||||||
color="var(--placeholder-fg)"
|
|
||||||
size={35}
|
|
||||||
/>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
<ControlsContainer>
|
|
||||||
<GridCardControls
|
|
||||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
|
||||||
itemData={data}
|
|
||||||
itemType={itemType}
|
|
||||||
/>
|
|
||||||
</ControlsContainer>
|
|
||||||
</ImageSection>
|
|
||||||
<DetailSection>
|
|
||||||
<CardRows
|
<CardRows
|
||||||
data={data}
|
data={data}
|
||||||
rows={cardRows}
|
rows={controls.cardRows}
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailContainer>
|
||||||
</StyledCard>
|
</InnerCardContainer>
|
||||||
</CardWrapper>
|
</DefaultCardContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardWrapper
|
<DefaultCardContainer
|
||||||
key={`card-${columnIndex}-${index}`}
|
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||||
itemGap={itemGap}
|
$isHidden={isHidden}
|
||||||
itemHeight={itemHeight}
|
|
||||||
itemWidth={itemWidth + 12}
|
|
||||||
>
|
>
|
||||||
<StyledCard>
|
<InnerCardContainer>
|
||||||
<Skeleton
|
<ImageContainer>
|
||||||
visible
|
<Skeleton
|
||||||
radius="sm"
|
visible
|
||||||
>
|
radius="sm"
|
||||||
<ImageSection size={itemWidth} />
|
/>
|
||||||
</Skeleton>
|
</ImageContainer>
|
||||||
<DetailSection>
|
</InnerCardContainer>
|
||||||
{cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => (
|
</DefaultCardContainer>
|
||||||
<Skeleton
|
|
||||||
key={`row-${row.property}-${columnIndex}`}
|
|
||||||
height={20}
|
|
||||||
my={2}
|
|
||||||
radius="md"
|
|
||||||
visible={!data}
|
|
||||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</DetailSection>
|
|
||||||
</StyledCard>
|
|
||||||
</CardWrapper>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { UnstyledButtonProps } from '@mantine/core';
|
import type { UnstyledButtonProps } from '@mantine/core';
|
||||||
import { Group } from '@mantine/core';
|
import { RiPlayFill, RiHeartFill, RiHeartLine, RiMoreFill } from 'react-icons/ri';
|
||||||
import { RiPlayFill, RiMore2Fill, RiHeartFill, RiHeartLine } from 'react-icons/ri';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { _Button } from '/@/renderer/components/button';
|
import { _Button } from '/@/renderer/components/button';
|
||||||
import { DropdownMenu } from '/@/renderer/components/dropdown-menu';
|
import type { PlayQueueAddOptions } from '/@/renderer/types';
|
||||||
import type { LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
|
|
||||||
import { Play } 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 {
|
||||||
|
ALBUM_CONTEXT_MENU_ITEMS,
|
||||||
|
ARTIST_CONTEXT_MENU_ITEMS,
|
||||||
|
} from '/@/renderer/features/context-menu/context-menu-items';
|
||||||
|
|
||||||
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
type PlayButtonType = UnstyledButtonProps & React.ComponentPropsWithoutRef<'button'>;
|
||||||
|
|
||||||
const PlayButton = styled.button<PlayButtonType>`
|
const PlayButton = styled.button<PlayButtonType>`
|
||||||
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -23,7 +28,7 @@ const PlayButton = styled.button<PlayButtonType>`
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
transition: scale 0.2s linear;
|
transition: scale 0.1s ease-in-out;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -58,6 +63,8 @@ const SecondaryButton = styled(_Button)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const GridCardControlsContainer = styled.div`
|
const GridCardControlsContainer = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -71,24 +78,13 @@ const ControlsRow = styled.div`
|
|||||||
height: calc(100% / 3);
|
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)`
|
const BottomControls = styled(ControlsRow)`
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
padding: 1rem 0.5rem;
|
padding: 1rem 0.5rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -98,31 +94,18 @@ const FavoriteWrapper = styled.span<{ isFavorite: boolean }>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PLAY_TYPES = [
|
|
||||||
{
|
|
||||||
label: 'Play',
|
|
||||||
play: Play.NOW,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Add to queue (last)',
|
|
||||||
play: Play.LAST,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Add to queue (next)',
|
|
||||||
play: Play.NEXT,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const GridCardControls = ({
|
export const GridCardControls = ({
|
||||||
itemData,
|
itemData,
|
||||||
itemType,
|
itemType,
|
||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
|
handleFavorite,
|
||||||
}: {
|
}: {
|
||||||
|
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||||
itemData: any;
|
itemData: any;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
}) => {
|
}) => {
|
||||||
const playButtonBehavior = useSettingsStore((state) => state.player.playButtonBehavior);
|
const playButtonBehavior = usePlayButtonBehavior();
|
||||||
|
|
||||||
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
const handlePlay = async (e: MouseEvent<HTMLButtonElement>, playType?: Play) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -137,66 +120,58 @@ export const GridCardControls = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFavorites = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
handleFavorite?.({
|
||||||
|
id: [itemData.id],
|
||||||
|
isFavorite: itemData.userFavorite,
|
||||||
|
itemType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = useHandleGeneralContextMenu(
|
||||||
|
itemType,
|
||||||
|
itemType === LibraryItem.ALBUM ? ALBUM_CONTEXT_MENU_ITEMS : ARTIST_CONTEXT_MENU_ITEMS,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GridCardControlsContainer>
|
<GridCardControlsContainer className="card-controls">
|
||||||
{/* <TopControls /> */}
|
<PlayButton onClick={handlePlay}>
|
||||||
{/* <CenterControls /> */}
|
<RiPlayFill size={25} />
|
||||||
|
</PlayButton>
|
||||||
<BottomControls>
|
<BottomControls>
|
||||||
<PlayButton onClick={handlePlay}>
|
<SecondaryButton
|
||||||
<RiPlayFill size={25} />
|
p={5}
|
||||||
</PlayButton>
|
variant="subtle"
|
||||||
<Group spacing="xs">
|
onClick={handleFavorites}
|
||||||
<SecondaryButton
|
>
|
||||||
disabled
|
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
||||||
p={5}
|
{itemData?.userFavorite ? (
|
||||||
sx={{ svg: { fill: 'white !important' } }}
|
<RiHeartFill size={20} />
|
||||||
variant="subtle"
|
) : (
|
||||||
>
|
<RiHeartLine
|
||||||
<FavoriteWrapper isFavorite={itemData?.isFavorite}>
|
color="white"
|
||||||
{itemData?.isFavorite ? (
|
size={20}
|
||||||
<RiHeartFill size={20} />
|
/>
|
||||||
) : (
|
)}
|
||||||
<RiHeartLine
|
</FavoriteWrapper>
|
||||||
color="white"
|
</SecondaryButton>
|
||||||
size={20}
|
<SecondaryButton
|
||||||
/>
|
p={5}
|
||||||
)}
|
variant="subtle"
|
||||||
</FavoriteWrapper>
|
onClick={(e) => {
|
||||||
</SecondaryButton>
|
e.preventDefault();
|
||||||
<DropdownMenu
|
e.stopPropagation();
|
||||||
withinPortal
|
handleContextMenu(e, [itemData]);
|
||||||
position="bottom-start"
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Target>
|
<RiMoreFill
|
||||||
<SecondaryButton
|
color="white"
|
||||||
p={5}
|
size={20}
|
||||||
sx={{ svg: { fill: 'white !important' } }}
|
/>
|
||||||
variant="subtle"
|
</SecondaryButton>
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiMore2Fill
|
|
||||||
color="white"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
</SecondaryButton>
|
|
||||||
</DropdownMenu.Target>
|
|
||||||
<DropdownMenu.Dropdown>
|
|
||||||
{PLAY_TYPES.filter((type) => type.play !== playButtonBehavior).map((type) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={`playtype-${type.play}`}
|
|
||||||
onClick={(e: MouseEvent<HTMLButtonElement>) => handlePlay(e, type.play)}
|
|
||||||
>
|
|
||||||
{type.label}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
<DropdownMenu.Item disabled>Add to playlist</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item disabled>Refresh metadata</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Dropdown>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Group>
|
|
||||||
</BottomControls>
|
</BottomControls>
|
||||||
</GridCardControlsContainer>
|
</GridCardControlsContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,21 +3,18 @@ import type { ListChildComponentProps } from 'react-window';
|
|||||||
import { areEqual } from 'react-window';
|
import { areEqual } from 'react-window';
|
||||||
import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card';
|
import { DefaultCard } from '/@/renderer/components/virtual-grid/grid-card/default-card';
|
||||||
import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card';
|
import { PosterCard } from '/@/renderer/components/virtual-grid/grid-card/poster-card';
|
||||||
import type { GridCardData } from '/@/renderer/types';
|
import { GridCardData, ListDisplayType } from '/@/renderer/types';
|
||||||
import { ListDisplayType } from '/@/renderer/types';
|
|
||||||
|
|
||||||
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
|
export const GridCard = memo(({ data, index, style }: ListChildComponentProps) => {
|
||||||
const {
|
const {
|
||||||
itemHeight,
|
|
||||||
itemWidth,
|
|
||||||
columnCount,
|
columnCount,
|
||||||
itemGap,
|
|
||||||
itemCount,
|
itemCount,
|
||||||
cardRows,
|
cardRows,
|
||||||
itemData,
|
itemData,
|
||||||
itemType,
|
itemType,
|
||||||
playButtonBehavior,
|
playButtonBehavior,
|
||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
|
handleFavorite,
|
||||||
route,
|
route,
|
||||||
display,
|
display,
|
||||||
} = data as GridCardData;
|
} = data as GridCardData;
|
||||||
@@ -26,39 +23,41 @@ export const GridCard = memo(({ data, index, style }: ListChildComponentProps) =
|
|||||||
const startIndex = index * columnCount;
|
const startIndex = index * columnCount;
|
||||||
const stopIndex = Math.min(itemCount - 1, startIndex + columnCount - 1);
|
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;
|
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(
|
cards.push(
|
||||||
<View
|
<View
|
||||||
key={`card-${i}-${index}`}
|
key={`card-${i}-${index}`}
|
||||||
columnIndex={i}
|
columnIndex={i}
|
||||||
controls={{
|
controls={{
|
||||||
cardRows,
|
cardRows,
|
||||||
|
handleFavorite,
|
||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
itemType,
|
itemType,
|
||||||
playButtonBehavior,
|
playButtonBehavior,
|
||||||
route,
|
route,
|
||||||
}}
|
}}
|
||||||
data={itemData[i]}
|
data={itemData[i]}
|
||||||
|
isHidden={i > stopIndex}
|
||||||
listChildProps={{ index }}
|
listChildProps={{ index }}
|
||||||
sizes={{ itemGap, itemHeight, itemWidth }}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
...style,
|
||||||
...style,
|
display: 'flex',
|
||||||
alignItems: 'center',
|
}}
|
||||||
display: 'flex',
|
>
|
||||||
justifyContent: 'start',
|
{cards}
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
{cards}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}, areEqual);
|
}, areEqual);
|
||||||
|
|||||||
@@ -1,61 +1,55 @@
|
|||||||
import { Center } from '@mantine/core';
|
import { Stack } from '@mantine/core';
|
||||||
import { RiAlbumFill } from 'react-icons/ri';
|
import { generatePath, useNavigate } from 'react-router-dom';
|
||||||
import { generatePath } from 'react-router';
|
import { ListChildComponentProps } from 'react-window';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { SimpleImg } from 'react-simple-img';
|
|
||||||
import type { ListChildComponentProps } from 'react-window';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { Skeleton } from '/@/renderer/components/skeleton';
|
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||||
import type { LibraryItem, CardRow, CardRoute, Play, PlayQueueAddOptions } from '/@/renderer/types';
|
|
||||||
import { GridCardControls } from '/@/renderer/components/virtual-grid/grid-card/grid-card-controls';
|
|
||||||
import { Album, Artist, AlbumArtist } from '/@/renderer/api/types';
|
|
||||||
import { CardRows } from '/@/renderer/components/card';
|
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<{
|
interface BaseGridCardProps {
|
||||||
itemGap: number;
|
columnIndex: number;
|
||||||
itemHeight: number;
|
controls: {
|
||||||
itemWidth: number;
|
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||||
}>`
|
handleFavorite: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||||
flex: ${({ itemWidth }) => `0 0 ${itemWidth}px`};
|
handlePlayQueueAdd: (options: PlayQueueAddOptions) => void;
|
||||||
width: ${({ itemWidth }) => `${itemWidth}px`};
|
itemType: LibraryItem;
|
||||||
height: ${({ itemHeight, itemGap }) => `${itemHeight - itemGap}px`};
|
playButtonBehavior: Play;
|
||||||
margin: ${({ itemGap }) => `0 ${itemGap / 2}px`};
|
route: CardRoute;
|
||||||
user-select: none;
|
};
|
||||||
pointer-events: auto; // https://github.com/bvaughn/react-window/issues/128#issuecomment-460166682
|
data: any;
|
||||||
|
isHidden?: boolean;
|
||||||
|
listChildProps: Omit<ListChildComponentProps, 'data' | 'style'>;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover div {
|
const PosterCardContainer = styled.div<{ $isHidden?: boolean }>`
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover * {
|
|
||||||
&::before {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 1px solid #fff;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledCard = styled.div`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
margin: 1rem;
|
||||||
background: var(--card-poster-bg);
|
overflow: hidden;
|
||||||
border-radius: var(--card-poster-radius);
|
opacity: ${({ $isHidden }) => ($isHidden ? 0 : 1)};
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
&:hover {
|
.card-controls {
|
||||||
background: var(--card-poster-bg-hover);
|
opacity: 0;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ImageSection = styled.div`
|
const LinkContainer = styled.div`
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ImageContainer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
aspect-ratio: 1/1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--card-default-bg);
|
||||||
border-radius: var(--card-poster-radius);
|
border-radius: var(--card-poster-radius);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
@@ -71,153 +65,99 @@ const ImageSection = styled.div`
|
|||||||
content: '';
|
content: '';
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
|
||||||
interface ImageProps {
|
&:hover {
|
||||||
height: number;
|
&::before {
|
||||||
isLoading?: boolean;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const Image = styled(SimpleImg)<ImageProps>`
|
&:hover .card-controls {
|
||||||
border: 0;
|
opacity: 1;
|
||||||
border-radius: var(--card-poster-radius);
|
|
||||||
|
|
||||||
img {
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ControlsContainer = styled.div`
|
const Image = styled.img`
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 50;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
opacity: 0;
|
max-width: 100%;
|
||||||
transition: all 0.2s ease-in-out;
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const DetailSection = styled.div`
|
const DetailContainer = styled.div`
|
||||||
display: flex;
|
margin-top: 0.5rem;
|
||||||
flex-direction: column;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface BaseGridCardProps {
|
|
||||||
columnIndex: number;
|
|
||||||
controls: {
|
|
||||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
|
||||||
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 = ({
|
export const PosterCard = ({
|
||||||
listChildProps,
|
listChildProps,
|
||||||
data,
|
data,
|
||||||
columnIndex,
|
columnIndex,
|
||||||
controls,
|
controls,
|
||||||
sizes,
|
isHidden,
|
||||||
}: BaseGridCardProps) => {
|
}: BaseGridCardProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
|
const path = generatePath(
|
||||||
|
controls.route.route,
|
||||||
|
controls.route.slugs?.reduce((acc, slug) => {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[slug.slugProperty]: data[slug.idProperty],
|
||||||
|
};
|
||||||
|
}, {}),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardWrapper
|
<PosterCardContainer key={`card-${columnIndex}-${listChildProps.index}`}>
|
||||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
<LinkContainer onClick={() => navigate(path)}>
|
||||||
itemGap={sizes.itemGap}
|
<ImageContainer>
|
||||||
itemHeight={sizes.itemHeight}
|
<Image
|
||||||
itemWidth={sizes.itemWidth}
|
placeholder={data?.imagePlaceholderUrl || 'var(--card-default-bg)'}
|
||||||
>
|
src={data?.imageUrl}
|
||||||
<StyledCard>
|
|
||||||
<Link
|
|
||||||
tabIndex={0}
|
|
||||||
to={generatePath(
|
|
||||||
controls.route.route,
|
|
||||||
controls.route.slugs?.reduce((acc, slug) => {
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[slug.slugProperty]: data[slug.idProperty],
|
|
||||||
};
|
|
||||||
}, {}),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ImageSection style={{ height: `${sizes.itemWidth}px` }}>
|
|
||||||
{data?.imageUrl ? (
|
|
||||||
<Image
|
|
||||||
animationDuration={0.3}
|
|
||||||
height={sizes.itemWidth}
|
|
||||||
importance="auto"
|
|
||||||
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
|
|
||||||
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
|
||||||
itemData={data}
|
|
||||||
itemType={controls.itemType}
|
|
||||||
/>
|
|
||||||
</ControlsContainer>
|
|
||||||
</ImageSection>
|
|
||||||
</Link>
|
|
||||||
<DetailSection>
|
|
||||||
<CardRows
|
|
||||||
data={data}
|
|
||||||
rows={controls.cardRows}
|
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
<GridCardControls
|
||||||
</StyledCard>
|
handleFavorite={controls.handleFavorite}
|
||||||
</CardWrapper>
|
handlePlayQueueAdd={controls.handlePlayQueueAdd}
|
||||||
|
itemData={data}
|
||||||
|
itemType={controls.itemType}
|
||||||
|
/>
|
||||||
|
</ImageContainer>
|
||||||
|
</LinkContainer>
|
||||||
|
<DetailContainer>
|
||||||
|
<CardRows
|
||||||
|
data={data}
|
||||||
|
rows={controls.cardRows}
|
||||||
|
/>
|
||||||
|
</DetailContainer>
|
||||||
|
</PosterCardContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardWrapper
|
<PosterCardContainer
|
||||||
key={`card-${columnIndex}-${listChildProps.index}`}
|
key={`card-${columnIndex}-${listChildProps.index}`}
|
||||||
itemGap={sizes.itemGap}
|
$isHidden={isHidden}
|
||||||
itemHeight={sizes.itemHeight}
|
|
||||||
itemWidth={sizes.itemWidth}
|
|
||||||
>
|
>
|
||||||
<StyledCard>
|
<Skeleton
|
||||||
<Skeleton
|
visible
|
||||||
visible
|
radius="sm"
|
||||||
radius="sm"
|
>
|
||||||
>
|
<ImageContainer />
|
||||||
<ImageSection style={{ height: `${sizes.itemWidth}px` }} />
|
</Skeleton>
|
||||||
</Skeleton>
|
<DetailContainer>
|
||||||
<DetailSection>
|
<Stack spacing="sm">
|
||||||
{controls.cardRows.map((row: CardRow<Album | Artist | AlbumArtist>, index: number) => (
|
{controls.cardRows.map((row) => (
|
||||||
<Skeleton
|
<Skeleton
|
||||||
key={`row-${row.property}-${columnIndex}`}
|
key={row.arrayProperty}
|
||||||
height={20}
|
visible
|
||||||
my={2}
|
height={14}
|
||||||
radius="md"
|
radius="sm"
|
||||||
visible={!data}
|
|
||||||
width={!data ? (index > 0 ? '50%' : '90%') : '100%'}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</DetailSection>
|
</Stack>
|
||||||
</StyledCard>
|
</DetailContainer>
|
||||||
</CardWrapper>
|
</PosterCardContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,14 +5,8 @@ import type { FixedSizeListProps } from 'react-window';
|
|||||||
import { FixedSizeList } from 'react-window';
|
import { FixedSizeList } from 'react-window';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { GridCard } from '/@/renderer/components/virtual-grid/grid-card';
|
import { GridCard } from '/@/renderer/components/virtual-grid/grid-card';
|
||||||
import type {
|
import type { CardRow, ListDisplayType, CardRoute, PlayQueueAddOptions } from '/@/renderer/types';
|
||||||
CardRow,
|
import { Album, AlbumArtist, Artist, LibraryItem } from '/@/renderer/api/types';
|
||||||
LibraryItem,
|
|
||||||
ListDisplayType,
|
|
||||||
CardRoute,
|
|
||||||
PlayQueueAddOptions,
|
|
||||||
} from '/@/renderer/types';
|
|
||||||
import { Album, AlbumArtist, Artist } from '/@/renderer/api/types';
|
|
||||||
|
|
||||||
const createItemData = memoize(
|
const createItemData = memoize(
|
||||||
(
|
(
|
||||||
@@ -27,10 +21,12 @@ const createItemData = memoize(
|
|||||||
itemWidth,
|
itemWidth,
|
||||||
route,
|
route,
|
||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
|
handleFavorite,
|
||||||
) => ({
|
) => ({
|
||||||
cardRows,
|
cardRows,
|
||||||
columnCount,
|
columnCount,
|
||||||
display,
|
display,
|
||||||
|
handleFavorite,
|
||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
itemCount,
|
itemCount,
|
||||||
itemData,
|
itemData,
|
||||||
@@ -56,6 +52,7 @@ export const VirtualGridWrapper = ({
|
|||||||
columnCount,
|
columnCount,
|
||||||
rowCount,
|
rowCount,
|
||||||
initialScrollOffset,
|
initialScrollOffset,
|
||||||
|
handleFavorite,
|
||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
itemData,
|
itemData,
|
||||||
route,
|
route,
|
||||||
@@ -65,6 +62,7 @@ export const VirtualGridWrapper = ({
|
|||||||
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
cardRows: CardRow<Album | AlbumArtist | Artist>[];
|
||||||
columnCount: number;
|
columnCount: number;
|
||||||
display: ListDisplayType;
|
display: ListDisplayType;
|
||||||
|
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||||
itemData: any[];
|
itemData: any[];
|
||||||
itemGap: number;
|
itemGap: number;
|
||||||
@@ -87,6 +85,7 @@ export const VirtualGridWrapper = ({
|
|||||||
itemWidth,
|
itemWidth,
|
||||||
route,
|
route,
|
||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
|
handleFavorite,
|
||||||
);
|
);
|
||||||
|
|
||||||
const memoizedOnScroll = createScrollHandler(onScroll);
|
const memoizedOnScroll = createScrollHandler(onScroll);
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import debounce from 'lodash/debounce';
|
|||||||
import type { FixedSizeListProps } from 'react-window';
|
import type { FixedSizeListProps } from 'react-window';
|
||||||
import InfiniteLoader from 'react-window-infinite-loader';
|
import InfiniteLoader from 'react-window-infinite-loader';
|
||||||
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
import { VirtualGridWrapper } from '/@/renderer/components/virtual-grid/virtual-grid-wrapper';
|
||||||
import type { CardRoute, CardRow, LibraryItem, PlayQueueAddOptions } from '/@/renderer/types';
|
import type { CardRoute, CardRow, PlayQueueAddOptions } from '/@/renderer/types';
|
||||||
import { ListDisplayType } from '/@/renderer/types';
|
import { ListDisplayType } from '/@/renderer/types';
|
||||||
|
import { LibraryItem } from '/@/renderer/api/types';
|
||||||
|
|
||||||
export type VirtualInfiniteGridRef = {
|
export type VirtualInfiniteGridRef = {
|
||||||
resetLoadMoreItemsCache: () => void;
|
resetLoadMoreItemsCache: () => void;
|
||||||
@@ -24,22 +25,16 @@ interface VirtualGridProps extends Omit<FixedSizeListProps, 'children' | 'itemSi
|
|||||||
cardRows: CardRow<any>[];
|
cardRows: CardRow<any>[];
|
||||||
display?: ListDisplayType;
|
display?: ListDisplayType;
|
||||||
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
fetchFn: (options: { columnCount: number; skip: number; take: number }) => Promise<any>;
|
||||||
|
handleFavorite?: (options: { id: string[]; isFavorite: boolean; itemType: LibraryItem }) => void;
|
||||||
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
handlePlayQueueAdd?: (options: PlayQueueAddOptions) => void;
|
||||||
itemGap: number;
|
itemGap: number;
|
||||||
itemSize: number;
|
itemSize: number;
|
||||||
itemType: LibraryItem;
|
itemType: LibraryItem;
|
||||||
|
loading?: boolean;
|
||||||
minimumBatchSize?: number;
|
minimumBatchSize?: number;
|
||||||
route?: CardRoute;
|
route?: CardRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
const constrainWidth = (width: number) => {
|
|
||||||
if (width < 1920) {
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1920;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VirtualInfiniteGrid = forwardRef(
|
export const VirtualInfiniteGrid = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -54,28 +49,30 @@ export const VirtualInfiniteGrid = forwardRef(
|
|||||||
handlePlayQueueAdd,
|
handlePlayQueueAdd,
|
||||||
minimumBatchSize,
|
minimumBatchSize,
|
||||||
fetchFn,
|
fetchFn,
|
||||||
|
loading,
|
||||||
initialScrollOffset,
|
initialScrollOffset,
|
||||||
|
handleFavorite,
|
||||||
height,
|
height,
|
||||||
width,
|
width,
|
||||||
}: VirtualGridProps,
|
}: VirtualGridProps,
|
||||||
ref: Ref<VirtualInfiniteGridRef>,
|
ref: Ref<VirtualInfiniteGridRef>,
|
||||||
) => {
|
) => {
|
||||||
const [itemData, setItemData] = useState<any[]>([]);
|
|
||||||
const listRef = useRef<any>(null);
|
const listRef = useRef<any>(null);
|
||||||
const loader = useRef<InfiniteLoader>(null);
|
const loader = useRef<InfiniteLoader>(null);
|
||||||
|
|
||||||
|
const [itemData, setItemData] = useState<any[]>([]);
|
||||||
|
|
||||||
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
const { itemHeight, rowCount, columnCount } = useMemo(() => {
|
||||||
const itemsPerRow = Math.floor(
|
const itemsPerRow = itemSize;
|
||||||
(constrainWidth(Number(width)) - itemGap + 3) / (itemSize! + itemGap + 2),
|
const widthPerItem = Number(width) / itemsPerRow;
|
||||||
);
|
const itemHeight = widthPerItem + cardRows.length * 26;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columnCount: itemsPerRow,
|
columnCount: itemsPerRow,
|
||||||
itemHeight: itemSize! + cardRows.length * 22 + itemGap,
|
itemHeight,
|
||||||
itemWidth: itemSize! + itemGap,
|
|
||||||
rowCount: Math.ceil(itemCount / itemsPerRow),
|
rowCount: Math.ceil(itemCount / itemsPerRow),
|
||||||
};
|
};
|
||||||
}, [cardRows.length, itemCount, itemGap, itemSize, width]);
|
}, [cardRows.length, itemCount, itemSize, width]);
|
||||||
|
|
||||||
const isItemLoaded = useCallback(
|
const isItemLoaded = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
@@ -111,7 +108,7 @@ export const VirtualInfiniteGrid = forwardRef(
|
|||||||
|
|
||||||
setItemData(newData);
|
setItemData(newData);
|
||||||
},
|
},
|
||||||
[columnCount, fetchFn, itemData],
|
[columnCount, fetchFn, itemData, setItemData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
|
const debouncedLoadMoreItems = debounce(loadMoreItems, 500);
|
||||||
@@ -120,52 +117,57 @@ export const VirtualInfiniteGrid = forwardRef(
|
|||||||
resetLoadMoreItemsCache: () => {
|
resetLoadMoreItemsCache: () => {
|
||||||
if (loader.current) {
|
if (loader.current) {
|
||||||
loader.current.resetloadMoreItemsCache(false);
|
loader.current.resetloadMoreItemsCache(false);
|
||||||
setItemData(() => []);
|
setItemData([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scrollTo: (index: number) => {
|
scrollTo: (index: number) => {
|
||||||
listRef.current.scrollToItem(index);
|
listRef?.current?.scrollToItem(index);
|
||||||
},
|
},
|
||||||
setItemData: (data: any[]) => {
|
setItemData: (data: any[]) => {
|
||||||
setItemData(data);
|
setItemData(data);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteLoader
|
<>
|
||||||
ref={loader}
|
<InfiniteLoader
|
||||||
isItemLoaded={(index) => isItemLoaded(index)}
|
ref={loader}
|
||||||
itemCount={itemCount || 0}
|
isItemLoaded={(index) => isItemLoaded(index)}
|
||||||
loadMoreItems={debouncedLoadMoreItems}
|
itemCount={itemCount || 0}
|
||||||
minimumBatchSize={minimumBatchSize}
|
loadMoreItems={debouncedLoadMoreItems}
|
||||||
threshold={30}
|
minimumBatchSize={minimumBatchSize}
|
||||||
>
|
threshold={30}
|
||||||
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
>
|
||||||
<VirtualGridWrapper
|
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
|
||||||
cardRows={cardRows}
|
<VirtualGridWrapper
|
||||||
columnCount={columnCount}
|
cardRows={cardRows}
|
||||||
display={display || ListDisplayType.CARD}
|
columnCount={columnCount}
|
||||||
handlePlayQueueAdd={handlePlayQueueAdd}
|
display={display || ListDisplayType.CARD}
|
||||||
height={height}
|
handleFavorite={handleFavorite}
|
||||||
initialScrollOffset={initialScrollOffset}
|
handlePlayQueueAdd={handlePlayQueueAdd}
|
||||||
itemCount={itemCount || 0}
|
height={height}
|
||||||
itemData={itemData}
|
initialScrollOffset={initialScrollOffset}
|
||||||
itemGap={itemGap}
|
itemCount={itemCount || 0}
|
||||||
itemHeight={itemHeight + itemGap / 2}
|
itemData={itemData}
|
||||||
itemType={itemType}
|
itemGap={itemGap}
|
||||||
itemWidth={itemSize}
|
itemHeight={itemHeight}
|
||||||
refInstance={(list) => {
|
itemType={itemType}
|
||||||
infiniteLoaderRef(list);
|
itemWidth={itemSize}
|
||||||
listRef.current = list;
|
refInstance={(list) => {
|
||||||
}}
|
infiniteLoaderRef(list);
|
||||||
route={route}
|
listRef.current = list;
|
||||||
rowCount={rowCount}
|
}}
|
||||||
width={width}
|
route={route}
|
||||||
onItemsRendered={onItemsRendered}
|
rowCount={rowCount}
|
||||||
onScroll={onScroll}
|
width={width}
|
||||||
/>
|
onItemsRendered={onItemsRendered}
|
||||||
)}
|
onScroll={onScroll}
|
||||||
</InfiniteLoader>
|
/>
|
||||||
|
)}
|
||||||
|
</InfiniteLoader>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
|||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
>
|
>
|
||||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
size="sm"
|
size="md"
|
||||||
style={{ display: 'inline-block' }}
|
style={{ display: 'inline-block' }}
|
||||||
>
|
>
|
||||||
,
|
,
|
||||||
@@ -43,8 +43,8 @@ export const AlbumArtistCell = ({ value, data }: ICellRendererParams) => {
|
|||||||
$secondary
|
$secondary
|
||||||
component={Link}
|
component={Link}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
|
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||||
albumArtistId: item.id,
|
albumArtistId: item.id,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
|||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
>
|
>
|
||||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
size="sm"
|
size="md"
|
||||||
style={{ display: 'inline-block' }}
|
style={{ display: 'inline-block' }}
|
||||||
>
|
>
|
||||||
,
|
,
|
||||||
@@ -43,7 +43,7 @@ export const ArtistCell = ({ value, data }: ICellRendererParams) => {
|
|||||||
$secondary
|
$secondary
|
||||||
component={Link}
|
component={Link}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
|
to={generatePath(AppRoute.LIBRARY_ARTISTS_DETAIL, {
|
||||||
artistId: item.id,
|
artistId: item.id,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { motion } from 'framer-motion';
|
|||||||
import { RiAlbumFill } from 'react-icons/ri';
|
import { RiAlbumFill } from 'react-icons/ri';
|
||||||
import { generatePath } from 'react-router';
|
import { generatePath } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { SimpleImg } from 'react-simple-img';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
import type { AlbumArtist, Artist } from '/@/renderer/api/types';
|
||||||
import { Text } from '/@/renderer/components/text';
|
import { Text } from '/@/renderer/components/text';
|
||||||
@@ -23,6 +22,7 @@ const CellContainer = styled(motion.div)<{ height: number }>`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ImageWrapper = styled.div`
|
const ImageWrapper = styled.div`
|
||||||
@@ -41,10 +41,8 @@ const MetadataWrapper = styled.div`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledImage = styled(SimpleImg)`
|
const StyledImage = styled.img`
|
||||||
img {
|
object-fit: cover;
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams) => {
|
||||||
@@ -105,14 +103,14 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
|||||||
<MetadataWrapper>
|
<MetadataWrapper>
|
||||||
<Text
|
<Text
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
>
|
>
|
||||||
{value.name}
|
{value.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
>
|
>
|
||||||
{artists?.length ? (
|
{artists?.length ? (
|
||||||
artists.map((artist: Artist | AlbumArtist, index: number) => (
|
artists.map((artist: Artist | AlbumArtist, index: number) => (
|
||||||
@@ -123,9 +121,9 @@ export const CombinedTitleCell = ({ value, rowIndex, node }: ICellRendererParams
|
|||||||
$secondary
|
$secondary
|
||||||
component={Link}
|
component={Link}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
sx={{ width: 'fit-content' }}
|
sx={{ width: 'fit-content' }}
|
||||||
to={generatePath(AppRoute.LIBRARY_ALBUMARTISTS_DETAIL, {
|
to={generatePath(AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL, {
|
||||||
albumArtistId: artist.id,
|
albumArtistId: artist.id,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* 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 { useCreateFavorite, useDeleteFavorite } from '/@/renderer/features/shared';
|
||||||
|
|
||||||
|
export const FavoriteCell = ({ value, data, node }: ICellRendererParams) => {
|
||||||
|
const createMutation = useCreateFavorite({});
|
||||||
|
const deleteMutation = useDeleteFavorite({});
|
||||||
|
|
||||||
|
const handleToggleFavorite = () => {
|
||||||
|
const newFavoriteValue = !value;
|
||||||
|
|
||||||
|
if (newFavoriteValue) {
|
||||||
|
createMutation.mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
id: [data.id],
|
||||||
|
type: data.itemType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
deleteMutation.mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
id: [data.id],
|
||||||
|
type: data.itemType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
node.setData({ ...data, userFavorite: newFavoriteValue });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CellContainer position="center">
|
||||||
|
<Button
|
||||||
|
compact
|
||||||
|
sx={{
|
||||||
|
svg: {
|
||||||
|
fill: !value
|
||||||
|
? 'var(--main-fg-secondary) !important'
|
||||||
|
: 'var(--primary-color) !important',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
>
|
||||||
|
{!value ? <RiHeartLine size="1.3em" /> : <RiHeartFill size="1.3em" />}
|
||||||
|
</Button>
|
||||||
|
</CellContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ export const CellContainer = styled(motion.div)<{ position?: 'left' | 'center' |
|
|||||||
: 'flex-start'};
|
: 'flex-start'};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
@@ -60,7 +61,7 @@ export const GenericCell = (
|
|||||||
$secondary={!primary}
|
$secondary={!primary}
|
||||||
component={Link}
|
component={Link}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
to={displayedValue.link}
|
to={displayedValue.link}
|
||||||
>
|
>
|
||||||
{isLink ? displayedValue.value : displayedValue}
|
{isLink ? displayedValue.value : displayedValue}
|
||||||
@@ -69,7 +70,7 @@ export const GenericCell = (
|
|||||||
<Text
|
<Text
|
||||||
$secondary={!primary}
|
$secondary={!primary}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
>
|
>
|
||||||
{displayedValue}
|
{displayedValue}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
|
|||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
>
|
>
|
||||||
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
{value?.map((item: Artist | AlbumArtist, index: number) => (
|
||||||
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
<React.Fragment key={`row-${item.id}-${data.uniqueId}`}>
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<Text
|
<Text
|
||||||
$secondary
|
$secondary
|
||||||
size="sm"
|
size="md"
|
||||||
style={{ display: 'inline-block' }}
|
style={{ display: 'inline-block' }}
|
||||||
>
|
>
|
||||||
,
|
,
|
||||||
@@ -29,7 +29,7 @@ export const GenreCell = ({ value, data }: ICellRendererParams) => {
|
|||||||
$secondary
|
$secondary
|
||||||
component={Link}
|
component={Link}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
size="sm"
|
size="md"
|
||||||
to="/"
|
to="/"
|
||||||
>
|
>
|
||||||
{item.name || '—'}
|
{item.name || '—'}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/* 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 { useSetRating } from '/@/renderer/features/shared';
|
||||||
|
|
||||||
|
export const RatingCell = ({ value, node }: ICellRendererParams) => {
|
||||||
|
const updateRatingMutation = useSetRating({});
|
||||||
|
|
||||||
|
const handleUpdateRating = (rating: number) => {
|
||||||
|
if (!value) return;
|
||||||
|
|
||||||
|
updateRatingMutation.mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
item: [value],
|
||||||
|
rating,
|
||||||
|
},
|
||||||
|
serverId: value?.serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
node.setData({ ...node.data, userRating: rating });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearRating = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateRatingMutation.mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
item: [value],
|
||||||
|
rating: 0,
|
||||||
|
},
|
||||||
|
serverId: value?.serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
node.setData({ ...node.data, userRating: 0 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CellContainer position="center">
|
||||||
|
<Rating
|
||||||
|
size="xs"
|
||||||
|
value={value?.userRating}
|
||||||
|
onChange={handleUpdateRating}
|
||||||
|
onClick={handleClearRating}
|
||||||
|
/>
|
||||||
|
</CellContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,9 +2,11 @@ import type { ReactNode } from 'react';
|
|||||||
import type { IHeaderParams } from '@ag-grid-community/core';
|
import type { IHeaderParams } from '@ag-grid-community/core';
|
||||||
import { AiOutlineNumber } from 'react-icons/ai';
|
import { AiOutlineNumber } from 'react-icons/ai';
|
||||||
import { FiClock } from 'react-icons/fi';
|
import { FiClock } from 'react-icons/fi';
|
||||||
|
import { RiHeartLine, RiStarLine } from 'react-icons/ri';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { _Text } from '/@/renderer/components/text';
|
||||||
|
|
||||||
type Presets = 'duration' | 'rowIndex';
|
type Presets = 'duration' | 'rowIndex' | 'userFavorite' | 'userRating';
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -12,7 +14,7 @@ type Options = {
|
|||||||
preset?: Presets;
|
preset?: Presets;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
|
const HeaderWrapper = styled.div<{ position: Options['position'] }>`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: ${(props) =>
|
justify-content: ${(props) =>
|
||||||
props.position === 'right'
|
props.position === 'right'
|
||||||
@@ -25,17 +27,63 @@ const HeaderWrapper = styled.div<{ position: 'left' | 'center' | 'right' }>`
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const headerPresets = { duration: <FiClock size={15} />, rowIndex: <AiOutlineNumber size={15} /> };
|
const TextHeaderWrapper = styled(_Text)<{ position: Options['position'] }>`
|
||||||
|
width: 100%;
|
||||||
|
color: var(--ag-header-foreground-color);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: ${(props) =>
|
||||||
|
props.position === 'right'
|
||||||
|
? 'flex-end'
|
||||||
|
: props.position === 'center'
|
||||||
|
? 'center'
|
||||||
|
: 'flex-start'};
|
||||||
|
text-transform: uppercase;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const headerPresets = {
|
||||||
|
duration: (
|
||||||
|
<FiClock
|
||||||
|
color="var(--ag-header-foreground-color)"
|
||||||
|
size="1em"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
rowIndex: (
|
||||||
|
<AiOutlineNumber
|
||||||
|
color="var(--ag-header-foreground-color)"
|
||||||
|
size="1em"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
userFavorite: (
|
||||||
|
<RiHeartLine
|
||||||
|
color="var(--ag-header-foreground-color)"
|
||||||
|
size="1em"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
userRating: (
|
||||||
|
<RiStarLine
|
||||||
|
color="var(--ag-header-foreground-color)"
|
||||||
|
size="1em"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const GenericTableHeader = (
|
export const GenericTableHeader = (
|
||||||
{ displayName }: IHeaderParams,
|
{ displayName }: IHeaderParams,
|
||||||
{ preset, children, position }: Options,
|
{ preset, children, position }: Options,
|
||||||
) => {
|
) => {
|
||||||
if (preset) {
|
if (preset) {
|
||||||
return <HeaderWrapper position={position || 'left'}>{headerPresets[preset]}</HeaderWrapper>;
|
return <HeaderWrapper position={position}>{headerPresets[preset]}</HeaderWrapper>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <HeaderWrapper position={position || 'left'}>{children || displayName}</HeaderWrapper>;
|
return (
|
||||||
|
<TextHeaderWrapper
|
||||||
|
overflow="hidden"
|
||||||
|
position={position}
|
||||||
|
weight={500}
|
||||||
|
>
|
||||||
|
{children || displayName}
|
||||||
|
</TextHeaderWrapper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
GenericTableHeader.defaultProps = {
|
GenericTableHeader.defaultProps = {
|
||||||
|
|||||||