Compare commits
291 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68b1134da5 | |||
| 46187acadc | |||
| 69c79f0620 | |||
| 234c9546c4 | |||
| edc80bc3b4 | |||
| 1d05966127 | |||
| 2013c46991 | |||
| eb7e259c86 | |||
| 20cd34a38f | |||
| 154de4147c | |||
| 4215e0e1f1 | |||
| b89fd340e7 | |||
| 19792bc8d8 | |||
| 118f416066 | |||
| 693286a6a1 | |||
| c273dd9753 | |||
| 9c98f8aa01 | |||
| ad39919f3f | |||
| 0a89e35a3e | |||
| de1e6c16c0 | |||
| b0f031bedc | |||
| 079a400e74 | |||
| 2cb54a068a | |||
| 44ffe1984b | |||
| d5d0c29280 | |||
| 96dc79a527 | |||
| 9b2f9f6326 | |||
| 1c130e6c58 | |||
| 01d327f9f1 | |||
| 99f9e67500 | |||
| 892fa0e7b8 | |||
| 9d7b595e41 | |||
| b1d06581ab | |||
| 0cb2e40f2b | |||
| 6f0c523559 | |||
| b51a79c3cd | |||
| cf9187f548 | |||
| 25f6c940bb | |||
| 449e11c2d4 | |||
| 9f4a58352d | |||
| 4f3bd8f44a | |||
| 5811069a0a | |||
| 24b0d37bd1 | |||
| d66c756af5 | |||
| 4cb78bb656 | |||
| fe678b546e | |||
| 2a6167441a | |||
| b4825c35b5 | |||
| c89808fd14 | |||
| 04b45e26cd | |||
| 3827953b59 | |||
| 82d4ad5502 | |||
| b411ab13ac | |||
| 67e3a00b26 | |||
| f40c3838a1 | |||
| da2ad0ac2e | |||
| bf5f464712 | |||
| f83368874f | |||
| 2be651ed56 | |||
| d120057c8d | |||
| 99398d5675 | |||
| f62aacf9d8 | |||
| a8f0a20602 | |||
| f9c5e6f9fb | |||
| e76267be06 | |||
| 2a29f33352 | |||
| 758e9d4d2c | |||
| 8d5a05c329 | |||
| 8dad9a8109 | |||
| 7061e16fb4 | |||
| 4029b149ee | |||
| 5ee5a089c5 | |||
| 9e6e8d7fd6 | |||
| 28cc1f9c30 | |||
| 38993f3352 | |||
| 1bd538b6b4 | |||
| 295eb82f20 | |||
| 1cc40348cb | |||
| 85e80be08b | |||
| 420d4835be | |||
| c54eea4382 | |||
| 1babcc40ee | |||
| 14c22c63a0 | |||
| 1a6c4af5df | |||
| 4e2325f05d | |||
| 135a8d7a45 | |||
| 74170185a2 | |||
| 3a8665fded | |||
| d028af5ff0 | |||
| ab7787484c | |||
| c469e2e5cc | |||
| 5de5b34ecf | |||
| ec59d27c1d | |||
| 9a079ef263 | |||
| 372fc7c1f6 | |||
| 1686005792 | |||
| b9c0b62cf8 | |||
| 797e3eab3b | |||
| b100f4f790 | |||
| 6c5a70c03e | |||
| 353168ad4e | |||
| a03f41b76d | |||
| 81fafd20d3 | |||
| 8353640d05 | |||
| 79fae63aaf | |||
| 544fd25f6b | |||
| 0e9953be13 | |||
| 258e5bb8f6 | |||
| 5c6cd2410e | |||
| b08c7dfaf1 | |||
| 46145fd3aa | |||
| 0b5cea16e9 | |||
| 34f4458733 | |||
| d3d6db03d7 | |||
| f7839d6ed6 | |||
| 581ef32845 | |||
| 73e6002cc7 | |||
| 048b5898b9 | |||
| ddc0355b1e | |||
| 30a7bac59a | |||
| 445e4b56b7 | |||
| df8e38cedd | |||
| 3bc0ea16bc | |||
| 84b031f126 | |||
| 8357941bfa | |||
| 187ccad15b | |||
| a326355576 | |||
| e6bf71dcfe | |||
| a964662ad4 | |||
| 2652ab927f | |||
| 30e9543544 | |||
| d6fe3038e0 | |||
| d3e4c7d975 | |||
| 1df07dd12a | |||
| 49eb78b03c | |||
| 7b1940e1f5 | |||
| b2cc85d368 | |||
| dbba5f9f95 | |||
| 03278d2624 | |||
| 9022f05463 | |||
| 3cdd08fe89 | |||
| f7ea6c45f5 | |||
| ecb090d324 | |||
| 587fa2422a | |||
| e774cdf031 | |||
| cd56783c96 | |||
| 6ac949bf88 | |||
| de91f75203 | |||
| d69221f8a4 | |||
| 39d98c5066 | |||
| 4304a2ae84 | |||
| fc1ab03118 | |||
| f76cc2f230 | |||
| 70ce493f5e | |||
| 633c6416df | |||
| 07123615ca | |||
| 385ec5f856 | |||
| 608518ac1c | |||
| cee6ff4df5 | |||
| c8da4f8146 | |||
| 63dba7b379 | |||
| e014ac0a4b | |||
| ae53b17214 | |||
| fd53f90db2 | |||
| d88e99e38c | |||
| 096a7713da | |||
| 7aa89e8ad2 | |||
| f284b29052 | |||
| 5908554f38 | |||
| 53a7d728b3 | |||
| be05c1df79 | |||
| e9142ffaa5 | |||
| aeefbf8f7f | |||
| 061e61b7d3 | |||
| a888007bfa | |||
| 94b40178aa | |||
| 73fff64a75 | |||
| 97486b23ee | |||
| 005a30e0f4 | |||
| 1cdcde010c | |||
| 895356701f | |||
| 689560a7a5 | |||
| bc9e6a9a73 | |||
| eb73c87933 | |||
| 7725d3dfbb | |||
| 659a9b949b | |||
| 04afa13eae | |||
| df1844b74c | |||
| b2fc76203d | |||
| a60a053b6b | |||
| 699ed268e6 | |||
| e4f797debc | |||
| 852a4297a3 | |||
| 2489622d90 | |||
| 19090a0ed8 | |||
| 0200b92860 | |||
| ff6882a6cd | |||
| 97b383ff0b | |||
| 4fb963d689 | |||
| c6d80831f8 | |||
| 32fe11d3de | |||
| 5bcb0a3824 | |||
| 91536f1bc9 | |||
| 4fb8d4ebd9 | |||
| b3f4cfee5d | |||
| 00710125d3 | |||
| 2352c136a1 | |||
| ee8cc14e7e | |||
| ca664d9430 | |||
| cbbf3087ff | |||
| 88e716b970 | |||
| 17258e950e | |||
| 5b349083c9 | |||
| 1130006f0f | |||
| e31d8c1cc4 | |||
| 834e311253 | |||
| 82a47c42b8 | |||
| 46eb5e4e5d | |||
| 9204cdbe6a | |||
| 8258bbe5b3 | |||
| 8f82d001f0 | |||
| 6763f4439d | |||
| 1622c12dfa | |||
| 02e3b96384 | |||
| c4765ba2d1 | |||
| 1f261a95ad | |||
| cf17ce6e9d | |||
| 146a03cb3c | |||
| f8e1a7d79e | |||
| dc891e1b79 | |||
| 0438f2d5f2 | |||
| 863dce88b7 | |||
| f09abdb4c6 | |||
| 781adb7c4d | |||
| dd3de66232 | |||
| 8973571147 | |||
| f8e7d02daf | |||
| 921c688c94 | |||
| 663b951cd7 | |||
| d4e4bdb858 | |||
| 33e4526caa | |||
| df3418120a | |||
| dfdf53f6ee | |||
| d5bbff5eb6 | |||
| 76b6eed4bb | |||
| 4a3ce02805 | |||
| db8a7d6a63 | |||
| c09e9b6583 | |||
| 730b72e64f | |||
| 3aab920c71 | |||
| 9675fd1d8e | |||
| 3e80f71833 | |||
| 9424203960 | |||
| 27b4b36cbf | |||
| 238c90478e | |||
| a0c634da2f | |||
| 7b55ca2fa8 | |||
| e2808e0bd4 | |||
| 5f844ef975 | |||
| 968e80a6d8 | |||
| 0772566637 | |||
| 99f30439e1 | |||
| 6433ccd750 | |||
| ea2d3ea8f1 | |||
| 21bf5ce523 | |||
| 95421698da | |||
| 02ef79dcb2 | |||
| 8aedd94033 | |||
| b5c7abb566 | |||
| 9d09b830f9 | |||
| dd6b80795e | |||
| a087dbdea3 | |||
| 3d677188b5 | |||
| d11051bbc1 | |||
| e1bc6ecf30 | |||
| 9c9cf3a978 | |||
| e1977b291e | |||
| c100bbb341 | |||
| bb4576390d | |||
| df5f8c08f3 | |||
| 95ff874702 | |||
| 1eed976747 | |||
| fe422897ab | |||
| bc86da7762 | |||
| aa673ac854 | |||
| b8cf1d8283 | |||
| b9a171b096 | |||
| fa9cf2efda | |||
| 34ee3222f4 | |||
| f13427022e | |||
| e8b612c974 |
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
release/app/node_modules
|
||||
release/app/dist
|
||||
server/node_modules
|
||||
@@ -22,16 +22,21 @@ if (process.env.NODE_ENV === 'production') {
|
||||
const port = process.env.PORT || 4343;
|
||||
const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const requiredByDLLConfig = module.parent!.filename.includes('webpack.config.renderer.dev.dll');
|
||||
const requiredByDLLConfig = module.parent!.filename.includes(
|
||||
'webpack.config.renderer.dev.dll'
|
||||
);
|
||||
|
||||
/**
|
||||
* Warn if the DLL is not built
|
||||
*/
|
||||
if (!requiredByDLLConfig && !(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))) {
|
||||
if (
|
||||
!requiredByDLLConfig &&
|
||||
!(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
|
||||
) {
|
||||
console.log(
|
||||
chalk.black.bgYellow.bold(
|
||||
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
|
||||
),
|
||||
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"'
|
||||
)
|
||||
);
|
||||
execSync('npm run postinstall');
|
||||
}
|
||||
|
||||
@@ -4,24 +4,21 @@ module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
createDefaultProgram: true,
|
||||
ecmaVersion: 12,
|
||||
parser: '@typescript-eslint/parser',
|
||||
ecmaVersion: 2020,
|
||||
project: './tsconfig.json',
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: './',
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
|
||||
root: true,
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-shadow': ['off'],
|
||||
'default-case': 'off',
|
||||
'import/extensions': 'off',
|
||||
'import/no-absolute-path': 'off',
|
||||
// A temporary hack related to IDE not resolving correct package.json
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
|
||||
'import/no-unresolved': 'error',
|
||||
'import/order': [
|
||||
'error',
|
||||
@@ -51,7 +48,6 @@ module.exports = {
|
||||
'no-nested-ternary': 'off',
|
||||
'no-restricted-syntax': 'off',
|
||||
'no-underscore-dangle': 'off',
|
||||
'prefer-destructuring': 'off',
|
||||
'react/jsx-props-no-spreading': 'off',
|
||||
'react/jsx-sort-props': [
|
||||
'error',
|
||||
@@ -64,9 +60,8 @@ module.exports = {
|
||||
shorthandLast: false,
|
||||
},
|
||||
],
|
||||
'react/no-array-index-key': 'off',
|
||||
// Since React 17 and typescript 4.1 you can safely disable the rule
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/require-default-props': 'off',
|
||||
'sort-keys-fix/sort-keys-fix': 'warn',
|
||||
},
|
||||
settings: {
|
||||
@@ -78,10 +73,7 @@ module.exports = {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
typescript: {},
|
||||
webpack: {
|
||||
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
|
||||
},
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
ko_fi: jeffvli
|
||||
|
||||
@@ -4,6 +4,18 @@ about: You're having technical issues. 🐞
|
||||
labels: 'bug'
|
||||
---
|
||||
|
||||
<!-- Please use the following issue template or your issue will be closed -->
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<!-- If the following boxes are not ALL checked, your issue is likely to be closed -->
|
||||
|
||||
- [ ] Using npm
|
||||
- [ ] Using an up-to-date [`main` branch](https://github.com/electron-react-boilerplate/electron-react-boilerplate/tree/main)
|
||||
- [ ] Using latest version of devtools. [Check the docs for how to update](https://electron-react-boilerplate.js.org/docs/dev-tools/)
|
||||
- [ ] Tried solutions mentioned in [#400](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/400)
|
||||
- [ ] For issue in production release, add devtools output of `DEBUG_PROD=true npm run build && npm start`
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!--- What should have happened? -->
|
||||
@@ -11,8 +23,6 @@ labels: 'bug'
|
||||
## Current Behavior
|
||||
|
||||
<!--- What went wrong? -->
|
||||
<!-- Add screenshots to help explain your problem -->
|
||||
<!-- (Open the browser dev tools in the menu or using CTRL + SHIFT + I) -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
@@ -34,12 +44,24 @@ labels: 'bug'
|
||||
## Context
|
||||
|
||||
<!--- How has this issue affected you? What are you trying to accomplish? -->
|
||||
<!--- Did you make any changes to the boilerplate after cloning it? -->
|
||||
<!--- Providing context helps us come up with a solution that is most useful in the real world -->
|
||||
|
||||
## Your Environment
|
||||
|
||||
<!--- Include as many relevant details about the environment you experienced the bug in -->
|
||||
|
||||
- Application version (e.g. v0.1.0) :
|
||||
- Operating System and version (e.g. Windows 10) :
|
||||
- Server and version (e.g. Navidrome v0.48.0) :
|
||||
- Node version (if developing locally) :
|
||||
- Node version :
|
||||
- electron-react-boilerplate version or branch :
|
||||
- Operating System and version :
|
||||
- Link to your project :
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
Donations will ensure the following:
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
|
||||
@@ -4,6 +4,16 @@ about: Ask a question.❓
|
||||
labels: 'question'
|
||||
---
|
||||
|
||||
<!-- Question issues will be closed. -->
|
||||
<!-- Ask questions in the discussions tab: Please use discussions https://github.com/jeffvli/feishin/discussions -->
|
||||
<!-- Or join the Discord/Matrix servers: https://discord.gg/FVKpcMDy5f https://matrix.to/#/#sonixd:matrix.org -->
|
||||
## Summary
|
||||
|
||||
<!-- What do you need help with? -->
|
||||
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
Donations will ensure the following:
|
||||
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Request a feature to be added to Feishin 🎉
|
||||
about: You want something added to the boilerplate. 🎉
|
||||
labels: 'enhancement'
|
||||
---
|
||||
|
||||
## What do you want to be added?
|
||||
<!---
|
||||
❗️❗️ Also, please consider donating (https://opencollective.com/electron-react-boilerplate-594) ❗️❗️
|
||||
|
||||
## Additional context
|
||||
Donations will ensure the following:
|
||||
|
||||
<!-- Is this a server-specific feature? (e.g. Jellyfin only). -->
|
||||
🔨 Long term maintenance of the project
|
||||
🛣 Progress on the roadmap
|
||||
🐛 Quick responses to bug reports and help requests
|
||||
-->
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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,54 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish Windows and macOS (Manual)
|
||||
name: Publish (Manual)
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
@@ -35,5 +35,5 @@ jobs:
|
||||
command: |
|
||||
npm run postinstall
|
||||
npm run build
|
||||
npm exec electron-builder -- --publish always --win --mac
|
||||
npm exec electron-builder -- --publish always --win --mac --linux
|
||||
on_retry_command: npm cache clean --force
|
||||
@@ -1,20 +1,8 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.css", "**/*.scss", "**/*.html"],
|
||||
"options": {
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"proseWrap": "never",
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"endOfLine": "lf",
|
||||
"singleAttributePerLine": true
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
|
||||
@@ -32,12 +32,23 @@
|
||||
"package-lock.json": true,
|
||||
"*.{css,sass,scss}.d.ts": true
|
||||
},
|
||||
"rest-client.environmentVariables": {
|
||||
"$shared": {
|
||||
"host": "http://localhost:9321"
|
||||
},
|
||||
"dev-user": {
|
||||
"token": "",
|
||||
"refreshToken": "",
|
||||
"authUsername": "user",
|
||||
"authPassword": "user"
|
||||
},
|
||||
"dev-admin": {
|
||||
"token": "",
|
||||
"refreshToken": "",
|
||||
"authUsername": "admin",
|
||||
"authPassword": "admin"
|
||||
}
|
||||
},
|
||||
"i18n-ally.localesPaths": ["src/i18n", "src/i18n/locales"],
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"stylelint.validate": ["css", "less", "postcss", "typescript", "typescriptreact", "scss"],
|
||||
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||
"[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib"
|
||||
}
|
||||
|
||||
@@ -2,4 +2,585 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
[0.15.0] - 2022-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added setting to save and resume the current queue between sessions (#130) (Thanks @kgarner7)
|
||||
- Added a simple "play random" button to the player bar (#276)
|
||||
- Added new seek/volume sliders (#272)
|
||||
- Seeking/dragging is now more responsive
|
||||
- Added improved discord rich presence (#286)
|
||||
- Added download button on the playlist view (#266)
|
||||
- (Jellyfin) Added "genre" column to the artist list
|
||||
|
||||
### Changed
|
||||
|
||||
- Swapped the order of "Seek Forward/Backward" and "Next/Prev Track" buttons on the player bar
|
||||
- Global volume is now calculated logarithmically (#275) (Thanks @gelaechter)
|
||||
- "Auto playlist" is now named "Play Random" (#276)
|
||||
- "Now playing" option is now available on the "Start page" setting
|
||||
|
||||
### Fixed
|
||||
|
||||
- Playing songs by double clicking on a list should now play in the proper order (#279)
|
||||
- (Linux) Fixed MPRIS metadata not updating when player automatically increments (#263)
|
||||
- Application fonts now loaded locally instead of from Google CDN (#284)
|
||||
- Enabling "Default to Album List on Artist Page" no longer performs a double redirect when entering the artist page (#271)
|
||||
- Stop button is no longer disabled when playback is stopped (#273)
|
||||
- Various package updates (#288) (Thanks @kgarner7)
|
||||
- Top control bar show no longer be accessible when not logged in (#267)
|
||||
|
||||
[0.14.0] - 2022-03-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added zoom options via hotkeys (#252)
|
||||
- Zoom in: CTRL + SHIFT + =
|
||||
- Zoom out: CTRL + SHIFT + -
|
||||
- Added PLAY context menu options to the Genre view (#239)
|
||||
- Added STOP button to the main player controls (#252)
|
||||
- Added "System Notifications" option to display native notifications when the song automatically changes (#245)
|
||||
- Added arm64 build (#238)
|
||||
- New languages
|
||||
- Spanish (Thanks @ami-sc) (#250)
|
||||
- Sinhala (Thanks @hirusha-adi) (#254)
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Jellyfin) Fixed the order of returned songs when playing from the Folder view using the context menu (#240)
|
||||
- (Linux) Reset MPRIS position to 0 when using "previous track" resets the song 0 (#249)
|
||||
- Fixed JavaScript error when removing all songs from the queue using the context menu (#248)
|
||||
- Fixed Ampache server support by adding .view to all Subsonic API endpoints (#253)
|
||||
|
||||
### Removed
|
||||
|
||||
- (Windows) Removed the cover art display when hovering Sonixd on the taskbar (due to new sidebar position) (#242)
|
||||
|
||||
[0.13.1] - 2022-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed startup crash on all OS if the default settings file is not present (#237)
|
||||
|
||||
[0.13.0] - 2022-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added new searchbar and search UI (#227, #228)
|
||||
- Added playback controls to the Sonixd tray menu (#225)
|
||||
- Added playlist selections to the `Start Page` config option
|
||||
|
||||
### Changed
|
||||
|
||||
- Sidebar changes (#206)
|
||||
|
||||
- Allow resizing of the sidebar when expanded
|
||||
- Allow a toggle of the playerbar's cover art to the sidebar when expanded
|
||||
- Display playlist list on the sidebar under the navigation
|
||||
- Allow configuration of the display of sidebar elements
|
||||
|
||||
- Changed the `Artist` row on the playerbar to use a comma delimited list of the song's artists rather than the album artist (#218)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the player volume not resetting to its default value when resetting a song while crossfading (#228)
|
||||
- (Jellyfin) Fixed artist list not displaying user favorites
|
||||
- (Jellyfin) Fixed `bitrate` column not properly by its numeric value (#220)
|
||||
- Fixed javascript exception when incrementing/decrementing the queue (#230)
|
||||
- Fixed popups/tooltips not using the configured font
|
||||
|
||||
[0.12.1] - 2022-02-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed translation syntax error causing application to crash when deleting playlists from the context menu (#216)
|
||||
- Fixed Player behavior (#217)
|
||||
- No longer scrobbles an additional time after the last song ends when repeat is off
|
||||
- (Jellyfin) Properly handles scrobbling the player's pause/resume and time position
|
||||
|
||||
[0.12.0] - 2022-01-31
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for language/translations (#146) (Thanks @gelaechter)
|
||||
- German translation added (Thanks @gelaechter)
|
||||
- Simplified Chinese translation added (Thanks @fangxx3863)
|
||||
- (Windows) Added media keys with desktop overlay (#79) (Thanks @GermanDarknes)
|
||||
- (Subsonic) Added support for `/getLyrics` to display the current song's lyrics in a popup (#151)
|
||||
- (Jellyfin) Added song list page
|
||||
- Added config to choose the default Album/Song list sort on startup (#169)
|
||||
- Added config to choose the application start page (#176) (Thanks @GermanDarknes)
|
||||
- Added config for pagination for Album/Song list pages
|
||||
- (Windows) Added option to set custom directory on installation (#184)
|
||||
- Added config to set the default artist page to the album list (#199)
|
||||
- Added info mode for the Now Playing page (#160)
|
||||
- Added release notes popup
|
||||
|
||||
### Changed
|
||||
|
||||
- Player behavior
|
||||
- `Media Stop` now stops the track and resets it instead of clearing the queue (#200)
|
||||
- `Media Prev` now resets to the start of the song if pressed after 5 seconds (#207)
|
||||
- `Media Prev` now resets to the start of the song if repeat is off and is the first song of the queue (#207)
|
||||
- `Media Next` now does nothing if repeat is off and is the last song of the queue (#207)
|
||||
- Playing a single track in the queue without repeat no longer plays the track twice (#205)
|
||||
- Scrobbling
|
||||
- (Jellyfin) Scrobbling has been reverted to use the `/sessions/playing` endpoint to support the Playback Reporting plugin (#187)
|
||||
- Scrobbling occurs after 5 seconds has elapsed for the current track as to not instantly mark the song as played
|
||||
- Pressing `CTRL + F` or the search button now focuses the text in the searchbar (#203) (Thanks @WeekendWarrior1)
|
||||
- Changed loading indicators for all pages
|
||||
- OBS scrobble now outputs an image.txt file instead of the downloading the cover image (#136)
|
||||
- Player Bar
|
||||
- Album name now appears under the artist
|
||||
- (Subsonic) 5-star rating is available
|
||||
- Clicking on the cover art now displays a full-size image
|
||||
- Clicking on the song name now redirects to the Now Playing queue
|
||||
- (Jellyfin) Removed track limit for "Auto Playlist"
|
||||
|
||||
### Fixed
|
||||
|
||||
- (macOS) Fixed macOS exit behavior (#198) (Thanks @zackslash)
|
||||
- (Linux) Fixed MPRIS `position` result (#162)
|
||||
- (Subsonic) Fixed artist page crashing the application if server does not support `/getArtistInfo2` (#170)
|
||||
- (Jellyfin) Fixed `View all songs` returning songs out of their album track order
|
||||
- (Jellyfin) Fixed the "Latest Albums" on the album artist page displaying no albums
|
||||
- Fixed card overlay button color on click
|
||||
- Fixed buttons on the Album page to work better with light mode
|
||||
- Fixed unfavorite button on Album page
|
||||
|
||||
[0.11.0] - 2022-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added external integrations
|
||||
- Added Discord rich presence to display the currently playing song (#155)
|
||||
- Added OBS (Open Broadcaster Software) scrobbling to send current track metadata to desktop or the Tuna plugin (#136)
|
||||
- Added a `Native` option for Titlebar Style (#148) (Thanks @gelaechter)
|
||||
- (Jellyfin) Added toggle to allow transcoding for non-directplay compatible filetypes (#158)
|
||||
- Additional MPRIS support
|
||||
- Added metadata:
|
||||
- `albumArtist`, `discNumber`, `trackNumber`, `useCount`, `genre`
|
||||
- Added events:
|
||||
- `seek`, `position`, `volume`, `repeat`, `shuffle`
|
||||
|
||||
### Changed
|
||||
|
||||
- Overhauled the Artist page
|
||||
- (Jellyfin) Split albums by album artist OR compilation
|
||||
- (Jellyfin) Added artist genres
|
||||
- (Subsonic) Added Top Songs section
|
||||
- Moved related artists to the main page scrolling menu
|
||||
- Added `View All Songs` button to view all songs by the artist
|
||||
- Added artist radio (mix) button
|
||||
- Horizontal scrolling menu no longer displays scrollbar
|
||||
- Changed button styling on Playlist/Album/Artist pages
|
||||
- Changed page image styling to use the card on Playlist/Album/Artist pages
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed various MPRIS features
|
||||
- Synchronized the play/pause state between the player and MPRIS client when pausing from Sonixd (#152)
|
||||
- Fixed the identity of Sonixd to use the app name instead of description (#163)
|
||||
- Fixed various submenus opening in the right-click context menu when the option is disabled (#164)
|
||||
- Fixed compatibility with older Subsonic API servers (now targets Subsonic v1.13.0) (#144)
|
||||
- Fixed playback causing heavily increased CPU/Power usage #145)
|
||||
|
||||
[0.10.0] - 2021-12-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added 2 new default themes
|
||||
- City Lights
|
||||
- One Dark
|
||||
- Added additional album filters (#66)
|
||||
- Genres (AND/OR)
|
||||
- Artists (AND/OR)
|
||||
- Years (FROM/TO)
|
||||
- Added external column sort filters for multiple pages (#66)
|
||||
- Added item counter to page titles
|
||||
- `Play Count` column has been added to albums (only works for Navidrome)
|
||||
|
||||
### Changed
|
||||
|
||||
- Config page has been fully refreshed to a new look
|
||||
- Config popover on the action bar now includes all config tabs
|
||||
- Tooltips
|
||||
- Increased default tooltip delay from 250ms -> 500ms
|
||||
- Increased tooltip delay on card overlay buttons to 1000ms
|
||||
- Grid view
|
||||
- Placeholder images for playlists, albums, and artists have been updated (inspired from Jellyfin Web UI)
|
||||
- Card title/subtitle width decreased from 100% to default length
|
||||
- Separate card info section from image/overlay buttons on hover
|
||||
- Popovers (config, auto playlist, etc)
|
||||
- Now have decreased opacity
|
||||
- Enabling/disabling global media keys no longer requires app restart
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Jellyfin) Fixed `Recently Played` and `Most Played` filters on the Dashboard page (#114)
|
||||
- (Jellyfin) Fixed server scrobble (#126)
|
||||
- No longer sends the `/playing` request on song start (prevents song being marked as played when it starts)
|
||||
- Fixed song play count increasing multiple times per play
|
||||
- (Jellyfin) Fixed tracks without embedded art displaying placeholder (#128)
|
||||
- (Jellyfin) Fixed song `Path` property not displaying data
|
||||
- (Subsonic) Fixed login check for Funkwhale servers (#135)
|
||||
- Fixed persistent grid-view scroll position
|
||||
- Fixed list-view columns
|
||||
- `Visibility` column now properly displays data
|
||||
- Selected media folder is now cleared from settings on disconnect (prevents errors when signing into a new server)
|
||||
- Fixed adding/removing artist as favorite on the Artist page not updating
|
||||
- Fixed search bar not properly handling Asian keyboard inputs
|
||||
|
||||
## [0.9.1] - 2021-12-07
|
||||
|
||||
### Changed
|
||||
|
||||
- List-view scroll position is now persistent for the following:
|
||||
- Now Playing
|
||||
- Playlist list
|
||||
- Favorites (all)
|
||||
- Album list
|
||||
- Artist list
|
||||
- Genre list
|
||||
- Grid-view scroll position is now persistent for the following:
|
||||
- Playlist list
|
||||
- Favorites (album/artist)
|
||||
- Album list
|
||||
- Artist list
|
||||
- (Jellyfin) Changed audio stream URL to force transcoding off (#108)
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Jellyfin) Fixed the player not sending the "finish" condition when the song meets the scrobble condition (unresolved from 0.9.0) (#111)
|
||||
|
||||
## [0.9.0] - 2021-12-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added 2 new default themes
|
||||
- Plex-like
|
||||
- Spotify-like
|
||||
- Added volume control improvements
|
||||
- Volume value tooltip while hovering the slider
|
||||
- Mouse scroll wheel controls volume while hovering the slider
|
||||
- Clicking the volume icon will mute/unmute
|
||||
|
||||
### Changed
|
||||
|
||||
- Overhauled all default themes
|
||||
- Rounded buttons, inputs, etc.
|
||||
- Changed grid card hover effects
|
||||
- Removed hover scale
|
||||
- Removed default background on overlay buttons
|
||||
- Moved border to only the image instead of full card
|
||||
- Album page
|
||||
- Genre(s) are now listed on a line separate from the artists
|
||||
- Album artist is now distinct from track artists
|
||||
- Increased length of the genre/artist line from 70% -> 80%
|
||||
- The genre/artist line is now scrollable using the mouse wheel
|
||||
- (Jellyfin) List view
|
||||
- `Artist` column now uses the album artist property
|
||||
- `Title (Combined)` column now displays all track artists, comma-delimited instead of the album artist
|
||||
- `Genre` column now displays all genres, comma-delimited, left-aligned
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Jellyfin) Fixed the player not sending the "finish" condition when the song meets the scrobble condition
|
||||
- (Jellyfin) Fixed album lists not sorting by the `genre` column
|
||||
- (Jellyfin)(API) Fixed the A-Z(Artist) not sorting by Album Artist on the album list
|
||||
- (Jellyfin)(API) Fixed auto playlist not respecting the selected music folder
|
||||
- (Jellyfin)(API) Fixed the artist page not respecting the selected music folder
|
||||
|
||||
## [0.8.5] - 2021-11-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed default (OOBE) title column not display data (#104)
|
||||
|
||||
## [0.8.4] - 2021-11-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Jellyfin)(Linux) Fixed JS MPRIS error when switching tracks due to unrounded song duration
|
||||
- (Linux) Fixed MPRIS artist, genre, and coverart not updating on track change
|
||||
|
||||
## [0.8.3] - 2021-11-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Subsonic) Fixed playing a folder from the folder view
|
||||
- Fixed rating context menu option available from the Genre page
|
||||
|
||||
## [0.8.2] - 2021-11-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added option to disable auto updates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed gapless playback on certain \*sonic servers (#100)
|
||||
- Fixed playerbar coverart not redirecting to `Now Playing` page
|
||||
|
||||
## [0.8.1] - 2021-11-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Subsonic) Fixed errors blocking playlists from being deleted
|
||||
|
||||
## [0.8.0] - 2021-11-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added Jellyfin server support (#87)
|
||||
- Supports full Sonixd feature-set (except ratings)
|
||||
- Added a mini config popover to change list/grid view options on the top action bar
|
||||
- Added system audio device selector (#96)
|
||||
- Added context menu option `Set rating` to bulk set ratings for songs (and albums/artists on Navidrome) (#95)
|
||||
|
||||
### Changed
|
||||
|
||||
- Reduced cached image from 500px -> 350px (to match max grid size)
|
||||
- Grid/header images now respect image aspect ratio returned by the server
|
||||
- Playback filter input now uses a regex validation before allowing you to add
|
||||
- Renamed all `Name` columns to `Title`
|
||||
- Search bar now clears after pressing enter to globally search
|
||||
- Added borders to popovers
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed application performance issues when player is crossfading to the next track
|
||||
- Fixed null entries showing at the beginning of descending sort on playlist/now playing lists
|
||||
- Tooltips no longer pop up on the artist/playlist description when null
|
||||
|
||||
## [0.7.0] - 2021-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added download buttons on the Album and Artist pages (#29)
|
||||
- Allows you to download (via browser) or copy download links to your clipboard (to use with a download manager)
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed default tooltip delay from `500ms` -> `250ms`
|
||||
- Moved search bar from page header to the main layout action bar
|
||||
- Added notice for macOS media keys to require trusted accessibility in the client
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed auto playlist and album fetch in Gonic servers
|
||||
- Fixed the macOS titlebar styling to better match the original (#83)
|
||||
- Fixed thumbnailclip error when resizing the application in macOS (#84)
|
||||
- Fixed playlist page not using cached image
|
||||
|
||||
## [0.6.0] - 2021-11-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added additional grid-view customization options (#74)
|
||||
- Gap size (spaces between cards)
|
||||
- Alignment (left-align, center-align)
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed default album/artist uncached image sizes from `150px` -> `350px`
|
||||
|
||||
### Fixed
|
||||
|
||||
- (Windows) Fixed default taskbar thumbnail on Windows10 when minimized to use window instead of album cover (#73)
|
||||
- Fixed playback settings unable to change via the UI
|
||||
- Crossfade duration
|
||||
- Polling interval
|
||||
- Volume fade
|
||||
- Fixed header styling on the Config page breaking at smaller window widths (#72)
|
||||
- Fixed the position of the description tooltip on the Artist page
|
||||
- Fixed the `Add to playlist` popover showing underneath the modal in modal-view
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed unused `fonts.size.pageTitle` theme property
|
||||
|
||||
## [0.5.0] - 2021-11-05
|
||||
|
||||
### Added
|
||||
|
||||
- Added extensible theming (#60)
|
||||
- Added playback presets (gapless, fade, normal) to the config
|
||||
- Added persistence for column sort for all list-views (except playlist and search) (#47)
|
||||
- Added playback filters to the config to filter out songs based on regex (#53)
|
||||
- Added music folder selector in auto playlist (this may or may not work depending on your server)
|
||||
- Added improved playlist, artist, and album pages
|
||||
- Added dynamic images on the Playlist page for servers that don't support playlist images (e.g. Navidrome)
|
||||
- Added link to open the local `settings.json` file
|
||||
- Added setting to use legacy authentication (#63)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved overall application keyboard accessibility
|
||||
- Playback no longer automatically starts if adding songs to the queue using `Add to queue`
|
||||
- Prevent accidental page navigation when using [Ctrl/Shift + Click] when multi-selecting rows in list-view
|
||||
- Standardized buttons between the Now Playing page and the mini player
|
||||
- "Add random" renamed to "Auto playlist"
|
||||
- Increased 'info' notification timeout from 1500ms -> 2000ms
|
||||
- Changed default mini player columns to better fit
|
||||
- Updated default themes to more modern standards (Default Dark, Default Light)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed title sort on the `Title (Combined)` column on the album list
|
||||
- Fixed 2nd song in queue being skipped when using the "Play" button multiple pages (album, artist, auto playlist)
|
||||
- Fixed `Title` column not showing the title on the Folder page (#69)
|
||||
- Fixed context menu windows showing underneath the mini player
|
||||
- Fixed `Add to queue (next)` adding songs to the wrong unshuffled index when shuffle is enabled
|
||||
- Fixed local search on the root Folder page
|
||||
- Fixed input picker dropdowns following the page on scroll
|
||||
- Fixed the current playing song not highlighted when using `Add to queue` on an empty play queue
|
||||
- Fixed artist list not using the `artistImageUrl` returned by Navidrome
|
||||
|
||||
## [0.4.1] - 2021-10-27
|
||||
|
||||
### Added
|
||||
|
||||
- Added links to the genre column on the list-view
|
||||
- Added page forward/back buttons to main layout
|
||||
|
||||
### Changed
|
||||
|
||||
- Increase delay when completing mouse drag select in list view from `100ms` -> `200ms`
|
||||
- Change casing for main application name `sonixd` -> `Sonixd`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Linux media hotkey support (MPRIS)
|
||||
- Added commands for additional events `play` and `pause` (used by KDE's media player overlay)
|
||||
- Set status to `Playing` when initially starting a song
|
||||
- Set current song metadata when track automatically changes instead of only when it manually changes
|
||||
- Fixed filtered link to Album List on the Album page
|
||||
- Fixed filtered link to Album List on the Dashboard page
|
||||
- Fixed font color for lists/tables in panels
|
||||
- Affects the search view song list and column selector list
|
||||
|
||||
## [0.4.0] - 2021-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added music folder selector (#52)
|
||||
- Added media hotkeys / MPRIS support for Linux (#50)
|
||||
- This is due to dbus overriding the global shortcuts that electron sends
|
||||
- Added advanced column selector component
|
||||
- Drag-n-drop list
|
||||
- Individual resizable columns
|
||||
- (Windows) Added tray (Thanks @ncarmic4) (#45)
|
||||
- Settings to minimize/exit to tray
|
||||
|
||||
### Changed
|
||||
|
||||
- Page selections are now persistent
|
||||
- Active tab on config page
|
||||
- Active tab on favorites page
|
||||
- Filter selector on album list page
|
||||
- Playlists can now be saved after being sorted using column filters
|
||||
- Folder view
|
||||
- Now shows all root folders in the list instead of in the input picker
|
||||
- Now shows music folders in the input picker
|
||||
- Now uses loader when switching pages
|
||||
- Changed styling for various views/components
|
||||
- Look & Feel setting page now split up into multiple panels
|
||||
- Renamed context menu button `Remove from current` -> `Remove selected`
|
||||
- Page header titles width increased from `45%` -> `80%`
|
||||
- Renamed `Scan library` -> `Scan`
|
||||
- All pages no longer refetch data when clicking back into the application
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed shift-click multi select on a column-sorted list-view
|
||||
- Fixed right-click context menu showing up behind all modals (#55)
|
||||
- Fixed mini player showing up behind tag picker elements
|
||||
- Fixed duration showing up as `NaN:NaN` when duration is null or invalid
|
||||
- Fixed albums showing as a folder in Navidrome instances
|
||||
|
||||
## [0.3.0] - 2021-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added folder browser (#1)
|
||||
- Added context menu button `View in folder`
|
||||
- Requires that your server has support for the original `/getIndexes` and `/getMusicDirectory` endpoints
|
||||
- Added configurable row-hover highlight for list-view
|
||||
- (Windows) Added playback controls in thumbnail toolbar (#32)
|
||||
- (Windows/macOS) Added window size/position remembering on application close (#31)
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed styling for various views/components
|
||||
- Tooltips added on grid-view card hover buttons
|
||||
- Mini-player removed rounded borders and increased opacity
|
||||
- Mini-player removed animation on open/close
|
||||
- Search bar now activated from button -> input on click / CTRL+F
|
||||
- Page header toolbar buttons styling consistency
|
||||
- Album list filter moved from right -> left
|
||||
- Reordered context menu button `Move selected to [...]`
|
||||
- Decreased horizontal width of expanded sidebar from 193px -> 165px
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate scrobble requests when pause/resuming a song after the scrobble threshold (#30)
|
||||
- Fixed genre column not applying in the song list-view
|
||||
- Fixed default titlebar set on first run
|
||||
|
||||
## [0.2.1] - 2021-10-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed using play buttons on the artist view not starting playback
|
||||
- Fixed favoriting on horizontal scroll menu on dashboard/search views
|
||||
- Fixed typo on default artist list viewtype
|
||||
- Fixed artist image selection on artist view
|
||||
|
||||
## [0.2.0] - 2021-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- Added setting to enable scrobbling playing/played tracks to your server (#17)
|
||||
- Added setting to change between macOS and Windows styled titlebar (#23)
|
||||
- Added app/build versions and update checker on the config page (#18)
|
||||
- Added 'view in modal' button on the list-view context menu (#8)
|
||||
- Added a persistent indicator on grid-view cards for favorited albums/artists (#7)
|
||||
- Added buttons for 'Add to queue (next)' and 'Add to queue (later)' (#6)
|
||||
- Added left/right scroll buttons to the horizontal scrolling menu (dashboard/search)
|
||||
- Added last.fm link to artist page
|
||||
- Added link to cache location to open in local file explorer
|
||||
- Added reset to default for cache location
|
||||
- Added additional tooltips
|
||||
- Grid-view card title and subtitle buttons
|
||||
- Cover art on the player bar
|
||||
- Header titles on album/artist pages
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed starring logic on grid-view card to update local cache instead of refetch
|
||||
- Changed styling for various views/components
|
||||
- Use dynamically sized hover buttons on grid-view cards depending on the card size
|
||||
- Decreased size of buttons on album/playlist/artist pages
|
||||
- Input picker text color changed from primary theme color to primary text color
|
||||
- Crossfade type config changed from radio buttons to input picker
|
||||
- Disconnect button color from red to default
|
||||
- Tooltip styling updated to better match default theme
|
||||
- Changed tag links to text links on album page
|
||||
- Changed page header images to use cache (album/artist)
|
||||
- Artist image now falls back to last.fm if no local image
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed song & image caching (#16)
|
||||
- Fixed set default artist list view type on first startup
|
||||
|
||||
## [0.1.0] - 2021-10-06
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Stage 1 - Build frontend
|
||||
FROM node:16.5-alpine as ui-builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm install
|
||||
RUN npm run build:renderer
|
||||
RUN npm prune --production
|
||||
RUN npm cache clean --force
|
||||
RUN rm -rf /root/.cache
|
||||
|
||||
# Stage 2 - Build server
|
||||
FROM node:16.5-alpine as server-builder
|
||||
WORKDIR /app
|
||||
COPY server .
|
||||
RUN npm install && npx prisma generate
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
RUN npm cache clean --force
|
||||
RUN rm -rf /root/.cache
|
||||
|
||||
|
||||
# Stage 3 - Deploy
|
||||
FROM node:16.5-alpine
|
||||
WORKDIR /root
|
||||
RUN mkdir appdata
|
||||
RUN mkdir feishin-server
|
||||
RUN mkdir feishin-client
|
||||
|
||||
RUN npm cache clean --force
|
||||
RUN npm prune --production
|
||||
|
||||
# Add server build files
|
||||
COPY --from=server-builder /app/dist ./feishin-server
|
||||
COPY --from=server-builder /app/node_modules ./feishin-server/node_modules
|
||||
COPY --from=server-builder /app/prisma ./feishin-server/prisma
|
||||
|
||||
# Add client build files
|
||||
COPY --from=ui-builder /app/release/app/dist/renderer ./feishin-client
|
||||
|
||||
COPY docker-entrypoint.sh ./feishin-server/docker-entrypoint.sh
|
||||
RUN chmod +x ./feishin-server/docker-entrypoint.sh
|
||||
|
||||
COPY ./server/wait-for-it.sh ./feishin-server/wait-for-it.sh
|
||||
RUN chmod +x ./feishin-server/wait-for-it.sh
|
||||
|
||||
RUN npm install pm2 -g
|
||||
|
||||
WORKDIR /root/feishin-server
|
||||
|
||||
EXPOSE 9321
|
||||
CMD ["sh", "docker-entrypoint.sh"]
|
||||
@@ -5,7 +5,7 @@
|
||||
<img src="https://img.shields.io/github/license/jeffvli/feishin?style=flat-square&color=brightgreen"
|
||||
alt="License">
|
||||
</a>
|
||||
<a href="https://github.com/jeffvli/feishin/releases">
|
||||
<a href="https://github.com/jeffvli/feishin/releases">
|
||||
<img src="https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue"
|
||||
alt="Release">
|
||||
</a>
|
||||
@@ -13,6 +13,15 @@
|
||||
<img src="https://img.shields.io/github/downloads/jeffvli/feishin/total?style=flat-square&color=orange"
|
||||
alt="Downloads">
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/jeffvictorli/feishin">
|
||||
<img src="https://img.shields.io/docker/v/jeffvictorli/feishin?style=flat-square&color=orange"
|
||||
alt="Docker">
|
||||
</a>
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/jeffvictorli/feishin">
|
||||
<img src="https://img.shields.io/docker/pulls/jeffvictorli/feishin?style=flat-square&color=orange"
|
||||
alt="Docker pulls">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/FVKpcMDy5f">
|
||||
@@ -25,46 +34,99 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
|
||||
## Features
|
||||
|
||||
- [x] MPV player backend
|
||||
- [x] Web player backend
|
||||
- [x] Modern UI
|
||||
- [x] Scrobble playback to your server
|
||||
- [x] Smart playlist editor (Navidrome)
|
||||
- [x] Synchronized and unsynchronized lyrics support
|
||||
- [ ] [Request a feature](https://github.com/jeffvli/feishin/issues) or [view taskboard](https://github.com/users/jeffvli/projects/5/views/1)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_full_screen_player.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_artist_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_album_detail.png" width="49.5%"/></a> <a href="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png"><img src="https://raw.githubusercontent.com/jeffvli/feishin/development/media/preview_smart_playlist.png" width="49.5%"/></a>
|
||||
Repository for the rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||
|
||||
## Getting Started
|
||||
|
||||
The default credentials to login will be `admin/admin`.
|
||||
|
||||
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
|
||||
|
||||
If you're using an M1 macOS device, [check here](https://github.com/jeffvli/feishin/issues/104#issuecomment-1553914730) for instructions on how to remove the app from quarantine.
|
||||
### Docker Compose
|
||||
|
||||
### Configuration
|
||||
**Warning:** Check the environment variable configuration before running the commands below.
|
||||
|
||||
1. Upon startup you will be greeted with a prompt to select the path to your MPV binary. If you do not have MPV installed, you can download it [here](https://mpv.io/installation/) or install it using any package manager supported by your OS. After inputting the path, restart the app.
|
||||
1. Copy and rename [example.env](https://github.com/jeffvli/feishin/blob/dev/example.env) to `.env` and make any changes necessary
|
||||
2. Run the compose file: `docker compose --file docker-compose.yml --env-file .env up`
|
||||
|
||||
2. After restarting the app, you will be prompted to select a server. Click the `Open menu` button and select `Manage servers`. Click the `Add server` button in the popup and fill out all applicable details. You will need to enter the full URL to your server, including the protocol and port if applicable (e.g. `https://navidrome.my-server.com` or `http://192.168.0.1:4533`).
|
||||
### Docker
|
||||
|
||||
- **Navidrome** - For the best experience, select "Save password" when creating the server and configure the `SessionTimeout` setting in your Navidrome config to a larger value (e.g. 72h).
|
||||
**Warning:** Check the environment variable configuration before running the commands below.
|
||||
|
||||
**Run a postgres database container:**
|
||||
|
||||
```
|
||||
docker run postgres:13 \
|
||||
-p 5432:5432 \
|
||||
-e POSTGRES_USER=admin \
|
||||
-e POSTGRES_PASSWORD=admin \
|
||||
-e POSTGRES_DB=feishin
|
||||
```
|
||||
|
||||
**Run the Feishin server container:**
|
||||
|
||||
```
|
||||
docker run jeffvictorli/feishin:latest \
|
||||
-p 8643:9321 \
|
||||
-e APP_BASE_URL=http://192.168.0.1:8643 \
|
||||
-e DATABASE_PORT=5432 \
|
||||
-e DATABASE_URL=postgresql://admin:admin@localhost:5432/feishin?schema=public \
|
||||
-e TOKEN_SECRET=secret
|
||||
```
|
||||
|
||||
**Docker Environment Variables**
|
||||
|
||||
```
|
||||
APP_BASE_URL — The URL the site will be accessible at from your server (needed for CORS)
|
||||
|
||||
DATABASE_PORT — The port of your running postgres container
|
||||
|
||||
DATABASE_URL — The connection string to your postgres instance following this format: postgresql://<DB_USERNAME>:<DB_PASSWORD>@<DB_URL>/<DB_NAME>?schema=public
|
||||
|
||||
Replace the following:
|
||||
<DB_USERNAME> — The admin username of your postgres container (POSTGRES_USER)
|
||||
<DB_PASSWORD> — The admin password of your postgres container (POSTGRES_PASSWORD)
|
||||
<DB_NAME> — The name of the database created in your postgres container (POSTGRES_DB)
|
||||
<DB_URL> — The URL the postgres container is reachable from
|
||||
|
||||
Example: postgresql://admin:password@192.168.0.1:5432/feishin?schema=public
|
||||
|
||||
TOKEN_SECRET — The string used to sign auth tokens
|
||||
|
||||
(optional) TOKEN_EXPIRATION — The time before the auth JWT expires
|
||||
|
||||
(optional) TOKEN_REFRESH_EXPIRATION - The time before the auth JWT refresh token expires
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
### Why is there a red lock next to the server I want to select?
|
||||
|
||||
If the server is specified to "require user credentials", you will need to add and enable your own credentials to access it. Since the songs and images aren't proxied by the Feishin backend, the server credentials would otherwise be leaked to any user that has access to it. The added credentials are stored locally in the browser and are then used to generate the audio and image URLs in the client.
|
||||
|
||||
### 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. **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).
|
||||
Feishin supports any music server that implements a [Subsonic](http://www.subsonic.org/pages/api.jsp), [Navidrome](https://www.navidrome.org/), or [Jellyfin](https://jellyfin.org/) API.
|
||||
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||
- [Funkwhale](https://funkwhale.audio/) - TBD
|
||||
- Subsonic-compatible servers - TBD
|
||||
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||
- [Airsonic](https://github.com/airsonic/airsonic)
|
||||
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||
- [Gonic](https://github.com/sentriz/gonic)
|
||||
- [Astiga](https://asti.ga/)
|
||||
- [Supysonic](https://github.com/spl0k/supysonic)
|
||||
|
||||
### Why does Feishin use its own database and backend instead of just use (insert music server)'s API?
|
||||
|
||||
Feishin was an idea I had after I ran into usage limitations while building out [Sonixd](https://github.com/jeffvli/sonixd). Each music server has their own quirks, and I decided I wanted to consolidate and extend their features with my own backend implemntation which includes: web/desktop clients, advanced filtering, smart playlists, desktop MPV player, and more.
|
||||
|
||||
### Can I use (insert database) instead of Postgresql?
|
||||
|
||||
Due to [Prisma limitations](https://www.prisma.io/docs/concepts/components/prisma-migrate/prisma-migrate-limitations-issues#you-cannot-automatically-switch-database-providers), there is no easy way to switch to a different database provider at this time.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -72,6 +134,52 @@ Built and tested using Node `v16.15.0`.
|
||||
|
||||
This project is built off of [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) v4.6.0.
|
||||
|
||||
### Developing with Docker Compose
|
||||
|
||||
1. Copy and rename the `example.env` to `.env.dev` and make any changes necessary
|
||||
2. **Run the server**: Use `npm run docker:up` to build and run the dev server
|
||||
1. Prisma studio available on `http://localhost:5555`
|
||||
2. Server available on `http://localhost:8643`
|
||||
3. Default seeded login credentials are `admin/admin`
|
||||
3. **Run the client**: Use `npm run start` to run the development Electron client
|
||||
1. The web version of the client is available on `http://localhost:4343`
|
||||
|
||||
**Docker Compose files**
|
||||
|
||||
```
|
||||
docker-compose.yml — The public compose file for running the latest release
|
||||
|
||||
docker-compose.dev.yml - Build and run the development environment locally (includes Prisma studio)
|
||||
|
||||
docker-compose.prod.yml - Build and run the production environment locally
|
||||
```
|
||||
|
||||
### NPM Scripts:
|
||||
|
||||
```
|
||||
$ npm run package — Packages the application for the local system
|
||||
|
||||
$ npm run start — Runs the development Electron and web client
|
||||
|
||||
$ npm run start:web — Runs the development web client
|
||||
|
||||
$ npm run docker:up — Builds and starts the docker development environment using the 'docker-compose.dev.yml' file
|
||||
|
||||
$ npm run docker:down — Stops the running docker development environment
|
||||
|
||||
$ npm run docker:dbpush — Pushes any schema changes made in 'schema.prisma' to the docker development database without migrating
|
||||
|
||||
$ npm run docker:migrate - Migrates any schema changes made in 'schema.prisma' and creates a migration file
|
||||
|
||||
$ npm run docker:createmigrate - Creates a migration file for any schema changes made in 'schema.prisma' without applying the migration
|
||||
|
||||
$ npm run docker:reset - Resets the docker development database and applies the default seed
|
||||
|
||||
$ npm run prod:buildserver - Builds and tags the server docker images locally with the 'latest' and '$VERSION' tags
|
||||
|
||||
$ npm run prod:publishserver - Pushes the locally build server docker images to docker hub
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0 ©](https://github.com/jeffvli/feishin/blob/dev/LICENSE)
|
||||
[GNU General Public License v3.0 ©](https://github.com/jeffvli/sonixd-rewrite/blob/dev/LICENSE)
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 896 B |
|
Before Width: | Height: | Size: 971 B |
|
Before Width: | Height: | Size: 479 B |
|
Before Width: | Height: | Size: 524 B |
@@ -0,0 +1,49 @@
|
||||
version: '3'
|
||||
services:
|
||||
db:
|
||||
container_name: feishin_db
|
||||
image: postgres:13
|
||||
volumes:
|
||||
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=${DATABASE_USERNAME}
|
||||
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
|
||||
- POSTGRES_DB=${DATABASE_NAME}
|
||||
ports:
|
||||
- '${DATABASE_PORT}:5432'
|
||||
restart: unless-stopped
|
||||
server:
|
||||
container_name: feishin_server
|
||||
volumes:
|
||||
- ./server:/app # Synchronise docker container with local change
|
||||
- /app/node_modules # Avoid re-copying local node_modules. Cache in container.
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- APP_BASE_URL=${APP_BASE_URL}
|
||||
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
|
||||
- DATABASE_PORT=${DATABASE_PORT}
|
||||
- TOKEN_SECRET=${TOKEN_SECRET}
|
||||
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
|
||||
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
|
||||
ports:
|
||||
- '8643:9321'
|
||||
restart: unless-stopped
|
||||
prisma:
|
||||
container_name: feishin_prisma_studio
|
||||
volumes:
|
||||
- ./server/prisma:/app/prisma
|
||||
build:
|
||||
context: ./server/prisma
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- db
|
||||
- server
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public
|
||||
ports:
|
||||
- '5555:5555'
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,32 @@
|
||||
version: '3'
|
||||
services:
|
||||
db:
|
||||
container_name: feishin_db
|
||||
image: postgres:13
|
||||
volumes:
|
||||
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=${DATABASE_USERNAME}
|
||||
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
|
||||
- POSTGRES_DB=${DATABASE_NAME}
|
||||
ports:
|
||||
- '${DATABASE_PORT}:5432'
|
||||
restart: unless-stopped
|
||||
server:
|
||||
container_name: feishin
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: feishin
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- APP_BASE_URL=${APP_BASE_URL}
|
||||
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
|
||||
- DATABASE_PORT=${DATABASE_PORT}
|
||||
- TOKEN_SECRET=${TOKEN_SECRET}
|
||||
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
|
||||
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
|
||||
ports:
|
||||
- '8643:9321'
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,29 @@
|
||||
version: '3'
|
||||
services:
|
||||
db:
|
||||
container_name: feishin_db
|
||||
image: postgres:13
|
||||
volumes:
|
||||
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=${DATABASE_USERNAME}
|
||||
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
|
||||
- POSTGRES_DB=${DATABASE_NAME}
|
||||
ports:
|
||||
- '${DATABASE_PORT}:5432'
|
||||
restart: unless-stopped
|
||||
server:
|
||||
container_name: feishin
|
||||
image: jeffvictorli/feishin:latest
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- APP_BASE_URL=${APP_BASE_URL}
|
||||
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
|
||||
- DATABASE_PORT=${DATABASE_PORT}
|
||||
- TOKEN_SECRET=${TOKEN_SECRET}
|
||||
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
|
||||
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
|
||||
ports:
|
||||
- '8643:9321'
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,5 @@
|
||||
./wait-for-it.sh db:$1 --timeout=20 --strict -- echo "db is up"
|
||||
|
||||
npx prisma migrate deploy
|
||||
npx ts-node prisma/seed.ts
|
||||
pm2-runtime server.js
|
||||
@@ -0,0 +1,9 @@
|
||||
DATABASE_USERNAME=admin
|
||||
DATABASE_PASSWORD=admin
|
||||
DATABASE_NAME=feishin
|
||||
DATABASE_PORT=5432
|
||||
DATABASE_PERSIST_PATH=C:/docker/feishin/db
|
||||
TOKEN_SECRET=SUPERSECRET
|
||||
TOKEN_EXPIRATION=30m
|
||||
TOKEN_REFRESH_EXPIRATION=90d
|
||||
APP_BASE_URL=http://localhost:8643
|
||||
|
Before Width: | Height: | Size: 644 KiB |
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 887 KiB |
|
Before Width: | Height: | Size: 396 KiB |
@@ -2,7 +2,7 @@
|
||||
"name": "feishin",
|
||||
"productName": "Feishin",
|
||||
"description": "Feishin music server",
|
||||
"version": "0.2.0",
|
||||
"version": "0.0.1-alpha1",
|
||||
"scripts": {
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||
@@ -11,8 +11,6 @@
|
||||
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:styles": "npx stylelint **/*.tsx",
|
||||
"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",
|
||||
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
|
||||
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
|
||||
"start:main": "cross-env NODE_ENV=development electron -r ts-node/register/transpile-only ./src/main/main.ts",
|
||||
@@ -23,7 +21,13 @@
|
||||
"prepare": "husky install",
|
||||
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
|
||||
"prod:buildserver": "pwsh -c \"./scripts/server-build.ps1\"",
|
||||
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\""
|
||||
"prod:publishserver": "pwsh -c \"./scripts/server-publish.ps1\"",
|
||||
"docker:up": "docker compose --file docker-compose.dev.yml --env-file .env.dev up --detach && docker compose --file docker-compose.dev.yml --env-file .env.dev logs -f",
|
||||
"docker:down": "docker compose --file docker-compose.dev.yml --env-file .env.dev down && docker image rm feishin_prisma",
|
||||
"docker:dbpush": "cd server && npx prisma generate && docker exec -ti feishin_server sh -c \"npx prisma generate && npx prisma db push\"",
|
||||
"docker:migrate": "cd server && npx prisma generate && docker exec -ti feishin_server sh -c \"npx prisma migrate dev\"",
|
||||
"docker:createmigrate": "cd server && npx prisma generate && docker exec -ti feishin_server sh -c \"npx prisma migrate dev --create-only\"",
|
||||
"docker:reset": "docker exec -ti feishin_server sh -c \"npx prisma migrate reset && npx prisma db push && npx ts-node prisma/seed.ts\""
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
@@ -51,7 +55,6 @@
|
||||
"package.json"
|
||||
],
|
||||
"afterSign": ".erb/scripts/notarize.js",
|
||||
"electronVersion": "22.3.1",
|
||||
"mac": {
|
||||
"target": {
|
||||
"target": "default",
|
||||
@@ -159,7 +162,6 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.2.10",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
|
||||
"@stylelint/postcss-css-in-js": "^0.38.0",
|
||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
||||
@@ -172,6 +174,7 @@
|
||||
"@types/node": "^17.0.23",
|
||||
"@types/react": "^18.0.25",
|
||||
"@types/react-dom": "^18.0.8",
|
||||
"@types/react-slider": "^1.3.1",
|
||||
"@types/react-test-renderer": "^17.0.1",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
@@ -180,8 +183,8 @@
|
||||
"@types/terser-webpack-plugin": "^5.0.4",
|
||||
"@types/webpack-bundle-analyzer": "^4.4.1",
|
||||
"@types/webpack-env": "^1.16.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.47.0",
|
||||
"@typescript-eslint/parser": "^5.47.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||
"@typescript-eslint/parser": "^5.18.0",
|
||||
"browserslist-config-erb": "^0.0.3",
|
||||
"chalk": "^4.1.2",
|
||||
"concurrently": "^7.1.0",
|
||||
@@ -190,12 +193,13 @@
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"detect-port": "^1.3.0",
|
||||
"electron": "^22.3.1",
|
||||
"electron-builder": "^24.0.0-alpha.13",
|
||||
"electron": "^21.2.0",
|
||||
"electron-builder": "^23.0.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-notarize": "^1.2.1",
|
||||
"electron-rebuild": "^3.2.7",
|
||||
"electronmon": "^2.0.2",
|
||||
"eslint": "^8.30.0",
|
||||
"eslint": "^8.12.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-erb": "^4.0.3",
|
||||
"eslint-import-resolver-typescript": "^2.7.1",
|
||||
@@ -238,7 +242,7 @@
|
||||
"ts-loader": "^9.2.8",
|
||||
"ts-node": "^10.7.0",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"typescript": "^4.8.4",
|
||||
"typescript": "^4.6.4",
|
||||
"typescript-plugin-styled-components": "^2.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^5.71.0",
|
||||
@@ -248,60 +252,55 @@
|
||||
"webpack-merge": "^5.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-grid-community/client-side-row-model": "^28.2.1",
|
||||
"@ag-grid-community/core": "^28.2.1",
|
||||
"@ag-grid-community/infinite-row-model": "^28.2.1",
|
||||
"@ag-grid-community/react": "^28.2.1",
|
||||
"@ag-grid-community/styles": "^28.2.1",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@mantine/core": "^6.0.13",
|
||||
"@mantine/dates": "^6.0.13",
|
||||
"@mantine/form": "^6.0.13",
|
||||
"@mantine/hooks": "^6.0.13",
|
||||
"@mantine/modals": "^6.0.13",
|
||||
"@mantine/notifications": "^6.0.13",
|
||||
"@mantine/utils": "^6.0.13",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"@tanstack/react-query-devtools": "^4.29.6",
|
||||
"@ts-rest/core": "^3.23.0",
|
||||
"axios": "^1.4.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"@jellyfin/client-axios": "^10.7.8",
|
||||
"@mantine/core": "^5.8.0",
|
||||
"@mantine/dates": "^5.8.0",
|
||||
"@mantine/dropzone": "^5.8.0",
|
||||
"@mantine/form": "^5.8.0",
|
||||
"@mantine/hooks": "^5.8.0",
|
||||
"@mantine/modals": "^5.8.0",
|
||||
"@mantine/notifications": "^5.8.0",
|
||||
"@mantine/spotlight": "^5.8.0",
|
||||
"@tanstack/react-query": "^4.16.1",
|
||||
"@tanstack/react-query-devtools": "^4.16.1",
|
||||
"ag-grid-community": "^28.2.1",
|
||||
"ag-grid-react": "^28.2.1",
|
||||
"axios": "^0.27.2",
|
||||
"dayjs": "^1.11.6",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-localshortcut": "^3.2.1",
|
||||
"electron-log": "^4.4.6",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^4.6.5",
|
||||
"fast-average-color": "^9.3.0",
|
||||
"format-duration": "^2.0.0",
|
||||
"framer-motion": "^9.1.7",
|
||||
"fuse.js": "^6.6.2",
|
||||
"framer-motion": "^6.4.2",
|
||||
"history": "^5.3.0",
|
||||
"i18next": "^21.6.16",
|
||||
"immer": "^9.0.21",
|
||||
"is-electron": "^2.2.2",
|
||||
"immer": "^9.0.15",
|
||||
"is-electron": "^2.2.1",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"nanoid": "^3.3.3",
|
||||
"net": "^1.0.2",
|
||||
"node-mpv": "github:jeffvli/Node-MPV",
|
||||
"node-mpv": "^2.0.0-beta.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-i18next": "^11.16.7",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-icons": "^4.6.0",
|
||||
"react-player": "^2.11.0",
|
||||
"react-router": "^6.5.0",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-router": "^6.3.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-simple-img": "^3.0.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.17",
|
||||
"react-window": "^1.8.9",
|
||||
"react-window-infinite-loader": "^1.0.9",
|
||||
"styled-components": "^5.3.11",
|
||||
"swiper": "^9.3.1",
|
||||
"zod": "^3.21.4",
|
||||
"zustand": "^4.3.8"
|
||||
"react-slider": "^2.0.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.6",
|
||||
"react-window": "^1.8.8",
|
||||
"react-window-infinite-loader": "^1.0.8",
|
||||
"socket.io-client": "^4.5.3",
|
||||
"styled-components": "^5.3.6",
|
||||
"zod": "^3.19.1",
|
||||
"zustand": "^4.1.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"styled-components": "^5"
|
||||
@@ -311,6 +310,20 @@
|
||||
"npm": ">=7.x"
|
||||
},
|
||||
"browserslist": [],
|
||||
"prettier": {
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
".prettierrc",
|
||||
".eslintrc"
|
||||
],
|
||||
"options": {
|
||||
"parser": "json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"singleQuote": true
|
||||
},
|
||||
"electronmon": {
|
||||
"patterns": [
|
||||
"!server",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.0.1-alpha1",
|
||||
"description": "",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -12,12 +12,6 @@
|
||||
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
|
||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"mpris-service": "^2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "22.3.1"
|
||||
},
|
||||
"license": "GPL-3.0"
|
||||
"dependencies": {},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
$repositoryRootDirectory = Join-Path -Path Get-Location -ChildPath '..'
|
||||
$packageJson = Get-Content -Path (Join-Path -Path $repositoryRootDirectory -ChildPath 'package.json') | ConvertFrom-Json
|
||||
|
||||
if (!packageJson.version) {
|
||||
throw 'package.json does not contain a version'
|
||||
}
|
||||
|
||||
$version = $packageJson.version
|
||||
$appName = $packageJson.name
|
||||
$dockerRepo = 'jeffvictorli'
|
||||
|
||||
|
||||
Write-Host "Building [${appname}:latest] & [${appName}:${version}]"
|
||||
|
||||
docker build -t "${dockerRepo}/${appName}:latest" -t "${dockerRepo}/${appName}:${version}" -f "${repositoryRootDirectory}/Dockerfile" .
|
||||
@@ -0,0 +1,18 @@
|
||||
$repositoryRootDirectory = Join-Path -Path Get-Location -ChildPath '..'
|
||||
try {
|
||||
$script:packageJson = Get-Content -Path (Join-Path -Path $repositoryRootDirectory -ChildPath 'package.json') | ConvertFrom-Json
|
||||
} catch {
|
||||
throw 'package.json does not exist'
|
||||
}
|
||||
|
||||
if (!$script:packageJson.version) {
|
||||
throw 'package.json does not contain a version'
|
||||
}
|
||||
|
||||
$version = $script:packageJson.version
|
||||
$appName = $script:packageJson.name
|
||||
$dockerRepo = 'jeffvictorli'
|
||||
|
||||
Write-Host "Pushing [${appname}:latest] & [${appName}:${version}]"
|
||||
|
||||
docker push "${dockerRepo}/${appName}" --all-tags
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,58 @@
|
||||
module.exports = {
|
||||
extends: ['plugin:typescript-sort-keys/recommended'],
|
||||
ignorePatterns: [],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
createDefaultProgram: true,
|
||||
ecmaVersion: 2020,
|
||||
project: './tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
|
||||
root: true,
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-shadow': ['off'],
|
||||
'import/no-cycle': 'error',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'import/no-unresolved': 'error',
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
alphabetize: {
|
||||
caseInsensitive: true,
|
||||
order: 'asc',
|
||||
},
|
||||
groups: ['builtin', 'external', 'internal', ['parent', 'sibling']],
|
||||
'newlines-between': 'never',
|
||||
pathGroups: [
|
||||
{
|
||||
group: 'external',
|
||||
pattern: 'react',
|
||||
position: 'before',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'import/prefer-default-export': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'no-console': 'off',
|
||||
'no-nested-ternary': 'off',
|
||||
'no-restricted-syntax': 'off',
|
||||
'sort-keys-fix/sort-keys-fix': 'warn',
|
||||
},
|
||||
settings: {
|
||||
'import/parsers': {
|
||||
'@typescript-eslint/parser': ['.ts'],
|
||||
},
|
||||
'import/resolver': {
|
||||
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
|
||||
node: {
|
||||
extensions: ['.js', '.ts'],
|
||||
paths: ['node_modules/', 'node_modules/@types'],
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -0,0 +1,22 @@
|
||||
@serverId =
|
||||
@albumArtistId =
|
||||
|
||||
###
|
||||
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
|
||||
# take: The number of rows to return. Must be a non-negative integer.
|
||||
# orderBy: asc | desc
|
||||
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
|
||||
GET {{host}}/api/servers/{{serverId}}/albumArtists
|
||||
?skip=0
|
||||
&take=100
|
||||
&sortBy=title
|
||||
&orderBy=desc
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers/{{serverId}}/albumArtists/{{albumArtistId}}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
@@ -0,0 +1,22 @@
|
||||
@serverId =
|
||||
@albumId =
|
||||
|
||||
###
|
||||
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
|
||||
# take: The number of rows to return. Must be a non-negative integer.
|
||||
# orderBy: asc | desc
|
||||
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
|
||||
GET {{host}}/api/servers/{{serverId}}/albums
|
||||
?skip=0
|
||||
&take=100
|
||||
&sortBy=title
|
||||
&orderBy=desc
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers/{{serverId}}/albums/{{albumId}}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
@@ -0,0 +1,22 @@
|
||||
@serverId =
|
||||
@artistId =
|
||||
|
||||
###
|
||||
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
|
||||
# take: The number of rows to return. Must be a non-negative integer.
|
||||
# orderBy: asc | desc
|
||||
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
|
||||
GET {{host}}/api/servers/{{serverId}}/artists
|
||||
?skip=0
|
||||
&take=100
|
||||
&sortBy=title
|
||||
&orderBy=desc
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers/{{serverId}}/artists/{{artistId}}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
###
|
||||
|
||||
POST {{host}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "{{authUsername}}",
|
||||
"password": "{{authPassword}}"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
POST {{host}}/api/auth/logout
|
||||
Content-Type: application/json
|
||||
Authorization: {{token}}
|
||||
|
||||
{
|
||||
"username": "{{authUsername}}",
|
||||
"password": "{{authPassword}}"
|
||||
}
|
||||
|
||||
|
||||
###
|
||||
|
||||
POST {{host}}/api/auth/refresh
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"refreshToken": "{{refreshToken}}"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
# @prompt username Login username
|
||||
# @prompt password Login password
|
||||
POST {{host}}/api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/auth/ping
|
||||
Content-Type: application/json
|
||||
|
||||
|
||||
@contentType = application/json
|
||||
@serverId =
|
||||
@@ -0,0 +1,66 @@
|
||||
@serverId =
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers/{{serverId}}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers/{{serverId}}/folder
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers/{{serverId}}/refresh
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/servers/{{serverId}}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
# name: Nickname for the server
|
||||
# type: SUBSONIC | JELLYFIN | NAVIDROME
|
||||
# url: The URL of the server e.g. http://192.168.1.1:8096
|
||||
# @prompt username The user which will be used to login and scan from the server
|
||||
# @prompt password The password for the user
|
||||
POST {{host}}/api/servers/
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"name": "My Jellyfin Server",
|
||||
"type": "JELLYFIN",
|
||||
"url": "http://192.168.14.11:8097",
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
|
||||
###
|
||||
|
||||
POST {{host}}/api/servers/{{serverId}}/scan
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"serverFolderIds": [""]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@serverId =
|
||||
|
||||
###
|
||||
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
|
||||
# take: The number of rows to return. Must be a non-negative integer.
|
||||
# orderBy: asc | desc
|
||||
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
|
||||
GET {{host}}/api/servers/{{serverId}}/songs
|
||||
?skip=0
|
||||
&take=100
|
||||
&sortBy=title
|
||||
&orderBy=desc
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
@@ -0,0 +1,21 @@
|
||||
@userId =
|
||||
|
||||
###
|
||||
# skip: The number of rows to skip before returning rows. Must be a non-negative integer.
|
||||
# take: The number of rows to return. Must be a non-negative integer.
|
||||
# orderBy: asc | desc
|
||||
# sortBy: date_added | date_added_remote | date_released | random | rating | title | year
|
||||
GET {{host}}/api/users
|
||||
?skip=0
|
||||
&take=100
|
||||
&sortBy=title
|
||||
&orderBy=desc
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
|
||||
###
|
||||
|
||||
GET {{host}}/api/users/{{userId}}
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM node:16.5-alpine
|
||||
|
||||
ARG DATABASE_PORT
|
||||
|
||||
ADD docker-entrypoint.sh /
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
COPY ./wait-for-it.sh /wait-for-it.sh
|
||||
RUN chmod +x /wait-for-it.sh
|
||||
|
||||
# Change directory so that our commands run inside this new directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency definitions
|
||||
COPY package.*json ./
|
||||
COPY prisma ./
|
||||
|
||||
# Install dependecies
|
||||
RUN npm install
|
||||
|
||||
# Get all the code needed to run the app
|
||||
COPY . .
|
||||
|
||||
# Expose the port the app runs in
|
||||
EXPOSE 9321
|
||||
|
||||
# Serve the app
|
||||
ENTRYPOINT ./docker-entrypoint.sh $DATABASE_PORT
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiSuccess, getSuccessResponse } from '@/utils';
|
||||
import { service } from '@services/index';
|
||||
import { validation, TypedRequest } from '@validations/index';
|
||||
|
||||
const getList = async (req: Request, res: Response) => {
|
||||
const { take, skip, serverFolderIds } = req.query;
|
||||
const albumArtists = await service.albumArtists.findMany(req, {
|
||||
serverFolderIds: String(serverFolderIds),
|
||||
skip: Number(skip),
|
||||
take: Number(take),
|
||||
user: req.authUser,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({
|
||||
data: albumArtists.data,
|
||||
paginationItems: {
|
||||
skip: Number(skip),
|
||||
take: Number(take),
|
||||
totalEntries: albumArtists.totalEntries,
|
||||
url: req.originalUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const getDetail = async (
|
||||
req: TypedRequest<typeof validation.albumArtists.detail>,
|
||||
res: Response
|
||||
) => {
|
||||
const { id } = req.params;
|
||||
const albumArtist = await service.albumArtists.findById({
|
||||
id,
|
||||
user: req.authUser,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: albumArtist });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const albumArtistsController = {
|
||||
getDetail,
|
||||
getList,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Response } from 'express';
|
||||
import { ApiSuccess, getSuccessResponse } from '@/utils';
|
||||
import { toApiModel } from '@helpers/api-model';
|
||||
import { service } from '@services/index';
|
||||
import { TypedRequest, validation } from '@validations/index';
|
||||
|
||||
const getDetail = async (
|
||||
req: TypedRequest<typeof validation.albums.detail>,
|
||||
res: Response
|
||||
) => {
|
||||
const { albumId, serverId } = req.params;
|
||||
|
||||
const album = await service.albums.findById(req.authUser, {
|
||||
id: albumId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({
|
||||
data: toApiModel.albums({ items: [album], user: req.authUser })[0],
|
||||
});
|
||||
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const getList = async (
|
||||
req: TypedRequest<typeof validation.albums.list>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const { take, skip, serverUrlId, advancedFilters } = req.query;
|
||||
|
||||
const decodedAdvancedFilters =
|
||||
advancedFilters && JSON.parse(decodeURI(advancedFilters));
|
||||
|
||||
const albums = await service.albums.findMany({
|
||||
...req.query,
|
||||
advancedFilters: decodedAdvancedFilters,
|
||||
serverId,
|
||||
skip: Number(skip),
|
||||
take: Number(take),
|
||||
user: req.authUser,
|
||||
});
|
||||
|
||||
const serverUrl = serverUrlId
|
||||
? await service.servers.findServerUrlById({
|
||||
id: serverUrlId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const success = ApiSuccess.ok({
|
||||
data: toApiModel.albums({
|
||||
items: albums.data,
|
||||
serverUrl: serverUrl?.url,
|
||||
user: req.authUser,
|
||||
}),
|
||||
paginationItems: {
|
||||
skip: Number(skip),
|
||||
take: Number(take),
|
||||
totalEntries: albums.totalEntries,
|
||||
url: req.originalUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const getDetailSongList = async (
|
||||
req: TypedRequest<typeof validation.albums.list>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const { take, skip, serverUrlId } = req.query;
|
||||
|
||||
const albums = await service.albums.findMany({
|
||||
...req.query,
|
||||
advancedFilters: undefined,
|
||||
serverId,
|
||||
skip: Number(skip),
|
||||
take: Number(take),
|
||||
user: req.authUser,
|
||||
});
|
||||
|
||||
const serverUrl = serverUrlId
|
||||
? await service.servers.findServerUrlById({
|
||||
id: serverUrlId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const success = ApiSuccess.ok({
|
||||
data: toApiModel.albums({
|
||||
items: albums.data,
|
||||
serverUrl: serverUrl?.url,
|
||||
user: req.authUser,
|
||||
}),
|
||||
paginationItems: {
|
||||
skip: Number(skip),
|
||||
take: Number(take),
|
||||
totalEntries: albums.totalEntries,
|
||||
url: req.originalUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const albumsController = {
|
||||
getDetail,
|
||||
getDetailSongList,
|
||||
getList,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Response } from 'express';
|
||||
import { ApiSuccess, getSuccessResponse } from '@/utils';
|
||||
import { service } from '@services/index';
|
||||
import { validation, TypedRequest } from '@validations/index';
|
||||
|
||||
const getDetail = async (
|
||||
req: TypedRequest<typeof validation.artists.detail>,
|
||||
res: Response
|
||||
) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const artist = await service.artists.findById({
|
||||
id,
|
||||
user: req.authUser,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: artist });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const getList = async (
|
||||
req: TypedRequest<typeof validation.artists.list>,
|
||||
res: Response
|
||||
) => {
|
||||
const { take, skip, serverFolderId } = req.query;
|
||||
|
||||
// const artists = await service.artists.findMany(req, {
|
||||
// serverFolderIds: String(serverFolderIds),
|
||||
// skip: Number(skip),
|
||||
// take: Number(take),
|
||||
// user: req.authUser,
|
||||
// });
|
||||
|
||||
// const success = ApiSuccess.ok({
|
||||
// data: artists,
|
||||
// paginationItems: {
|
||||
// skip: Number(skip),
|
||||
// take: Number(take),
|
||||
// totalEntries,
|
||||
// url: req.originalUrl,
|
||||
// },
|
||||
// });
|
||||
|
||||
// return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const artistsController = {
|
||||
getDetail,
|
||||
getList,
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiSuccess, getSuccessResponse } from '@/utils';
|
||||
import { toApiModel } from '@helpers/api-model';
|
||||
import { service } from '@services/index';
|
||||
import { validation, TypedRequest } from '@validations/index';
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const login = async (
|
||||
req: TypedRequest<typeof validation.auth.login>,
|
||||
res: Response
|
||||
) => {
|
||||
const { username } = req.body;
|
||||
const user = await service.auth.login({ username });
|
||||
|
||||
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const register = async (
|
||||
req: TypedRequest<typeof validation.auth.register>,
|
||||
res: Response
|
||||
) => {
|
||||
const { username, password } = req.body;
|
||||
const user = await service.auth.register({
|
||||
password,
|
||||
username,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const logout = async (req: Request, res: Response) => {
|
||||
await service.auth.logout({
|
||||
user: req.authUser,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.noContent({ data: {} });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const ping = async (_req: Request, res: Response) => {
|
||||
return res.status(200).json(
|
||||
getSuccessResponse({
|
||||
data: {
|
||||
description: packageJson.description,
|
||||
name: packageJson.name,
|
||||
version: packageJson.version,
|
||||
},
|
||||
statusCode: 200,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const refresh = async (
|
||||
req: TypedRequest<typeof validation.auth.refresh>,
|
||||
res: Response
|
||||
) => {
|
||||
const refresh = await service.auth.refresh({
|
||||
refreshToken: req.body.refreshToken,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: refresh });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const authController = { login, logout, ping, refresh, register };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Response } from 'express';
|
||||
import { toApiModel } from '@helpers/api-model';
|
||||
import { service } from '@services/index';
|
||||
import { ApiSuccess } from '@utils/api-success';
|
||||
import { getSuccessResponse } from '@utils/get-success-response';
|
||||
import { validation } from '@validations/index';
|
||||
import { TypedRequest } from '@validations/shared.validation';
|
||||
|
||||
const getList = async (
|
||||
req: TypedRequest<typeof validation.genres.list>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
|
||||
const data = await service.genres.findManyByServer({ serverId });
|
||||
|
||||
const success = ApiSuccess.ok({ data: toApiModel.genres(data) });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const genresController = {
|
||||
getList,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { albumArtistsController } from '@controllers/album-artists.controller';
|
||||
import { albumsController } from '@controllers/albums.controller';
|
||||
import { artistsController } from '@controllers/artists.controller';
|
||||
import { authController } from '@controllers/auth.controller';
|
||||
import { genresController } from '@controllers/genres.controller';
|
||||
import { serversController } from '@controllers/servers.controller';
|
||||
import { songsController } from '@controllers/songs.controller';
|
||||
import { tasksController } from '@controllers/tasks.controller';
|
||||
import { usersController } from '@controllers/users.controller';
|
||||
|
||||
export const controller = {
|
||||
albumArtists: albumArtistsController,
|
||||
albums: albumsController,
|
||||
artists: artistsController,
|
||||
auth: authController,
|
||||
genres: genresController,
|
||||
servers: serversController,
|
||||
songs: songsController,
|
||||
tasks: tasksController,
|
||||
users: usersController,
|
||||
};
|
||||
@@ -0,0 +1,365 @@
|
||||
import { ServerType } from '@prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiError, ApiSuccess, getSuccessResponse } from '@/utils';
|
||||
import { toApiModel } from '@helpers/api-model';
|
||||
import { service } from '@services/index';
|
||||
import { TypedRequest, validation } from '@validations/index';
|
||||
|
||||
const getServerListMap = async (req: Request, res: Response) => {
|
||||
const data = await service.servers.getServerListMap();
|
||||
const success = ApiSuccess.ok({ data });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const getServerDetail = async (
|
||||
req: TypedRequest<typeof validation.servers.detail>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const data = await service.servers.findById(req.authUser, { id: serverId });
|
||||
const success = ApiSuccess.ok({ data: toApiModel.servers([data]) });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const getServerList = async (
|
||||
req: TypedRequest<typeof validation.servers.list>,
|
||||
res: Response
|
||||
) => {
|
||||
const { enabled } = req.query;
|
||||
const data = await service.servers.findMany(req.authUser, {
|
||||
enabled: Boolean(enabled),
|
||||
});
|
||||
const success = ApiSuccess.ok({ data: toApiModel.servers(data) });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const deleteServer = async (
|
||||
req: TypedRequest<typeof validation.servers.deleteServer>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
await service.servers.deleteById({ id: serverId });
|
||||
const success = ApiSuccess.noContent({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const createServer = async (
|
||||
req: TypedRequest<typeof validation.servers.create>,
|
||||
res: Response
|
||||
) => {
|
||||
const remoteServerLoginRes = await service.servers.remoteServerLogin(
|
||||
req.body
|
||||
);
|
||||
|
||||
const data = await service.servers.create({
|
||||
name: req.body.name,
|
||||
...remoteServerLoginRes,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const updateServer = async (
|
||||
req: TypedRequest<typeof validation.servers.update>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const { username, password, name, legacy, type, url, noCredential } =
|
||||
req.body;
|
||||
|
||||
if (type && username && password && url) {
|
||||
const remoteServerLoginRes = await service.servers.remoteServerLogin({
|
||||
legacy,
|
||||
password,
|
||||
type,
|
||||
url,
|
||||
username,
|
||||
});
|
||||
|
||||
const data = await service.servers.update(
|
||||
{ id: serverId },
|
||||
{
|
||||
name,
|
||||
remoteUserId: remoteServerLoginRes.remoteUserId,
|
||||
token:
|
||||
type === ServerType.NAVIDROME
|
||||
? `${remoteServerLoginRes.token}||${remoteServerLoginRes?.altToken}`
|
||||
: remoteServerLoginRes.token,
|
||||
type,
|
||||
url: remoteServerLoginRes.url,
|
||||
username: remoteServerLoginRes.username,
|
||||
}
|
||||
);
|
||||
|
||||
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
}
|
||||
|
||||
const data = await service.servers.update(
|
||||
{ id: serverId },
|
||||
{ name, noCredential, url }
|
||||
);
|
||||
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const refreshServer = async (
|
||||
req: TypedRequest<typeof validation.servers.refresh>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const data = await service.servers.refresh({ id: serverId });
|
||||
|
||||
const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const fullScanServer = async (
|
||||
req: TypedRequest<typeof validation.servers.scan>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const { serverFolderId } = req.body;
|
||||
|
||||
// TODO: Check that server is accessible first with the saved token, otherwise throw error
|
||||
|
||||
const scansInProgress = await service.servers.findScanInProgress({
|
||||
serverId,
|
||||
});
|
||||
|
||||
if (scansInProgress.length > 0) {
|
||||
throw ApiError.badRequest('Scan already in progress');
|
||||
}
|
||||
|
||||
const io = req.app.get('socketio');
|
||||
await io.emit('task:started');
|
||||
|
||||
const data = await service.servers.fullScan(req.authUser, {
|
||||
id: serverId,
|
||||
serverFolderId,
|
||||
});
|
||||
|
||||
// return res.status(200).json({ data: null });
|
||||
const success = ApiSuccess.ok({ data });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const quickScanServer = async (
|
||||
req: TypedRequest<typeof validation.servers.scan>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const { serverFolderId } = req.body;
|
||||
|
||||
// TODO: Check that server is accessible first with the saved token, otherwise throw error
|
||||
|
||||
const scansInProgress = await service.servers.findScanInProgress({
|
||||
serverId,
|
||||
});
|
||||
|
||||
if (scansInProgress.length > 0) {
|
||||
throw ApiError.badRequest('Scan already in progress');
|
||||
}
|
||||
|
||||
const io = req.app.get('socketio');
|
||||
await io.emit('task:started');
|
||||
|
||||
// await service.servers.fullScan({
|
||||
// id: serverId,
|
||||
// serverFolderId,
|
||||
// });
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const createServerUrl = async (
|
||||
req: TypedRequest<typeof validation.servers.createUrl>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const { url } = req.body;
|
||||
|
||||
const data = await service.servers.createUrl({
|
||||
serverId,
|
||||
url,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const deleteServerUrl = async (
|
||||
req: TypedRequest<typeof validation.servers.deleteUrl>,
|
||||
res: Response
|
||||
) => {
|
||||
const { urlId } = req.params;
|
||||
|
||||
await service.servers.deleteUrlById({
|
||||
id: urlId,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const enableServerUrl = async (
|
||||
req: TypedRequest<typeof validation.servers.enableUrl>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId, urlId } = req.params;
|
||||
|
||||
await service.servers.enableUrlById(req.authUser, {
|
||||
id: urlId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const disableServerUrl = async (
|
||||
req: TypedRequest<typeof validation.servers.disableUrl>,
|
||||
res: Response
|
||||
) => {
|
||||
await service.servers.disableUrlById(req.authUser);
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const deleteServerFolder = async (
|
||||
req: TypedRequest<typeof validation.servers.deleteFolder>,
|
||||
res: Response
|
||||
) => {
|
||||
const { folderId } = req.params;
|
||||
|
||||
await service.servers.deleteFolderById({ id: folderId });
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const enableServerFolder = async (
|
||||
req: TypedRequest<typeof validation.servers.enableFolder>,
|
||||
res: Response
|
||||
) => {
|
||||
const { folderId } = req.params;
|
||||
|
||||
await service.servers.enableFolderById({ id: folderId });
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const disableServerFolder = async (
|
||||
req: TypedRequest<typeof validation.servers.disableFolder>,
|
||||
res: Response
|
||||
) => {
|
||||
const { folderId } = req.params;
|
||||
|
||||
await service.servers.disableFolderById({ id: folderId });
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const addServerPermission = async (
|
||||
req: TypedRequest<typeof validation.servers.addServerPermission>,
|
||||
res: Response
|
||||
) => {
|
||||
const { serverId } = req.params;
|
||||
const { userId, type } = req.body;
|
||||
|
||||
const data = await service.servers.addPermission({
|
||||
serverId,
|
||||
type,
|
||||
userId,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const deleteServerPermission = async (
|
||||
req: TypedRequest<typeof validation.servers.deleteServerPermission>,
|
||||
res: Response
|
||||
) => {
|
||||
const { permissionId } = req.params;
|
||||
|
||||
await service.servers.deletePermission({
|
||||
id: permissionId,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const updateServerPermission = async (
|
||||
req: TypedRequest<typeof validation.servers.updateServerPermission>,
|
||||
res: Response
|
||||
) => {
|
||||
const { permissionId } = req.params;
|
||||
const { type } = req.body;
|
||||
|
||||
await service.servers.updateServerPermission({
|
||||
id: permissionId,
|
||||
type,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const addServerFolderPermission = async (
|
||||
req: TypedRequest<typeof validation.servers.addServerFolderPermission>,
|
||||
res: Response
|
||||
) => {
|
||||
const { folderId } = req.params;
|
||||
const { userId } = req.body;
|
||||
|
||||
const data = await service.servers.addFolderPermission({
|
||||
serverFolderId: folderId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({ data });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const deleteServerFolderPermission = async (
|
||||
req: TypedRequest<typeof validation.servers.deleteServerFolderPermission>,
|
||||
res: Response
|
||||
) => {
|
||||
const { folderPermissionId } = req.params;
|
||||
|
||||
await service.servers.deleteFolderPermission({ id: folderPermissionId });
|
||||
|
||||
const success = ApiSuccess.ok({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const serversController = {
|
||||
addServerFolderPermission,
|
||||
addServerPermission,
|
||||
createServer,
|
||||
createServerUrl,
|
||||
deleteServer,
|
||||
deleteServerFolder,
|
||||
deleteServerFolderPermission,
|
||||
deleteServerPermission,
|
||||
deleteServerUrl,
|
||||
disableServerFolder,
|
||||
disableServerUrl,
|
||||
enableServerFolder,
|
||||
enableServerUrl,
|
||||
fullScanServer,
|
||||
getServerDetail,
|
||||
getServerList,
|
||||
getServerListMap,
|
||||
quickScanServer,
|
||||
refreshServer,
|
||||
updateServer,
|
||||
updateServerPermission,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
const getSongList = async (req: Request, res: Response) => {
|
||||
const { serverId } = req.params;
|
||||
const { take, skip, serverFolderId } = req.query;
|
||||
|
||||
// const songs = await songsService.findMany(req, {
|
||||
// serverFolderIds: String(serverFolderId),
|
||||
// serverId,
|
||||
// skip: Number(skip),
|
||||
// take: Number(take),
|
||||
// user: req.authUser,
|
||||
// });
|
||||
|
||||
// const success = ApiSuccess.ok({
|
||||
// // data: toRes.songs(songs.data, req.authUser),
|
||||
// data: songs.data,
|
||||
// paginationItems: {
|
||||
// skip: Number(skip),
|
||||
// take: Number(take),
|
||||
// totalEntries: songs.totalEntries,
|
||||
// url: req.originalUrl,
|
||||
// },
|
||||
// });
|
||||
|
||||
return {};
|
||||
|
||||
// return res.status(data.statusCode).json(getSuccessResponse(data));
|
||||
};
|
||||
|
||||
export const songsController = {
|
||||
getSongList,
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { queue } from '@/queue/queues';
|
||||
import { toApiModel } from '@helpers/api-model';
|
||||
import { prisma } from '@lib/prisma';
|
||||
import { ApiSuccess } from '@utils/api-success';
|
||||
import { getSuccessResponse } from '@utils/get-success-response';
|
||||
import { validation } from '@validations/index';
|
||||
import { TypedRequest } from '@validations/shared.validation';
|
||||
import { SortOrder } from '../types/types';
|
||||
|
||||
const getActiveTasks = async (_req: Request, res: Response) => {
|
||||
const tasks = await prisma.task.findMany({
|
||||
include: {
|
||||
server: true,
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: SortOrder.ASC,
|
||||
},
|
||||
where: {
|
||||
completed: false,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (queue.scanner.length === 0) {
|
||||
await prisma.task.updateMany({
|
||||
data: { completed: true, isError: true, message: 'Task not found' },
|
||||
where: { completed: false },
|
||||
});
|
||||
}
|
||||
|
||||
const success = ApiSuccess.ok({ data: toApiModel.tasks({ items: tasks }) });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const cancelAllTasks = async (
|
||||
_req: TypedRequest<typeof validation.tasks.cancelAll>,
|
||||
res: Response
|
||||
) => {
|
||||
const runningTasks = await prisma.task.findMany({
|
||||
include: {
|
||||
server: true,
|
||||
user: true,
|
||||
},
|
||||
where: {
|
||||
completed: false,
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
for (const task of runningTasks) {
|
||||
queue.scanner.push({
|
||||
fn: async () => {
|
||||
return {};
|
||||
},
|
||||
id: task.id,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.task.updateMany({
|
||||
data: {
|
||||
completed: true,
|
||||
message: 'Task was cancelled by user',
|
||||
},
|
||||
where: { completed: false },
|
||||
});
|
||||
|
||||
const success = ApiSuccess.noContent({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const cancelTaskById = async (
|
||||
req: TypedRequest<typeof validation.tasks.cancel>,
|
||||
res: Response
|
||||
) => {
|
||||
const { taskId } = req.params;
|
||||
|
||||
const task = await prisma.task.update({
|
||||
data: {
|
||||
completed: true,
|
||||
message: 'Task was cancelled by user',
|
||||
},
|
||||
include: {
|
||||
server: true,
|
||||
user: true,
|
||||
},
|
||||
where: { id: taskId },
|
||||
});
|
||||
|
||||
queue.scanner.push({
|
||||
fn: async () => {
|
||||
return {};
|
||||
},
|
||||
id: taskId,
|
||||
});
|
||||
|
||||
const success = ApiSuccess.ok({
|
||||
data: toApiModel.tasks({ items: [task] })[0],
|
||||
});
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const tasksController = {
|
||||
cancelAllTasks,
|
||||
cancelTaskById,
|
||||
getActiveTasks,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiSuccess, getSuccessResponse } from '@/utils';
|
||||
import { toApiModel } from '@helpers/api-model';
|
||||
import { service } from '@services/index';
|
||||
import { validation } from '@validations/index';
|
||||
import { TypedRequest } from '@validations/shared.validation';
|
||||
|
||||
const getUserDetail = async (
|
||||
req: TypedRequest<typeof validation.users.detail>,
|
||||
res: Response
|
||||
) => {
|
||||
const { userId } = req.params;
|
||||
const user = await service.users.findById(req.authUser, { id: userId });
|
||||
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const getUserList = async (_req: Request, res: Response) => {
|
||||
const users = await service.users.findMany();
|
||||
const success = ApiSuccess.ok({ data: toApiModel.users(users) });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const createUser = async (
|
||||
req: TypedRequest<typeof validation.users.createUser>,
|
||||
res: Response
|
||||
) => {
|
||||
const user = await service.users.createUser(req.authUser, req.body);
|
||||
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const updateUser = async (
|
||||
req: TypedRequest<typeof validation.users.updateUser>,
|
||||
res: Response
|
||||
) => {
|
||||
const { userId } = req.params;
|
||||
|
||||
const user = await service.users.updateUser(
|
||||
{ userId },
|
||||
{ ...req.body, image: req.file }
|
||||
);
|
||||
const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
const deleteUser = async (
|
||||
req: TypedRequest<typeof validation.users.deleteUser>,
|
||||
res: Response
|
||||
) => {
|
||||
const { userId } = req.params;
|
||||
await service.users.deleteUser({ userId });
|
||||
const success = ApiSuccess.noContent({ data: null });
|
||||
return res.status(success.statusCode).json(getSuccessResponse(success));
|
||||
};
|
||||
|
||||
export const usersController = {
|
||||
createUser,
|
||||
deleteUser,
|
||||
getUserDetail,
|
||||
getUserList,
|
||||
updateUser,
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
apk add --no-cache bash
|
||||
|
||||
./wait-for-it.sh db:$1 --timeout=20 --strict -- echo "db is up"
|
||||
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
npx ts-node prisma/seed.ts
|
||||
|
||||
npm run dev
|
||||
@@ -0,0 +1,355 @@
|
||||
import { AuthUser } from '@/middleware';
|
||||
import { SortOrder } from '@/types/types';
|
||||
import { songHelpers } from '@helpers/songs.helpers';
|
||||
|
||||
export enum AlbumSort {
|
||||
DATE_ADDED = 'added',
|
||||
DATE_ADDED_REMOTE = 'addedRemote',
|
||||
DATE_RELEASED = 'released',
|
||||
DATE_RELEASED_YEAR = 'year',
|
||||
FAVORITE = 'favorite',
|
||||
NAME = 'name',
|
||||
RANDOM = 'random',
|
||||
RATING = 'rating',
|
||||
}
|
||||
|
||||
const include = (user: AuthUser, options: { songs?: boolean }) => {
|
||||
// Prisma.AlbumInclude
|
||||
const props = {
|
||||
_count: {
|
||||
select: {
|
||||
favorites: true,
|
||||
songs: true,
|
||||
},
|
||||
},
|
||||
albumArtists: true,
|
||||
artists: true,
|
||||
favorites: { where: { userId: user?.id } },
|
||||
genres: true,
|
||||
images: true,
|
||||
ratings: {
|
||||
where: {
|
||||
userId: user?.id,
|
||||
},
|
||||
},
|
||||
server: true,
|
||||
serverFolders: { where: { enabled: true } },
|
||||
songs: options?.songs && songHelpers.findMany(user),
|
||||
};
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
const sort = (sortBy: AlbumSort, orderBy: SortOrder) => {
|
||||
let order;
|
||||
|
||||
switch (sortBy) {
|
||||
case AlbumSort.NAME:
|
||||
order = { name: orderBy };
|
||||
break;
|
||||
|
||||
case AlbumSort.DATE_ADDED:
|
||||
order = { createdAt: orderBy };
|
||||
break;
|
||||
|
||||
case AlbumSort.DATE_ADDED_REMOTE:
|
||||
order = { remoteCreatedAt: orderBy };
|
||||
break;
|
||||
|
||||
case AlbumSort.DATE_RELEASED:
|
||||
order = { releaseDate: orderBy };
|
||||
break;
|
||||
|
||||
case AlbumSort.DATE_RELEASED_YEAR:
|
||||
order = { releaseYear: orderBy };
|
||||
break;
|
||||
|
||||
case AlbumSort.RATING:
|
||||
order = { rating: orderBy };
|
||||
break;
|
||||
|
||||
case AlbumSort.FAVORITE:
|
||||
order = { favorite: orderBy };
|
||||
break;
|
||||
|
||||
default:
|
||||
order = { title: orderBy };
|
||||
break;
|
||||
}
|
||||
|
||||
return order;
|
||||
};
|
||||
|
||||
export enum FilterGroupType {
|
||||
AND = 'AND',
|
||||
OR = 'OR',
|
||||
}
|
||||
|
||||
export type AdvancedFilterRule = {
|
||||
field: string | null;
|
||||
operator: string | null;
|
||||
uniqueId: string;
|
||||
value: string | number | Date | undefined | null | any;
|
||||
};
|
||||
|
||||
export type AdvancedFilterGroup = {
|
||||
group: AdvancedFilterGroup[];
|
||||
rules: AdvancedFilterRule[];
|
||||
type: FilterGroupType;
|
||||
uniqueId: string;
|
||||
};
|
||||
|
||||
const operatorMap = {
|
||||
'!=': 'not',
|
||||
'!~': 'contains',
|
||||
$: 'endsWith',
|
||||
'<': 'lt',
|
||||
'<=': 'lte',
|
||||
'=': 'equals',
|
||||
'>': 'gt',
|
||||
'>=': 'gte',
|
||||
'^': 'startsWith',
|
||||
'~': 'contains',
|
||||
};
|
||||
|
||||
const insensitiveFields = ['name'];
|
||||
|
||||
const advancedFilterGroup = (
|
||||
groups: AdvancedFilterGroup[],
|
||||
user: AuthUser,
|
||||
data: any[]
|
||||
) => {
|
||||
if (groups.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const filterGroups: any[] = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const rootType = group.type.toUpperCase();
|
||||
const query: any = {
|
||||
[rootType]: [],
|
||||
};
|
||||
|
||||
for (const rule of group.rules) {
|
||||
if (rule.field && rule.operator) {
|
||||
const [table, field, relationField] = rule.field.split('.');
|
||||
const condition =
|
||||
rule.operator === '!~' || rule.operator === '!=' ? 'none' : 'some';
|
||||
const op = operatorMap[rule.operator as keyof typeof operatorMap];
|
||||
const value =
|
||||
field !== 'releaseDate' ? rule.value : new Date(rule.value);
|
||||
|
||||
switch (table) {
|
||||
case 'albums':
|
||||
if (field === 'ratings') {
|
||||
query[rootType].push({
|
||||
[field]: {
|
||||
[condition]: {
|
||||
[relationField]: {
|
||||
[op]: value,
|
||||
},
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (field === 'genres') {
|
||||
query[rootType].push({
|
||||
[field]: {
|
||||
[condition]: {
|
||||
[relationField]: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
query[rootType].push({
|
||||
[field]: {
|
||||
mode: insensitiveFields.includes(field)
|
||||
? 'insensitive'
|
||||
: undefined,
|
||||
[op]: value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
if (field === 'ratings') {
|
||||
query[rootType].push({
|
||||
[table]: {
|
||||
some: {
|
||||
[field]: {
|
||||
some: {
|
||||
[relationField]: {
|
||||
[op]: value,
|
||||
},
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (field === 'genres') {
|
||||
query[rootType].push({
|
||||
[table]: {
|
||||
some: {
|
||||
[field]: {
|
||||
[condition]: {
|
||||
[relationField]: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
query[rootType].push({
|
||||
[table]: {
|
||||
[condition]: {
|
||||
[field]: {
|
||||
mode: 'insensitive',
|
||||
[op]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (group.group.length > 0) {
|
||||
const b = advancedFilterGroup(group.group, user, data);
|
||||
b.forEach((c) => query[rootType].push(c));
|
||||
}
|
||||
|
||||
data.push(query);
|
||||
filterGroups.push(query);
|
||||
}
|
||||
|
||||
return filterGroups;
|
||||
};
|
||||
|
||||
const advancedFilter = (filter: AdvancedFilterGroup, user: AuthUser) => {
|
||||
const rootQueryType = filter.type.toUpperCase();
|
||||
const rootQuery = {
|
||||
[rootQueryType]: [] as any[],
|
||||
};
|
||||
|
||||
for (const rule of filter.rules) {
|
||||
if (rule.field && rule.operator) {
|
||||
let [table, field, relationField] = rule.field.split('.');
|
||||
const condition =
|
||||
rule.operator === '!~' || rule.operator === '!=' ? 'none' : 'some';
|
||||
const op = operatorMap[rule.operator as keyof typeof operatorMap];
|
||||
const value = field !== 'releaseDate' ? rule.value : new Date(rule.value);
|
||||
|
||||
switch (table) {
|
||||
case 'albums':
|
||||
if (field === 'ratings') {
|
||||
rootQuery[rootQueryType].push({
|
||||
[field]: {
|
||||
[condition]: {
|
||||
[relationField]: {
|
||||
[op]: value,
|
||||
},
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (field === 'genres') {
|
||||
rootQuery[rootQueryType].push({
|
||||
[field]: {
|
||||
[condition]: {
|
||||
[relationField]: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
rootQuery[rootQueryType].push({
|
||||
[field]: {
|
||||
mode: insensitiveFields.includes(field)
|
||||
? 'insensitive'
|
||||
: undefined,
|
||||
[op]: value,
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
if (field === 'ratings') {
|
||||
rootQuery[rootQueryType].push({
|
||||
[table]: {
|
||||
some: {
|
||||
[field]: {
|
||||
some: {
|
||||
[relationField]: {
|
||||
[op]: value,
|
||||
},
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (field === 'genres') {
|
||||
rootQuery[rootQueryType].push({
|
||||
[table]: {
|
||||
some: {
|
||||
[field]: {
|
||||
[condition]: {
|
||||
[relationField]: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
rootQuery[rootQueryType].push({
|
||||
[table]: {
|
||||
[condition]: {
|
||||
[field]: {
|
||||
mode: 'insensitive',
|
||||
[op]: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groups = advancedFilterGroup(filter.group, user, []);
|
||||
for (const group of groups) {
|
||||
rootQuery[rootQueryType].push(group);
|
||||
}
|
||||
|
||||
return rootQuery;
|
||||
};
|
||||
|
||||
export const albumHelpers = {
|
||||
advancedFilter,
|
||||
include,
|
||||
sort,
|
||||
};
|
||||
@@ -0,0 +1,685 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
import {
|
||||
Album,
|
||||
AlbumArtist,
|
||||
AlbumArtistRating,
|
||||
AlbumRating,
|
||||
Artist,
|
||||
ArtistRating,
|
||||
External,
|
||||
File,
|
||||
FileType,
|
||||
Genre,
|
||||
Image,
|
||||
ImageType,
|
||||
Server,
|
||||
ServerFolder,
|
||||
ServerFolderPermission,
|
||||
ServerPermission,
|
||||
ServerType,
|
||||
ServerUrl,
|
||||
Song,
|
||||
SongRating,
|
||||
Task,
|
||||
User,
|
||||
UserServerUrl,
|
||||
} from '@prisma/client';
|
||||
import { AuthUser } from '@middleware/authenticate';
|
||||
|
||||
const getSubsonicStreamUrl = (options: {
|
||||
deviceId: string;
|
||||
remoteId: string;
|
||||
token?: string;
|
||||
url: string;
|
||||
}) => {
|
||||
const { deviceId, remoteId, token, url } = options;
|
||||
return (
|
||||
`${url}/rest/stream.view` +
|
||||
`?id=${remoteId}` +
|
||||
`&v=1.13.0` +
|
||||
`&c=Feishin_${deviceId}` +
|
||||
`&${token ? `${token}` : ''}`
|
||||
);
|
||||
};
|
||||
|
||||
const getJellyfinStreamUrl = (options: {
|
||||
deviceId: string;
|
||||
remoteId: string;
|
||||
token?: string;
|
||||
url: string;
|
||||
userId: string;
|
||||
}) => {
|
||||
const { deviceId, remoteId, token, url, userId } = options;
|
||||
return (
|
||||
`${url}/audio` +
|
||||
`/${remoteId}/universal` +
|
||||
`?userId=${userId}` +
|
||||
`&audioCodec=aac` +
|
||||
`&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg` +
|
||||
`&transcodingContainer=ts` +
|
||||
`&transcodingProtocol=hls` +
|
||||
`&deviceId=Feishin_${deviceId}` +
|
||||
`&playSessionId=${deviceId}` +
|
||||
`&api_key=${token ? `${token}` : ''}`
|
||||
);
|
||||
};
|
||||
|
||||
const buildStreamUrl = (
|
||||
type: ServerType,
|
||||
options: {
|
||||
deviceId: string;
|
||||
noCredential: boolean;
|
||||
remoteId: string;
|
||||
token: string;
|
||||
url: string;
|
||||
userId?: string;
|
||||
}
|
||||
) => {
|
||||
if (type === ServerType.JELLYFIN) {
|
||||
return getJellyfinStreamUrl({
|
||||
deviceId: options.deviceId,
|
||||
remoteId: options.remoteId,
|
||||
token: options.noCredential ? undefined : options.token,
|
||||
url: options.url,
|
||||
userId: options.userId || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (type === ServerType.SUBSONIC) {
|
||||
return getSubsonicStreamUrl({
|
||||
deviceId: options.deviceId,
|
||||
remoteId: options.remoteId,
|
||||
token: options.noCredential ? undefined : options.token,
|
||||
url: options.url,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === ServerType.NAVIDROME) {
|
||||
const [_ndToken, ssToken] = options.token.split('||');
|
||||
|
||||
if (options.noCredential) {
|
||||
return getSubsonicStreamUrl({
|
||||
deviceId: options.deviceId,
|
||||
remoteId: options.remoteId,
|
||||
url: options.url,
|
||||
});
|
||||
}
|
||||
|
||||
return getSubsonicStreamUrl({
|
||||
deviceId: options.deviceId,
|
||||
remoteId: options.remoteId,
|
||||
token: ssToken,
|
||||
url: options.url,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const imageUrl = (
|
||||
type: ServerType,
|
||||
imageType: ImageType,
|
||||
baseUrl: string,
|
||||
imageId: string,
|
||||
token?: string
|
||||
) => {
|
||||
if (type === ServerType.JELLYFIN) {
|
||||
if (imageType === ImageType.PRIMARY) {
|
||||
return (
|
||||
`${baseUrl}/Items` +
|
||||
`/${imageId}` +
|
||||
`/Images/Primary` +
|
||||
'?fillHeight=250' +
|
||||
`&fillWidth=250` +
|
||||
'&quality=90'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
`${baseUrl}/Items` +
|
||||
`/${imageId}` +
|
||||
`/Images/Backdrop` +
|
||||
'?fillHeight=250' +
|
||||
`&fillWidth=250` +
|
||||
'&quality=90'
|
||||
);
|
||||
}
|
||||
|
||||
if (type === ServerType.SUBSONIC || type === ServerType.NAVIDROME) {
|
||||
return (
|
||||
`${baseUrl}/rest/getCoverArt.view` +
|
||||
`?id=${imageId}` +
|
||||
`&size=250` +
|
||||
`&v=1.13.0` +
|
||||
`&c=Feishin` +
|
||||
`&${token ? `${token}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const relatedAlbum = (
|
||||
item: Album & {
|
||||
albumArtists: AlbumArtist[];
|
||||
}
|
||||
) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
remoteId: item.remoteId,
|
||||
albumArtists: item.albumArtists
|
||||
? relatedAlbumArtists(item.albumArtists)
|
||||
: [],
|
||||
deleted: item.deleted,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
};
|
||||
|
||||
const relatedArtists = (items: Artist[]) => {
|
||||
return (
|
||||
items?.map((item) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
remoteId: item.remoteId,
|
||||
deleted: item.deleted,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const relatedAlbumArtists = (items: AlbumArtist[]) => {
|
||||
return (
|
||||
items?.map((item) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
remoteId: item.remoteId,
|
||||
deleted: item.deleted,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const relatedGenres = (items: Genre[]) => {
|
||||
return (
|
||||
items?.map((item) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const genres = (items: (Genre & { _count?: any })[]) => {
|
||||
return (
|
||||
items?.map((item) => {
|
||||
const totalCount = Object.keys(item._count)
|
||||
.map((key) => item._count[key])
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
songCount: item._count?.songs,
|
||||
albumCount: item._count?.albums,
|
||||
artistCount: item._count?.artists,
|
||||
albumArtistCount: item._count?.albumArtists,
|
||||
totalCount,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const relatedServerFolders = (items: ServerFolder[]) => {
|
||||
const serverFolders = items?.map((item) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
enabled: item.enabled,
|
||||
remoteId: item.remoteId,
|
||||
lastScannedAt: item.lastScannedAt,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
});
|
||||
|
||||
return serverFolders || [];
|
||||
};
|
||||
|
||||
const relatedServerUrls = (
|
||||
items: (ServerUrl & {
|
||||
userServerUrls?: UserServerUrl[];
|
||||
})[]
|
||||
) => {
|
||||
const serverUrls = items?.map((item) => {
|
||||
const userServerUrlIds = item.userServerUrls?.map(
|
||||
(userServerUrl) => userServerUrl.serverUrlId
|
||||
);
|
||||
const enabled = userServerUrlIds?.some((id) => id === item.id);
|
||||
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
enabled,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
});
|
||||
|
||||
return serverUrls || [];
|
||||
};
|
||||
|
||||
const rating = (
|
||||
items: AlbumRating[] | SongRating[] | ArtistRating[] | AlbumArtistRating[]
|
||||
) => {
|
||||
if (items.length > 0) {
|
||||
return items[0].value;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildImageUrl = (options: {
|
||||
imageType: ImageType;
|
||||
images: Image[];
|
||||
noCredential?: boolean;
|
||||
remoteId: string;
|
||||
token?: string;
|
||||
type: ServerType;
|
||||
url: string;
|
||||
}) => {
|
||||
const { imageType, images, remoteId, token, type, url, noCredential } =
|
||||
options;
|
||||
|
||||
const image = images.find((i) => i.type === imageType);
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
if (type === ServerType.JELLYFIN) {
|
||||
return imageUrl(type, imageType, url, remoteId);
|
||||
}
|
||||
|
||||
if (type === ServerType.SUBSONIC) {
|
||||
if (noCredential) {
|
||||
return imageUrl(type, imageType, url, image.remoteUrl);
|
||||
}
|
||||
|
||||
return imageUrl(type, imageType, url, image.remoteUrl, token);
|
||||
}
|
||||
|
||||
if (type === ServerType.NAVIDROME) {
|
||||
const [_ndToken, ssToken] = token!.split('||');
|
||||
|
||||
if (noCredential) {
|
||||
return imageUrl(type, imageType, url, image.remoteUrl);
|
||||
}
|
||||
|
||||
return imageUrl(type, imageType, url, image.remoteUrl, ssToken);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type DbSong = Song & DbSongInclude;
|
||||
|
||||
type DbSongInclude = {
|
||||
album: Album & { albumArtists: AlbumArtist[]; images: Image[] };
|
||||
artists: Artist[];
|
||||
externals: External[];
|
||||
genres: Genre[];
|
||||
images: Image[];
|
||||
ratings: SongRating[];
|
||||
server: Server & { serverUrls: ServerUrl[] };
|
||||
};
|
||||
|
||||
const songs = (
|
||||
items: DbSong[],
|
||||
options: {
|
||||
deviceId: string;
|
||||
imageUrl?: string;
|
||||
serverFolderId?: number;
|
||||
token: string;
|
||||
type: ServerType;
|
||||
url: string;
|
||||
userId: string;
|
||||
},
|
||||
noCredential: boolean
|
||||
) => {
|
||||
return (
|
||||
items?.map((item) => {
|
||||
const customUrl = item.server.serverUrls[0]?.url;
|
||||
const baseUrl = customUrl ? customUrl : options.url;
|
||||
|
||||
const streamUrl = buildStreamUrl(options.type, {
|
||||
deviceId: options.deviceId,
|
||||
noCredential,
|
||||
remoteId: item.remoteId,
|
||||
token: options.token,
|
||||
url: baseUrl,
|
||||
userId: options.userId,
|
||||
});
|
||||
|
||||
let imageUrl = buildImageUrl({
|
||||
imageType: ImageType.PRIMARY,
|
||||
images: item.images,
|
||||
noCredential,
|
||||
remoteId: item.remoteId,
|
||||
token: options.token,
|
||||
type: options.type,
|
||||
url: baseUrl,
|
||||
});
|
||||
|
||||
if (!imageUrl) {
|
||||
imageUrl = buildImageUrl({
|
||||
imageType: ImageType.PRIMARY,
|
||||
images: item.album.images,
|
||||
noCredential,
|
||||
remoteId: item.remoteId,
|
||||
token: options.token,
|
||||
type: options.type,
|
||||
url: baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
artistName: item.artistName,
|
||||
album: item.album && relatedAlbum(item.album),
|
||||
artists: relatedArtists(item.artists),
|
||||
bitRate: item.bitRate,
|
||||
container: item.container,
|
||||
createdAt: item.createdAt,
|
||||
deleted: item.deleted,
|
||||
discNumber: item.discNumber,
|
||||
duration: item.duration,
|
||||
genres: relatedGenres(item.genres),
|
||||
imageUrl,
|
||||
releaseDate: item.releaseDate,
|
||||
releaseYear: item.releaseYear,
|
||||
remoteCreatedAt: item.remoteCreatedAt,
|
||||
remoteId: item.remoteId,
|
||||
// serverFolderId: item.serverFolderId,
|
||||
serverId: item.serverId,
|
||||
streamUrl,
|
||||
trackNumber: item.trackNumber,
|
||||
updatedAt: item.updatedAt,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
type DbAlbum = Album & DbAlbumInclude;
|
||||
|
||||
type DbAlbumInclude = {
|
||||
_count: {
|
||||
favorites: number;
|
||||
songs: number;
|
||||
};
|
||||
albumArtists: AlbumArtist[];
|
||||
genres: Genre[];
|
||||
images: Image[];
|
||||
ratings: AlbumRating[];
|
||||
server: Server;
|
||||
serverFolders: ServerFolder[];
|
||||
songs?: DbSong[];
|
||||
};
|
||||
|
||||
const albums = (options: {
|
||||
items: DbAlbum[] | any[];
|
||||
serverUrl?: string;
|
||||
user: AuthUser;
|
||||
}) => {
|
||||
const { items, serverUrl, user } = options;
|
||||
return (
|
||||
items?.map((item) => {
|
||||
const { type, token, remoteUserId, noCredential } = item.server;
|
||||
const url = serverUrl || item.server.url;
|
||||
|
||||
// Jellyfin does not require credentials for image url
|
||||
const shouldBuildImage = type === ServerType.JELLYFIN || !noCredential;
|
||||
const tokenForImage = shouldBuildImage ? token : undefined;
|
||||
|
||||
const imageUrl = buildImageUrl({
|
||||
imageType: ImageType.PRIMARY,
|
||||
images: item.images,
|
||||
noCredential,
|
||||
remoteId: item.remoteId,
|
||||
token,
|
||||
type,
|
||||
url,
|
||||
});
|
||||
|
||||
const backdropImageUrl = buildImageUrl({
|
||||
imageType: ImageType.BACKDROP,
|
||||
images: item.images,
|
||||
noCredential,
|
||||
remoteId: item.remoteId,
|
||||
token,
|
||||
type,
|
||||
url,
|
||||
});
|
||||
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
sortName: item.sortName,
|
||||
releaseDate: item.releaseDate,
|
||||
releaseYear: item.releaseYear,
|
||||
isFavorite: item.favorites.length === 1,
|
||||
rating: rating(item.ratings),
|
||||
songCount: item._count.songs,
|
||||
type,
|
||||
imageUrl,
|
||||
backdropImageUrl: backdropImageUrl,
|
||||
deleted: item.deleted,
|
||||
remoteId: item.remoteId,
|
||||
remoteCreatedAt: item.remoteCreatedAt,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
genres: item.genres ? relatedGenres(item.genres) : [],
|
||||
albumArtists: item.albumArtists
|
||||
? relatedAlbumArtists(item.albumArtists)
|
||||
: [],
|
||||
artists: item.artists ? relatedArtists(item.artists) : [],
|
||||
serverFolders: relatedServerFolders(item.serverFolders),
|
||||
songs:
|
||||
item.songs &&
|
||||
songs(
|
||||
item?.songs?.map((s: any) => ({
|
||||
...s,
|
||||
album: { images: item?.images, ...relatedAlbum(item) },
|
||||
})),
|
||||
{
|
||||
deviceId: user.deviceId,
|
||||
token,
|
||||
type,
|
||||
url,
|
||||
userId: remoteUserId,
|
||||
},
|
||||
noCredential
|
||||
),
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const servers = (
|
||||
items: (Server & {
|
||||
serverFolders?: ServerFolder[];
|
||||
serverUrls?: (ServerUrl & {
|
||||
userServerUrls?: UserServerUrl[];
|
||||
})[];
|
||||
})[]
|
||||
) => {
|
||||
return (
|
||||
items.map((item) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
type: item.type,
|
||||
noCredential: item.noCredential,
|
||||
username: item.username,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
serverFolders:
|
||||
item.serverFolders && relatedServerFolders(item.serverFolders),
|
||||
serverUrls: item.serverUrls && relatedServerUrls(item.serverUrls),
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const relatedServers = (items: Server[]) => {
|
||||
const result = items.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
url: item.url,
|
||||
}));
|
||||
|
||||
return result || [];
|
||||
};
|
||||
|
||||
const relatedServerFolderPermissions = (items: ServerFolderPermission[]) => {
|
||||
return items.map((item) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
serverFolderId: item.serverFolderId,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const relatedServerPermissions = (items: ServerPermission[]) => {
|
||||
return items.map((item) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
serverId: item.serverId,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const relatedFile = (item: File) => {
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
name: item.fileName,
|
||||
path: item.path,
|
||||
type: item.type,
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
};
|
||||
|
||||
const users = (
|
||||
items: (User & {
|
||||
accessToken?: string;
|
||||
files?: File[];
|
||||
refreshToken?: string;
|
||||
serverFolderPermissions?: ServerFolderPermission[];
|
||||
serverPermissions?: ServerPermission[];
|
||||
})[]
|
||||
) => {
|
||||
return (
|
||||
items.map((item) => {
|
||||
const avatar = item.files?.find((f) => f.type === FileType.USER);
|
||||
|
||||
return {
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
||||
id: item.id,
|
||||
username: item.username,
|
||||
displayName: item.displayName,
|
||||
avatar: avatar ? relatedFile(avatar) : null,
|
||||
accessToken: item.accessToken,
|
||||
refreshToken: item.refreshToken,
|
||||
enabled: item.enabled,
|
||||
isAdmin: item.isAdmin,
|
||||
isSuperAdmin: item.isSuperAdmin,
|
||||
deviceId: item.deviceId,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
flatServerPermissions:
|
||||
item.serverPermissions && item.serverPermissions.map((s) => s.id),
|
||||
serverFolderPermissions:
|
||||
item.serverFolderPermissions &&
|
||||
relatedServerFolderPermissions(item.serverFolderPermissions),
|
||||
serverPermissions:
|
||||
item.serverPermissions &&
|
||||
relatedServerPermissions(item.serverPermissions),
|
||||
/* eslint-enable sort-keys-fix/sort-keys-fix */
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
};
|
||||
|
||||
const relatedUsers = (items: User[]) => {
|
||||
const result = items.map((item) => ({
|
||||
enabled: item.enabled,
|
||||
id: item.id,
|
||||
isAdmin: item.isAdmin,
|
||||
username: item.username,
|
||||
}));
|
||||
|
||||
return result || [];
|
||||
};
|
||||
|
||||
type DbTask = Task & DbTaskInclude;
|
||||
|
||||
type DbTaskInclude = {
|
||||
server: Server;
|
||||
user: User;
|
||||
};
|
||||
|
||||
const tasks = (options: { items: DbTask[] | any[] }) => {
|
||||
const { items } = options;
|
||||
|
||||
const result = items.map((item) => ({
|
||||
createdAt: item.createdAt,
|
||||
id: item.id,
|
||||
isCompleted: item.completed,
|
||||
isError: item.isError,
|
||||
message: item.message,
|
||||
server: item.server ? relatedServers([item.server])[0] : null,
|
||||
type: item.type,
|
||||
updatedAt: item.updatedAt,
|
||||
user: item.user ? relatedUsers([item.user])[0] : null,
|
||||
}));
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const toApiModel = {
|
||||
albums,
|
||||
genres,
|
||||
servers,
|
||||
songs,
|
||||
tasks,
|
||||
users,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { albumHelpers } from './albums.helpers';
|
||||
import { sharedHelpers } from './shared.helpers';
|
||||
import { songHelpers } from './songs.helpers';
|
||||
|
||||
export const helpers = {
|
||||
albums: albumHelpers,
|
||||
shared: sharedHelpers,
|
||||
songs: songHelpers,
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ServerPermissionType } from '@prisma/client';
|
||||
import { AuthUser } from '@/middleware';
|
||||
import { ApiError } from '@/utils';
|
||||
import { prisma } from '@lib/prisma';
|
||||
|
||||
const checkServerPermissions = (
|
||||
user: AuthUser,
|
||||
options: { serverId?: string }
|
||||
) => {
|
||||
const { serverId } = options;
|
||||
|
||||
if (user.isAdmin || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverId && !user.flatServerPermissions.includes(serverId)) {
|
||||
throw ApiError.forbidden();
|
||||
}
|
||||
};
|
||||
|
||||
const checkServerFolderPermissions = (
|
||||
user: AuthUser,
|
||||
options: { serverFolderId?: string[] | string; serverId: string }
|
||||
) => {
|
||||
const { serverFolderId, serverId } = options;
|
||||
|
||||
if (user.isAdmin || !serverFolderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isServerAdmin =
|
||||
user.serverPermissions.find((s) => s.serverId === serverId)?.type ===
|
||||
ServerPermissionType.ADMIN;
|
||||
|
||||
if (isServerAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ids: string[] = [];
|
||||
if (typeof serverFolderId === 'string') {
|
||||
ids = [serverFolderId];
|
||||
} else if (typeof serverFolderId === 'object') {
|
||||
ids = serverFolderId;
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
if (!user.flatServerFolderPermissions.includes(id)) {
|
||||
throw ApiError.forbidden('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableServerFolderIds = async (
|
||||
user: AuthUser,
|
||||
options: { serverId: string }
|
||||
) => {
|
||||
const { serverId } = options;
|
||||
|
||||
if (user.isAdmin) {
|
||||
const serverFoldersWithAccess = await prisma.serverFolder.findMany({
|
||||
where: { enabled: true, serverId },
|
||||
});
|
||||
|
||||
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
|
||||
(serverFolder) => serverFolder.id
|
||||
);
|
||||
|
||||
return serverFoldersWithAccessIds;
|
||||
}
|
||||
|
||||
const serverFoldersWithAccess = await prisma.serverFolder.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
server: {
|
||||
serverPermissions: {
|
||||
some: { type: ServerPermissionType.ADMIN, userId: user.id },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
enabled: true,
|
||||
serverFolderPermissions: {
|
||||
some: { userId: { equals: user.id } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
|
||||
(serverFolder) => serverFolder.id
|
||||
);
|
||||
|
||||
return serverFoldersWithAccessIds;
|
||||
};
|
||||
|
||||
const serverFolderFilter = (serverFolderIds: string[]) => {
|
||||
return {
|
||||
serverFolders: { every: { id: { in: serverFolderIds } } },
|
||||
};
|
||||
};
|
||||
|
||||
const paginationParams = (options: { skip: any; take: any }) => {
|
||||
const { skip, take } = options;
|
||||
|
||||
return {
|
||||
skip: Number(skip),
|
||||
take: Number(take),
|
||||
};
|
||||
};
|
||||
|
||||
export const sharedHelpers = {
|
||||
checkServerFolderPermissions,
|
||||
checkServerPermissions,
|
||||
getAvailableServerFolderIds,
|
||||
params: {
|
||||
pagination: paginationParams,
|
||||
},
|
||||
serverFolderFilter,
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '@middleware/authenticate';
|
||||
|
||||
const include = () => {
|
||||
const props: Prisma.SongInclude = {
|
||||
album: true,
|
||||
artists: true,
|
||||
externals: true,
|
||||
genres: true,
|
||||
images: true,
|
||||
ratings: true,
|
||||
server: {
|
||||
include: { serverUrls: true },
|
||||
},
|
||||
};
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
const findMany = (user: AuthUser) => {
|
||||
const props: Prisma.SongFindManyArgs = {
|
||||
include: {
|
||||
album: true,
|
||||
artists: true,
|
||||
externals: true,
|
||||
genres: true,
|
||||
images: true,
|
||||
ratings: true,
|
||||
server: {
|
||||
include: {
|
||||
serverUrls: {
|
||||
where: { userServerUrls: { some: { userId: user.id } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
// { albumId: Prisma.SortOrder.asc },
|
||||
{ discNumber: Prisma.SortOrder.asc },
|
||||
{ trackNumber: Prisma.SortOrder.asc },
|
||||
],
|
||||
};
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
export const songHelpers = {
|
||||
findMany,
|
||||
include,
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './prisma';
|
||||
export { default as throttle } from './throttle';
|
||||
@@ -0,0 +1,99 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import passport from 'passport';
|
||||
import {
|
||||
Strategy as JwtStrategy,
|
||||
ExtractJwt,
|
||||
StrategyOptions,
|
||||
} from 'passport-jwt';
|
||||
import { Strategy as LocalStrategy } from 'passport-local';
|
||||
import { prisma } from './prisma';
|
||||
|
||||
export const generateToken = (
|
||||
id: string,
|
||||
otherProperties?: { [key: string]: any }
|
||||
) => {
|
||||
return jwt.sign(
|
||||
{ id, ...otherProperties },
|
||||
String(process.env.TOKEN_SECRET),
|
||||
{
|
||||
expiresIn: String(process.env.TOKEN_EXPIRATION || '15m'),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const generateRefreshToken = (
|
||||
id: string,
|
||||
otherProperties?: { [key: string]: any }
|
||||
) => {
|
||||
return jwt.sign(
|
||||
{ id, ...otherProperties },
|
||||
String(process.env.TOKEN_SECRET),
|
||||
{
|
||||
expiresIn: String(process.env.TOKEN_REFRESH_EXPIRATION || '90d'),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const authenticateUser = async (
|
||||
username: string,
|
||||
password: string,
|
||||
done: any
|
||||
) => {
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
|
||||
if (user === null || user === undefined) {
|
||||
return done(null, false);
|
||||
}
|
||||
|
||||
if (!user.enabled) {
|
||||
return done(null, false, { message: 'The user is not enabled.' });
|
||||
}
|
||||
|
||||
if (await bcrypt.compare(password, user.password)) {
|
||||
return done(null, user);
|
||||
}
|
||||
|
||||
return done(null, false, { message: 'Invalid credentials.' });
|
||||
};
|
||||
|
||||
passport.use(new LocalStrategy(authenticateUser));
|
||||
|
||||
const jwtOptions: StrategyOptions = {
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: String(process.env.TOKEN_SECRET),
|
||||
};
|
||||
|
||||
passport.use(
|
||||
new JwtStrategy(jwtOptions, async (jwt_payload: any, done: any) => {
|
||||
await prisma.user
|
||||
.findUnique({
|
||||
include: {
|
||||
serverFolderPermissions: true,
|
||||
serverPermissions: true,
|
||||
},
|
||||
where: { id: jwt_payload.id },
|
||||
})
|
||||
.then((user) => {
|
||||
return done(null, user);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err.message);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
passport.serializeUser((user: any, done) => {
|
||||
return done(null, user.id);
|
||||
});
|
||||
|
||||
passport.deserializeUser(async (id: string, done) => {
|
||||
return done(
|
||||
null,
|
||||
await prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient({ errorFormat: 'minimal' });
|
||||
export const exclude = <T, Key extends keyof T>(
|
||||
resultSet: T,
|
||||
...keys: Key[]
|
||||
): Omit<T, Key> => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const key of keys) {
|
||||
delete resultSet[key];
|
||||
}
|
||||
return resultSet;
|
||||
};
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
prisma.$use(async (params, next) => {
|
||||
const maxRetries = 3;
|
||||
let retries = 0;
|
||||
|
||||
do {
|
||||
try {
|
||||
const result = await next(params);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.log('err', err);
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2002') {
|
||||
retries = 3; // Don't retry on unique constraint violation
|
||||
return null;
|
||||
}
|
||||
}
|
||||
retries += 1;
|
||||
return sleep(100);
|
||||
}
|
||||
} while (retries < maxRetries);
|
||||
});
|
||||
|
||||
// prisma.$use(async (params, next) => {
|
||||
// const before = Date.now();
|
||||
|
||||
// const result = await next(params);
|
||||
|
||||
// const after = Date.now();
|
||||
|
||||
// console.log(
|
||||
// `Query ${params.model}.${params.action} took ${after - before}ms`
|
||||
// );
|
||||
|
||||
// return result;
|
||||
// });
|
||||
@@ -0,0 +1,8 @@
|
||||
import pThrottle from 'p-throttle';
|
||||
|
||||
const throttle = pThrottle({
|
||||
interval: 1000,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
export default throttle;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const authenticateAdmin = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (!req.authUser.isAdmin) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This action requires an administrator account.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ServerPermission, ServerPermissionType } from '@prisma/client';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const authenticateServerAdmin = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (!req.params.serverId) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Server id is required.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const permission = req.authUser.serverPermissions.find(
|
||||
(p: ServerPermission) => p.serverId === req.params.serverId
|
||||
)?.type;
|
||||
|
||||
if (permission !== ServerPermissionType.ADMIN) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This action requires "Admin" server permissions.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ServerPermission, ServerPermissionType } from '@prisma/client';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const authenticateServerEditor = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (!req.params.serverId) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Server id is required.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const permission = req.authUser.serverPermissions.find(
|
||||
(p: ServerPermission) => p.serverId === req.params.serverId
|
||||
)?.type;
|
||||
|
||||
if (
|
||||
permission !== ServerPermissionType.EDITOR &&
|
||||
permission !== ServerPermissionType.ADMIN
|
||||
) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This action requires "Editor" server permissions.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ServerPermission, ServerPermissionType } from '@prisma/client';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const authenticateServerViewer = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (!req.params.serverId) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Server id is required.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.authUser.isAdmin || req.authUser.isSuperAdmin) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const permission = req.authUser.serverPermissions.find(
|
||||
(p: ServerPermission) => p.serverId === req.params.serverId
|
||||
)?.type;
|
||||
|
||||
if (permission === undefined) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This action requires "Viewer" server permissions.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const authenticateSuperAdmin = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
if (!req.authUser.isSuperAdmin) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'This action requires an administrator account.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ServerFolderPermission, ServerPermission, User } from '@prisma/client';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import passport from 'passport';
|
||||
|
||||
export type AuthUser = Request['authUser'];
|
||||
|
||||
export const authenticate = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
passport.authenticate('jwt', { session: false }, (err, user, info) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: info?.message || 'Invalid authorization.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.enabled) {
|
||||
return res.status(401).json({
|
||||
error: {
|
||||
message: 'Your account is not enabled.',
|
||||
path: req.path,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const flatServerFolderPermissions = user.serverFolderPermissions.map(
|
||||
(permission: ServerFolderPermission) => permission.serverFolderId
|
||||
);
|
||||
|
||||
const flatServerPermissions = user.serverPermissions.map(
|
||||
(permission: ServerPermission) => permission.serverId
|
||||
);
|
||||
|
||||
const props = {
|
||||
createdAt: user?.createdAt,
|
||||
deviceId: user?.deviceId,
|
||||
enabled: user?.enabled,
|
||||
flatServerFolderPermissions,
|
||||
flatServerPermissions,
|
||||
id: user?.id,
|
||||
isAdmin: user?.isAdmin,
|
||||
isSuperAdmin: user?.isSuperAdmin,
|
||||
serverFolderPermissions: user?.serverFolderPermissions,
|
||||
serverId: req.params.serverId,
|
||||
serverPermissions: user?.serverPermissions,
|
||||
updatedAt: user?.updatedAt,
|
||||
username: user?.username,
|
||||
};
|
||||
|
||||
req.authUser = props;
|
||||
|
||||
return next();
|
||||
})(req, res, next);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { isJsonString } from '@utils/is-json-string';
|
||||
|
||||
export const errorHandler = (
|
||||
err: any,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
let message = '';
|
||||
|
||||
const trace = err.stack?.match(/at .* \(.*\)/g).map((e: string) => {
|
||||
return e.replace(/\(|\)/g, '');
|
||||
});
|
||||
|
||||
if (err.message) {
|
||||
message = isJsonString(err.message)
|
||||
? Array.isArray(JSON.parse(err.message))
|
||||
? JSON.parse(err.message)[0].message // Handles errors sent from zod preprocess
|
||||
: JSON.parse(err.message)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
res.status(err.statusCode || 500).json({
|
||||
error: {
|
||||
message,
|
||||
path: req.path,
|
||||
trace,
|
||||
},
|
||||
response: 'Error',
|
||||
statusCode: err.statusCode || 500,
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './error-handler';
|
||||
export * from './authenticate';
|
||||
export * from './authenticate-admin';
|
||||
export * from './authenticate-super-admin';
|
||||
export * from './authenticate-server-admin';
|
||||
export * from './authenticate-server-editor';
|
||||
export * from './authenticate-server-viewer';
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "feishin-server",
|
||||
"version": "0.0.1-alpha1",
|
||||
"description": "A full-featured Subsonic/Jellyfin compatible music player",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon --legacy-watch -e ts,js --exec ts-node -r tsconfig-paths/register server.ts",
|
||||
"prod": "ts-node --transpileOnly -r tsconfig-paths/register server.ts",
|
||||
"dev:debug": "nodemon --config nodemon.json --inspect-brk server.ts",
|
||||
"build": "tsc --project . && tsconfig-replace-paths --project tsconfig.json"
|
||||
},
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
"navidrome",
|
||||
"airsonic",
|
||||
"jellyfin",
|
||||
"react",
|
||||
"electron"
|
||||
],
|
||||
"author": {
|
||||
"name": "jeffvli",
|
||||
"url": "https://github.com/jeffvli/"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"devDependencies": {
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/better-queue": "^3.8.3",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.14",
|
||||
"@types/lodash": "^4.14.186",
|
||||
"@types/md5": "^2.3.2",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^18.8.4",
|
||||
"@types/passport-jwt": "^3.0.7",
|
||||
"@types/passport-local": "^1.0.34",
|
||||
"@types/sharp": "^0.31.0",
|
||||
"@typescript-eslint/parser": "^5.40.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-erb": "^4.0.3",
|
||||
"eslint-import-resolver-typescript": "^2.7.1",
|
||||
"eslint-plugin-compat": "^4.0.2",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^27.1.3",
|
||||
"eslint-plugin-n": "^15.3.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-sort-keys-fix": "^1.1.2",
|
||||
"eslint-plugin-typescript-sort-keys": "^2.1.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"prisma": "^4.5.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"tsconfig-replace-paths": "^0.0.11",
|
||||
"typescript": "^4.8.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^4.5.0",
|
||||
"axios": "^0.27.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-queue": "^3.8.12",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"p-throttle": "^4.1.1",
|
||||
"passport": "^0.4.1",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"sharp": "^0.31.2",
|
||||
"socket.io": "^4.5.3",
|
||||
"zod": "^3.19.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:16.5-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./ ./prisma/
|
||||
|
||||
CMD ["npx", "prisma", "studio"]
|
||||
@@ -0,0 +1,938 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServerType" AS ENUM ('SUBSONIC', 'JELLYFIN', 'NAVIDROME');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ServerPermissionType" AS ENUM ('ADMIN', 'EDITOR', 'VIEWER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ExternalSource" AS ENUM ('MUSICBRAINZ', 'LASTFM', 'THEAUDIODB', 'SPOTIFY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ExternalType" AS ENUM ('ID', 'LINK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ImageType" AS ENUM ('PRIMARY', 'BACKDROP', 'LOGO', 'SCREENSHOT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TaskType" AS ENUM ('FULL_SCAN', 'QUICK_SCAN', 'REFRESH', 'SPOTIFY', 'MUSICBRAINZ', 'LASTFM');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"token" TEXT NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "History" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"userId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "History_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Server" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"remoteUserId" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"type" "ServerType" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Server_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Folder" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"parentId" UUID,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServerPermission" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"type" "ServerPermissionType" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "ServerPermission_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServerUrl" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"url" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "ServerUrl_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserServerUrl" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"serverUrlId" UUID NOT NULL,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "UserServerUrl_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServerFolder" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"remoteId" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastScannedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"deleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "ServerFolder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ServerFolderPermission" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"serverFolderId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "ServerFolderPermission_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Genre" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Genre_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AlbumArtistFavorite" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"albumArtistId" UUID NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "AlbumArtistFavorite_pkey" PRIMARY KEY ("userId","albumArtistId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArtistFavorite" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"artistId" UUID NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "ArtistFavorite_pkey" PRIMARY KEY ("userId","artistId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AlbumFavorite" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"albumId" UUID NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "AlbumFavorite_pkey" PRIMARY KEY ("userId","albumId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SongFavorite" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"songId" UUID NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "SongFavorite_pkey" PRIMARY KEY ("userId","songId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AlbumArtistRating" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"albumArtistId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "AlbumArtistRating_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArtistRating" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"artistId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "ArtistRating_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AlbumRating" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"albumId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "AlbumRating_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SongRating" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"value" DOUBLE PRECISION NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"songId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "SongRating_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Image" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"url" TEXT,
|
||||
"remoteUrl" TEXT NOT NULL,
|
||||
"type" "ImageType" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Image_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "External" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"value" TEXT NOT NULL,
|
||||
"type" "ExternalType" NOT NULL,
|
||||
"source" "ExternalSource" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "External_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AlbumArtist" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"sortName" TEXT NOT NULL,
|
||||
"biography" TEXT,
|
||||
"remoteId" TEXT NOT NULL,
|
||||
"remoteCreatedAt" TIMESTAMP(3),
|
||||
"deleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "AlbumArtist_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Album" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"sortName" TEXT NOT NULL,
|
||||
"releaseDate" TIMESTAMP(3),
|
||||
"releaseYear" INTEGER,
|
||||
"remoteId" TEXT NOT NULL,
|
||||
"remoteCreatedAt" TIMESTAMP(3),
|
||||
"deleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "Album_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Artist" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"sortName" TEXT NOT NULL,
|
||||
"biography" TEXT,
|
||||
"remoteId" TEXT NOT NULL,
|
||||
"remoteCreatedAt" TIMESTAMP(3),
|
||||
"deleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "Artist_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Song" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"sortName" TEXT NOT NULL,
|
||||
"releaseDate" TIMESTAMP(3),
|
||||
"releaseYear" INTEGER,
|
||||
"duration" DOUBLE PRECISION NOT NULL,
|
||||
"size" INTEGER,
|
||||
"lyrics" TEXT,
|
||||
"bitRate" INTEGER NOT NULL,
|
||||
"container" TEXT NOT NULL,
|
||||
"discNumber" INTEGER NOT NULL DEFAULT 1,
|
||||
"trackNumber" INTEGER,
|
||||
"artistName" TEXT,
|
||||
"remoteId" TEXT NOT NULL,
|
||||
"remoteCreatedAt" TIMESTAMP(3),
|
||||
"deleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"albumArtistId" UUID,
|
||||
"albumId" UUID,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "Song_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Task" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "TaskType" NOT NULL,
|
||||
"message" TEXT,
|
||||
"progress" TEXT,
|
||||
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isError" BOOLEAN DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"serverId" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_HistoryToSong" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_FolderToSong" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_FolderToServerFolder" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ServerFolderToSong" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_GenreToSong" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ImageToSong" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ExternalToSong" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumArtistToGenre" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumArtistToExternal" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumArtistToServerFolder" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumArtistToImage" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumToGenre" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumToArtist" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumToAlbumArtist" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumToExternal" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumToServerFolder" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_AlbumToImage" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ArtistToGenre" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ArtistToSong" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ArtistToExternal" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ArtistToServerFolder" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_ArtistToImage" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_deviceId_key" ON "User"("deviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Server_url_key" ON "Server"("url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Folder_path_key" ON "Folder"("path");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Folder_serverId_path_key" ON "Folder"("serverId", "path");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServerPermission_userId_serverId_key" ON "ServerPermission"("userId", "serverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServerUrl_serverId_url_key" ON "ServerUrl"("serverId", "url");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserServerUrl_userId_serverId_key" ON "UserServerUrl"("userId", "serverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServerFolder_remoteId_key" ON "ServerFolder"("remoteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServerFolder_serverId_remoteId_key" ON "ServerFolder"("serverId", "remoteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ServerFolderPermission_userId_serverFolderId_key" ON "ServerFolderPermission"("userId", "serverFolderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AlbumArtistFavorite_userId_albumArtistId_key" ON "AlbumArtistFavorite"("userId", "albumArtistId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ArtistFavorite_userId_artistId_key" ON "ArtistFavorite"("userId", "artistId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AlbumFavorite_userId_albumId_key" ON "AlbumFavorite"("userId", "albumId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SongFavorite_userId_songId_key" ON "SongFavorite"("userId", "songId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AlbumArtistRating_userId_albumArtistId_key" ON "AlbumArtistRating"("userId", "albumArtistId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ArtistRating_userId_artistId_key" ON "ArtistRating"("userId", "artistId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AlbumRating_userId_albumId_key" ON "AlbumRating"("userId", "albumId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SongRating_userId_songId_key" ON "SongRating"("userId", "songId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Image_remoteUrl_type_key" ON "Image"("remoteUrl", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "External_value_source_key" ON "External"("value", "source");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AlbumArtist_serverId_remoteId_key" ON "AlbumArtist"("serverId", "remoteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Album_serverId_remoteId_key" ON "Album"("serverId", "remoteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Artist_serverId_remoteId_key" ON "Artist"("serverId", "remoteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Song_serverId_remoteId_key" ON "Song"("serverId", "remoteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_HistoryToSong_AB_unique" ON "_HistoryToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_HistoryToSong_B_index" ON "_HistoryToSong"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_FolderToSong_AB_unique" ON "_FolderToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_FolderToSong_B_index" ON "_FolderToSong"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_FolderToServerFolder_AB_unique" ON "_FolderToServerFolder"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_FolderToServerFolder_B_index" ON "_FolderToServerFolder"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ServerFolderToSong_AB_unique" ON "_ServerFolderToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ServerFolderToSong_B_index" ON "_ServerFolderToSong"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_GenreToSong_AB_unique" ON "_GenreToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_GenreToSong_B_index" ON "_GenreToSong"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ImageToSong_AB_unique" ON "_ImageToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ImageToSong_B_index" ON "_ImageToSong"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ExternalToSong_AB_unique" ON "_ExternalToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ExternalToSong_B_index" ON "_ExternalToSong"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumArtistToGenre_AB_unique" ON "_AlbumArtistToGenre"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumArtistToGenre_B_index" ON "_AlbumArtistToGenre"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumArtistToExternal_AB_unique" ON "_AlbumArtistToExternal"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumArtistToExternal_B_index" ON "_AlbumArtistToExternal"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumArtistToServerFolder_AB_unique" ON "_AlbumArtistToServerFolder"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumArtistToServerFolder_B_index" ON "_AlbumArtistToServerFolder"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumArtistToImage_AB_unique" ON "_AlbumArtistToImage"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumArtistToImage_B_index" ON "_AlbumArtistToImage"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumToGenre_AB_unique" ON "_AlbumToGenre"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumToGenre_B_index" ON "_AlbumToGenre"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumToArtist_AB_unique" ON "_AlbumToArtist"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumToArtist_B_index" ON "_AlbumToArtist"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumToAlbumArtist_AB_unique" ON "_AlbumToAlbumArtist"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumToAlbumArtist_B_index" ON "_AlbumToAlbumArtist"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumToExternal_AB_unique" ON "_AlbumToExternal"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumToExternal_B_index" ON "_AlbumToExternal"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumToServerFolder_AB_unique" ON "_AlbumToServerFolder"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumToServerFolder_B_index" ON "_AlbumToServerFolder"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_AlbumToImage_AB_unique" ON "_AlbumToImage"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_AlbumToImage_B_index" ON "_AlbumToImage"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ArtistToGenre_AB_unique" ON "_ArtistToGenre"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ArtistToGenre_B_index" ON "_ArtistToGenre"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ArtistToSong_AB_unique" ON "_ArtistToSong"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ArtistToSong_B_index" ON "_ArtistToSong"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ArtistToExternal_AB_unique" ON "_ArtistToExternal"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ArtistToExternal_B_index" ON "_ArtistToExternal"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ArtistToServerFolder_AB_unique" ON "_ArtistToServerFolder"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ArtistToServerFolder_B_index" ON "_ArtistToServerFolder"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_ArtistToImage_AB_unique" ON "_ArtistToImage"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_ArtistToImage_B_index" ON "_ArtistToImage"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "History" ADD CONSTRAINT "History_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServerPermission" ADD CONSTRAINT "ServerPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServerPermission" ADD CONSTRAINT "ServerPermission_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServerUrl" ADD CONSTRAINT "ServerUrl_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserServerUrl" ADD CONSTRAINT "UserServerUrl_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserServerUrl" ADD CONSTRAINT "UserServerUrl_serverUrlId_fkey" FOREIGN KEY ("serverUrlId") REFERENCES "ServerUrl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserServerUrl" ADD CONSTRAINT "UserServerUrl_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServerFolder" ADD CONSTRAINT "ServerFolder_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServerFolderPermission" ADD CONSTRAINT "ServerFolderPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ServerFolderPermission" ADD CONSTRAINT "ServerFolderPermission_serverFolderId_fkey" FOREIGN KEY ("serverFolderId") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumArtistFavorite" ADD CONSTRAINT "AlbumArtistFavorite_albumArtistId_fkey" FOREIGN KEY ("albumArtistId") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumArtistFavorite" ADD CONSTRAINT "AlbumArtistFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ArtistFavorite" ADD CONSTRAINT "ArtistFavorite_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ArtistFavorite" ADD CONSTRAINT "ArtistFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumFavorite" ADD CONSTRAINT "AlbumFavorite_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumFavorite" ADD CONSTRAINT "AlbumFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SongFavorite" ADD CONSTRAINT "SongFavorite_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SongFavorite" ADD CONSTRAINT "SongFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumArtistRating" ADD CONSTRAINT "AlbumArtistRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumArtistRating" ADD CONSTRAINT "AlbumArtistRating_albumArtistId_fkey" FOREIGN KEY ("albumArtistId") REFERENCES "AlbumArtist"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ArtistRating" ADD CONSTRAINT "ArtistRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ArtistRating" ADD CONSTRAINT "ArtistRating_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumRating" ADD CONSTRAINT "AlbumRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumRating" ADD CONSTRAINT "AlbumRating_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SongRating" ADD CONSTRAINT "SongRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SongRating" ADD CONSTRAINT "SongRating_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlbumArtist" ADD CONSTRAINT "AlbumArtist_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Album" ADD CONSTRAINT "Album_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Artist" ADD CONSTRAINT "Artist_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Song" ADD CONSTRAINT "Song_albumArtistId_fkey" FOREIGN KEY ("albumArtistId") REFERENCES "AlbumArtist"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Song" ADD CONSTRAINT "Song_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Song" ADD CONSTRAINT "Song_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_HistoryToSong" ADD CONSTRAINT "_HistoryToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "History"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_HistoryToSong" ADD CONSTRAINT "_HistoryToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FolderToSong" ADD CONSTRAINT "_FolderToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FolderToSong" ADD CONSTRAINT "_FolderToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FolderToServerFolder" ADD CONSTRAINT "_FolderToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FolderToServerFolder" ADD CONSTRAINT "_FolderToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ServerFolderToSong" ADD CONSTRAINT "_ServerFolderToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ServerFolderToSong" ADD CONSTRAINT "_ServerFolderToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_GenreToSong" ADD CONSTRAINT "_GenreToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_GenreToSong" ADD CONSTRAINT "_GenreToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageToSong" ADD CONSTRAINT "_ImageToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ImageToSong" ADD CONSTRAINT "_ImageToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ExternalToSong" ADD CONSTRAINT "_ExternalToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ExternalToSong" ADD CONSTRAINT "_ExternalToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToGenre" ADD CONSTRAINT "_AlbumArtistToGenre_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToGenre" ADD CONSTRAINT "_AlbumArtistToGenre_B_fkey" FOREIGN KEY ("B") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToExternal" ADD CONSTRAINT "_AlbumArtistToExternal_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToExternal" ADD CONSTRAINT "_AlbumArtistToExternal_B_fkey" FOREIGN KEY ("B") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToServerFolder" ADD CONSTRAINT "_AlbumArtistToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToServerFolder" ADD CONSTRAINT "_AlbumArtistToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToImage" ADD CONSTRAINT "_AlbumArtistToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumArtistToImage" ADD CONSTRAINT "_AlbumArtistToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToGenre" ADD CONSTRAINT "_AlbumToGenre_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToGenre" ADD CONSTRAINT "_AlbumToGenre_B_fkey" FOREIGN KEY ("B") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToArtist" ADD CONSTRAINT "_AlbumToArtist_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToArtist" ADD CONSTRAINT "_AlbumToArtist_B_fkey" FOREIGN KEY ("B") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToAlbumArtist" ADD CONSTRAINT "_AlbumToAlbumArtist_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToAlbumArtist" ADD CONSTRAINT "_AlbumToAlbumArtist_B_fkey" FOREIGN KEY ("B") REFERENCES "AlbumArtist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToExternal" ADD CONSTRAINT "_AlbumToExternal_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToExternal" ADD CONSTRAINT "_AlbumToExternal_B_fkey" FOREIGN KEY ("B") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToServerFolder" ADD CONSTRAINT "_AlbumToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToServerFolder" ADD CONSTRAINT "_AlbumToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToImage" ADD CONSTRAINT "_AlbumToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_AlbumToImage" ADD CONSTRAINT "_AlbumToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToGenre" ADD CONSTRAINT "_ArtistToGenre_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToGenre" ADD CONSTRAINT "_ArtistToGenre_B_fkey" FOREIGN KEY ("B") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToSong" ADD CONSTRAINT "_ArtistToSong_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToSong" ADD CONSTRAINT "_ArtistToSong_B_fkey" FOREIGN KEY ("B") REFERENCES "Song"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToExternal" ADD CONSTRAINT "_ArtistToExternal_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToExternal" ADD CONSTRAINT "_ArtistToExternal_B_fkey" FOREIGN KEY ("B") REFERENCES "External"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToServerFolder" ADD CONSTRAINT "_ArtistToServerFolder_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToServerFolder" ADD CONSTRAINT "_ArtistToServerFolder_B_fkey" FOREIGN KEY ("B") REFERENCES "ServerFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToImage" ADD CONSTRAINT "_ArtistToImage_A_fkey" FOREIGN KEY ("A") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_ArtistToImage" ADD CONSTRAINT "_ArtistToImage_B_fkey" FOREIGN KEY ("B") REFERENCES "Image"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Server" ADD COLUMN "noCredential" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `name` on the `Task` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `progress` on the `Task` table. All the data in the column will be lost.
|
||||
- Made the column `isError` on table `Task` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" DROP COLUMN "name",
|
||||
DROP COLUMN "progress",
|
||||
ADD COLUMN "userId" UUID,
|
||||
ALTER COLUMN "isError" SET NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[displayName]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Song" ADD COLUMN "skip" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "displayName" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_displayName_key" ON "User"("displayName");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "isSuperAdmin" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FileType" AS ENUM ('ALBUM', 'SONG', 'AUDIO', 'USER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "File" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"path" TEXT NOT NULL,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"type" "FileType" NOT NULL,
|
||||
"userId" UUID,
|
||||
|
||||
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "File_path_key" ON "File"("path");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "File_fileName_key" ON "File"("fileName");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "File_userId_type_key" ON "File"("userId", "type");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `skip` on the `Song` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "ServerFolder_remoteId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Song" DROP COLUMN "skip",
|
||||
ALTER COLUMN "duration" DROP NOT NULL,
|
||||
ALTER COLUMN "bitRate" DROP NOT NULL,
|
||||
ALTER COLUMN "discNumber" DROP NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,543 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["fullTextSearch", "orderByNulls", "filteredRelationCount", "fieldReference"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum ServerType {
|
||||
SUBSONIC
|
||||
JELLYFIN
|
||||
NAVIDROME
|
||||
}
|
||||
|
||||
enum ServerPermissionType {
|
||||
ADMIN
|
||||
EDITOR
|
||||
VIEWER
|
||||
}
|
||||
|
||||
enum ExternalSource {
|
||||
MUSICBRAINZ
|
||||
LASTFM
|
||||
THEAUDIODB
|
||||
SPOTIFY
|
||||
}
|
||||
|
||||
enum ExternalType {
|
||||
ID
|
||||
LINK
|
||||
}
|
||||
|
||||
enum ImageType {
|
||||
PRIMARY
|
||||
BACKDROP
|
||||
LOGO
|
||||
SCREENSHOT
|
||||
}
|
||||
|
||||
enum TaskType {
|
||||
FULL_SCAN
|
||||
QUICK_SCAN
|
||||
REFRESH
|
||||
SPOTIFY
|
||||
MUSICBRAINZ
|
||||
LASTFM
|
||||
}
|
||||
|
||||
enum FileType {
|
||||
ALBUM
|
||||
SONG
|
||||
AUDIO
|
||||
USER
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
token String @unique
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
displayName String? @unique
|
||||
username String @unique
|
||||
password String
|
||||
enabled Boolean @default(false)
|
||||
isAdmin Boolean @default(false)
|
||||
isSuperAdmin Boolean @default(false)
|
||||
deviceId String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
histories History[]
|
||||
albumArtistRatings AlbumArtistRating[]
|
||||
artistRatings ArtistRating[]
|
||||
albumRatings AlbumRating[]
|
||||
songRatings SongRating[]
|
||||
refreshTokens RefreshToken[]
|
||||
files File[]
|
||||
|
||||
serverFolderPermissions ServerFolderPermission[]
|
||||
serverPermissions ServerPermission[]
|
||||
albumArtistFavorites AlbumArtistFavorite[]
|
||||
artistFavorites ArtistFavorite[]
|
||||
albumFavorites AlbumFavorite[]
|
||||
songFavorites SongFavorite[]
|
||||
userServerUrls UserServerUrl[]
|
||||
tasks Task[]
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
path String @unique
|
||||
originalName String
|
||||
fileName String @unique
|
||||
size Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
type FileType
|
||||
|
||||
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String? @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, type], name: "uniqueFileId")
|
||||
}
|
||||
|
||||
model History {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
|
||||
songs Song[]
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
}
|
||||
|
||||
model Server {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
url String @unique
|
||||
remoteUserId String
|
||||
username String
|
||||
token String
|
||||
noCredential Boolean @default(true)
|
||||
type ServerType
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
albumArtists AlbumArtist[]
|
||||
artists Artist[]
|
||||
albums Album[]
|
||||
songs Song[]
|
||||
serverFolders ServerFolder[]
|
||||
serverUrls ServerUrl[]
|
||||
folders Folder[]
|
||||
serverPermissions ServerPermission[]
|
||||
tasks Task[]
|
||||
userServerUrls UserServerUrl[]
|
||||
}
|
||||
|
||||
model Folder {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
path String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
songs Song[]
|
||||
serverFolders ServerFolder[]
|
||||
|
||||
parentId String? @db.Uuid
|
||||
parent Folder? @relation("FolderChildren", fields: [parentId], references: [id])
|
||||
children Folder[] @relation("FolderChildren")
|
||||
|
||||
Server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [serverId, path], name: "uniqueFolderId")
|
||||
}
|
||||
|
||||
model ServerPermission {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
type ServerPermissionType
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, serverId], name: "uniqueServerPermissionsId")
|
||||
}
|
||||
|
||||
model ServerUrl {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
url String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
userServerUrls UserServerUrl[]
|
||||
|
||||
@@unique(fields: [serverId, url], name: "uniqueServerUrlId")
|
||||
}
|
||||
|
||||
model UserServerUrl {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
|
||||
serverUrl ServerUrl @relation(fields: [serverUrlId], references: [id], onDelete: Cascade)
|
||||
serverUrlId String @db.Uuid
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, serverId], name: "uniqueUserServerUrlId")
|
||||
}
|
||||
|
||||
model ServerFolder {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
remoteId String
|
||||
enabled Boolean @default(true)
|
||||
lastScannedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deleted Boolean @default(false)
|
||||
|
||||
albumArtists AlbumArtist[]
|
||||
artists Artist[]
|
||||
albums Album[]
|
||||
songs Song[]
|
||||
folders Folder[]
|
||||
serverFolderPermissions ServerFolderPermission[]
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [serverId, remoteId], name: "uniqueServerFolderId")
|
||||
}
|
||||
|
||||
model ServerFolderPermission {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @db.Uuid
|
||||
|
||||
serverFolder ServerFolder @relation(fields: [serverFolderId], references: [id], onDelete: Cascade)
|
||||
serverFolderId String @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, serverFolderId], name: "uniqueServerFolderPermissionsId")
|
||||
}
|
||||
|
||||
model Genre {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
albumArtists AlbumArtist[]
|
||||
artists Artist[]
|
||||
albums Album[]
|
||||
songs Song[]
|
||||
}
|
||||
|
||||
model AlbumArtistFavorite {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
albumArtist AlbumArtist @relation(fields: [albumArtistId], references: [id], onDelete: Cascade)
|
||||
albumArtistId String @db.Uuid
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
|
||||
@@id([userId, albumArtistId])
|
||||
@@unique(fields: [userId, albumArtistId], name: "uniqueAlbumArtistFavoriteId")
|
||||
}
|
||||
|
||||
model ArtistFavorite {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||||
artistId String @db.Uuid
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
|
||||
@@id([userId, artistId])
|
||||
@@unique(fields: [userId, artistId], name: "uniqueArtistFavoriteId")
|
||||
}
|
||||
|
||||
model AlbumFavorite {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||
albumId String @db.Uuid
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
|
||||
@@id([userId, albumId])
|
||||
@@unique(fields: [userId, albumId], name: "uniqueAlbumFavoriteId")
|
||||
}
|
||||
|
||||
model SongFavorite {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||
songId String @db.Uuid
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
|
||||
@@id([userId, songId])
|
||||
@@unique(fields: [userId, songId], name: "uniqueSongFavoriteId")
|
||||
}
|
||||
|
||||
model AlbumArtistRating {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
value Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @db.Uuid
|
||||
|
||||
albumArtist AlbumArtist @relation(fields: [albumArtistId], references: [id])
|
||||
albumArtistId String @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, albumArtistId], name: "uniqueAlbumArtistRatingId")
|
||||
}
|
||||
|
||||
model ArtistRating {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
value Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @db.Uuid
|
||||
|
||||
artist Artist @relation(fields: [artistId], references: [id])
|
||||
artistId String @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, artistId], name: "uniqueArtistRatingId")
|
||||
}
|
||||
|
||||
model AlbumRating {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
value Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @db.Uuid
|
||||
|
||||
album Album @relation(fields: [albumId], references: [id])
|
||||
albumId String @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, albumId], name: "uniqueAlbumRatingId")
|
||||
}
|
||||
|
||||
model SongRating {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
value Float
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String @db.Uuid
|
||||
|
||||
song Song @relation(fields: [songId], references: [id])
|
||||
songId String @db.Uuid
|
||||
|
||||
@@unique(fields: [userId, songId], name: "uniqueSongRatingId")
|
||||
}
|
||||
|
||||
model Image {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
url String?
|
||||
remoteUrl String
|
||||
type ImageType
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
albumArtists AlbumArtist[]
|
||||
artists Artist[]
|
||||
albums Album[]
|
||||
songs Song[]
|
||||
|
||||
@@unique(fields: [remoteUrl, type], name: "uniqueImageId")
|
||||
}
|
||||
|
||||
model External {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
value String
|
||||
type ExternalType
|
||||
source ExternalSource
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
albumArtists AlbumArtist[]
|
||||
artists Artist[]
|
||||
albums Album[]
|
||||
songs Song[]
|
||||
|
||||
@@unique(fields: [value, source], name: "uniqueExternalId")
|
||||
}
|
||||
|
||||
model AlbumArtist {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
sortName String
|
||||
biography String?
|
||||
remoteId String
|
||||
remoteCreatedAt DateTime?
|
||||
deleted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
albums Album[]
|
||||
genres Genre[]
|
||||
externals External[]
|
||||
serverFolders ServerFolder[]
|
||||
ratings AlbumArtistRating[]
|
||||
images Image[]
|
||||
songs Song[]
|
||||
albumArtistFavorites AlbumArtistFavorite[]
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [serverId, remoteId], name: "uniqueAlbumArtistId")
|
||||
}
|
||||
|
||||
model Album {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
sortName String
|
||||
releaseDate DateTime?
|
||||
releaseYear Int?
|
||||
remoteId String
|
||||
remoteCreatedAt DateTime?
|
||||
deleted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
songs Song[]
|
||||
genres Genre[]
|
||||
artists Artist[]
|
||||
albumArtists AlbumArtist[]
|
||||
externals External[]
|
||||
serverFolders ServerFolder[]
|
||||
ratings AlbumRating[]
|
||||
images Image[]
|
||||
favorites AlbumFavorite[]
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [serverId, remoteId], name: "uniqueAlbumId")
|
||||
}
|
||||
|
||||
model Artist {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
sortName String
|
||||
biography String?
|
||||
remoteId String
|
||||
remoteCreatedAt DateTime?
|
||||
deleted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
genres Genre[]
|
||||
albums Album[]
|
||||
songs Song[]
|
||||
externals External[]
|
||||
serverFolders ServerFolder[]
|
||||
ratings ArtistRating[]
|
||||
images Image[]
|
||||
favorites ArtistFavorite[]
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [serverId, remoteId], name: "uniqueArtistId")
|
||||
}
|
||||
|
||||
model Song {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
name String
|
||||
sortName String
|
||||
releaseDate DateTime?
|
||||
releaseYear Int?
|
||||
duration Float?
|
||||
size Int?
|
||||
lyrics String?
|
||||
bitRate Int?
|
||||
container String
|
||||
discNumber Int? @default(1)
|
||||
trackNumber Int?
|
||||
artistName String?
|
||||
remoteId String
|
||||
remoteCreatedAt DateTime?
|
||||
deleted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
genres Genre[]
|
||||
artists Artist[]
|
||||
externals External[]
|
||||
folders Folder[]
|
||||
serverFolders ServerFolder[]
|
||||
histories History[]
|
||||
ratings SongRating[]
|
||||
images Image[]
|
||||
favorites SongFavorite[]
|
||||
|
||||
albumArtist AlbumArtist? @relation(fields: [albumArtistId], references: [id])
|
||||
albumArtistId String? @db.Uuid
|
||||
|
||||
album Album? @relation(fields: [albumId], references: [id])
|
||||
albumId String? @db.Uuid
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
@@unique(fields: [serverId, remoteId], name: "uniqueSongId")
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||
type TaskType
|
||||
message String?
|
||||
completed Boolean @default(false)
|
||||
isError Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
serverId String @db.Uuid
|
||||
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String? @db.Uuid
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import { randomString } from '../utils';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const hashedPassword =
|
||||
'$2y$12$icIH42ono1yTBypZ34V/PuDMXIbMD04GtSB6pgYpcwbjjIvujzv2y';
|
||||
|
||||
let error;
|
||||
do {
|
||||
try {
|
||||
await prisma.user.upsert({
|
||||
create: {
|
||||
deviceId: `admin_${randomString(10)}`,
|
||||
enabled: true,
|
||||
isAdmin: true,
|
||||
isSuperAdmin: true,
|
||||
password: hashedPassword,
|
||||
username: 'admin',
|
||||
},
|
||||
update: {},
|
||||
where: { username: 'admin' },
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientInitializationError) {
|
||||
error = 'retry';
|
||||
}
|
||||
|
||||
error = undefined;
|
||||
}
|
||||
} while (error === 'retry');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
// process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './subsonic';
|
||||
export * from './jellyfin';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { jellyfinApi } from './jellyfin.api';
|
||||
import { jellyfinScanner } from './jellyfin.scanner';
|
||||
|
||||
export const jellyfin = {
|
||||
api: jellyfinApi,
|
||||
scanner: jellyfinScanner,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Server } from '@prisma/client';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
JFAlbumArtistsResponse,
|
||||
JFAlbumsResponse,
|
||||
JFArtistsResponse,
|
||||
JFAuthenticate,
|
||||
JFCollectionType,
|
||||
JFGenreResponse,
|
||||
JFItemType,
|
||||
JFMusicFoldersResponse,
|
||||
JFRequestParams,
|
||||
JFSongsResponse,
|
||||
} from './jellyfin.types';
|
||||
|
||||
export const api = axios.create({});
|
||||
|
||||
export const authenticate = async (options: {
|
||||
password: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
const { password, url, username } = options;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const { data } = await api.post<JFAuthenticate>(
|
||||
`${cleanServerUrl}/users/authenticatebyname`,
|
||||
{ pw: password, username },
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1-alpha1"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getMusicFolders = async (server: Partial<Server>) => {
|
||||
const { data } = await api.get<JFMusicFoldersResponse>(
|
||||
`${server.url}/users/${server.remoteUserId}/items`,
|
||||
{ headers: { 'X-MediaBrowser-Token': server.token! } }
|
||||
);
|
||||
|
||||
const musicFolders = data.Items.filter(
|
||||
(folder) => folder.CollectionType === JFCollectionType.MUSIC
|
||||
);
|
||||
|
||||
return musicFolders;
|
||||
};
|
||||
|
||||
export const getGenres = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFGenreResponse>(`${server.url}/genres`, {
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAlbumArtists = async (
|
||||
server: Server,
|
||||
params: JFRequestParams
|
||||
) => {
|
||||
const { data } = await api.get<JFAlbumArtistsResponse>(
|
||||
`${server.url}/artists/albumArtists`,
|
||||
{
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getArtists = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFArtistsResponse>(`${server.url}/artists`, {
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAlbums = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFAlbumsResponse>(
|
||||
`${server.url}/users/${server.remoteUserId}/items`,
|
||||
{
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params: { includeItemTypes: JFItemType.MUSICALBUM, ...params },
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getSongs = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFSongsResponse>(
|
||||
`${server.url}/users/${server.remoteUserId}/items`,
|
||||
{
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params: { includeItemTypes: JFItemType.AUDIO, ...params },
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const jellyfinApi = {
|
||||
authenticate,
|
||||
getAlbumArtists,
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getMusicFolders,
|
||||
getSongs,
|
||||
};
|
||||
@@ -0,0 +1,500 @@
|
||||
import {
|
||||
ExternalSource,
|
||||
Folder,
|
||||
ImageType,
|
||||
Server,
|
||||
ServerFolder,
|
||||
Task,
|
||||
} from '@prisma/client';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { prisma } from '../../lib';
|
||||
import { groupByProperty } from '../../utils';
|
||||
import { queue } from '../queues';
|
||||
import { jellyfinApi } from './jellyfin.api';
|
||||
import { JFExternalType, JFImageType, JFItemType } from './jellyfin.types';
|
||||
import { jellyfinUtils } from './jellyfin.utils';
|
||||
|
||||
const scanGenres = async (options: {
|
||||
server: Server;
|
||||
serverFolder: ServerFolder;
|
||||
task: Task;
|
||||
}) => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning genres' },
|
||||
where: { id: options.task.id },
|
||||
});
|
||||
|
||||
const genres = await jellyfinApi.getGenres(options.server, {
|
||||
parentId: options.serverFolder.remoteId,
|
||||
});
|
||||
|
||||
const genresCreate = genres.Items.map((genre) => {
|
||||
return { name: genre.Name };
|
||||
});
|
||||
|
||||
await prisma.genre.createMany({
|
||||
data: genresCreate,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const scanAlbumArtists = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning album artists' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
// TODO: Possibly need to scan without the parentId to get all artists, since Jellyfin may link an album to an artist of a different folder
|
||||
const albumArtists = await jellyfinApi.getAlbumArtists(server, {
|
||||
fields: 'Genres,DateCreated,ExternalUrls,Overview',
|
||||
parentId: serverFolder.remoteId,
|
||||
});
|
||||
|
||||
await jellyfinUtils.insertGenres(albumArtists.Items);
|
||||
await jellyfinUtils.insertImages(albumArtists.Items);
|
||||
await jellyfinUtils.insertExternals(albumArtists.Items);
|
||||
|
||||
for (const albumArtist of albumArtists.Items) {
|
||||
const genresConnect = albumArtist.Genres.map((genre) => ({ name: genre }));
|
||||
|
||||
const imagesConnectOrCreate = [];
|
||||
for (const backdrop of albumArtist.BackdropImageTags) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: backdrop, type: ImageType.BACKDROP },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: backdrop, type: ImageType.BACKDROP },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(albumArtist.ImageTags)) {
|
||||
if (key === JFImageType.PRIMARY) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (key === JFImageType.LOGO) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.LOGO },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const externalsConnect = albumArtist.ExternalUrls.map((external) => ({
|
||||
uniqueExternalId: {
|
||||
source:
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB,
|
||||
value: external.Url.split('/').pop() || '',
|
||||
},
|
||||
}));
|
||||
|
||||
await prisma.albumArtist.upsert({
|
||||
create: {
|
||||
biography: albumArtist.Overview,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: {
|
||||
connectOrCreate: imagesConnectOrCreate,
|
||||
},
|
||||
name: albumArtist.Name,
|
||||
remoteCreatedAt: albumArtist.DateCreated,
|
||||
remoteId: albumArtist.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: albumArtist.Name,
|
||||
},
|
||||
update: {
|
||||
biography: albumArtist.Overview,
|
||||
deleted: false,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: {
|
||||
connectOrCreate: imagesConnectOrCreate,
|
||||
},
|
||||
name: albumArtist.Name,
|
||||
remoteCreatedAt: albumArtist.DateCreated,
|
||||
remoteId: albumArtist.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: albumArtist.Name,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: albumArtist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scanAlbums = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
const check = await jellyfinApi.getAlbums(server, {
|
||||
enableUserData: false,
|
||||
includeItemTypes: JFItemType.MUSICALBUM,
|
||||
limit: 1,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const albumCount = check.TotalRecordCount;
|
||||
const chunkSize = 5000;
|
||||
const albumChunkCount = Math.ceil(albumCount / chunkSize);
|
||||
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning albums' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (let i = 0; i < albumChunkCount; i += 1) {
|
||||
const albums = await jellyfinApi.getAlbums(server, {
|
||||
enableImageTypes: 'Primary,Logo,Backdrop',
|
||||
enableUserData: false,
|
||||
fields: 'Genres,DateCreated,ExternalUrls,Overview',
|
||||
imageTypeLimit: 1,
|
||||
limit: chunkSize,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
startIndex: i * chunkSize,
|
||||
});
|
||||
|
||||
await jellyfinUtils.insertGenres(albums.Items);
|
||||
await jellyfinUtils.insertImages(albums.Items);
|
||||
await jellyfinUtils.insertExternals(albums.Items);
|
||||
|
||||
for (const album of albums.Items) {
|
||||
const genresConnect = album.Genres.map((genre) => ({ name: genre }));
|
||||
|
||||
const imagesConnectOrCreate = [];
|
||||
for (const [key, value] of Object.entries(album.ImageTags)) {
|
||||
if (key === JFImageType.PRIMARY) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (key === JFImageType.LOGO) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.LOGO },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const externalsConnect = album.ExternalUrls.map((external) => ({
|
||||
uniqueExternalId: {
|
||||
source:
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB,
|
||||
value: external.Url.split('/').pop() || '',
|
||||
},
|
||||
}));
|
||||
|
||||
const remoteAlbumArtists = album.AlbumArtists;
|
||||
|
||||
const albumArtists = await prisma.albumArtist.findMany({
|
||||
where: {
|
||||
remoteId: { in: remoteAlbumArtists.map((artist) => artist.Id) },
|
||||
},
|
||||
});
|
||||
|
||||
const albumArtistsConnect = [];
|
||||
for (const albumArtist of remoteAlbumArtists) {
|
||||
const invalid = !albumArtists.find(
|
||||
(artist) => artist.remoteId === albumArtist.Id
|
||||
);
|
||||
|
||||
if (invalid) {
|
||||
// If Jellyfin returns an invalid album artist, we'll just use the first matching one
|
||||
const foundAlternate = await prisma.albumArtist.findFirst({
|
||||
where: {
|
||||
name: albumArtist.Name,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (foundAlternate) {
|
||||
albumArtistsConnect.push({
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: foundAlternate.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
albumArtistsConnect.push({
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: albumArtist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.album.upsert({
|
||||
create: {
|
||||
albumArtists: { connect: albumArtistsConnect },
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: album.Name,
|
||||
releaseDate: album.PremiereDate,
|
||||
releaseYear: album.ProductionYear,
|
||||
remoteCreatedAt: album.DateCreated,
|
||||
remoteId: album.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.Name,
|
||||
},
|
||||
update: {
|
||||
albumArtists: { connect: albumArtistsConnect },
|
||||
deleted: false,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: album.Name,
|
||||
releaseDate: album.PremiereDate,
|
||||
releaseYear: album.ProductionYear,
|
||||
remoteCreatedAt: album.DateCreated,
|
||||
remoteId: album.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.Name,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: album.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scanSongs = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
const check = await jellyfinApi.getSongs(server, {
|
||||
enableUserData: false,
|
||||
limit: 0,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const songCount = check.TotalRecordCount;
|
||||
const chunkSize = 5000;
|
||||
const songChunkCount = Math.ceil(songCount / chunkSize);
|
||||
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning songs' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (let i = 0; i < songChunkCount; i += 1) {
|
||||
const songs = await jellyfinApi.getSongs(server, {
|
||||
enableImageTypes: 'Primary,Logo,Backdrop',
|
||||
enableUserData: false,
|
||||
fields: 'Genres,DateCreated,ExternalUrls,MediaSources,SortName',
|
||||
imageTypeLimit: 1,
|
||||
limit: chunkSize,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
sortBy: 'DateCreated,Album',
|
||||
sortOrder: 'Descending',
|
||||
startIndex: i * chunkSize,
|
||||
});
|
||||
|
||||
const folderGroups = songs.Items.map((song) => {
|
||||
const songPaths = song.MediaSources[0].Path.split('/');
|
||||
const paths = [];
|
||||
for (let b = 0; b < songPaths.length - 1; b += 1) {
|
||||
paths.push({
|
||||
name: songPaths[b],
|
||||
path: songPaths.slice(0, b + 1).join('/'),
|
||||
});
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const uniqueFolders = uniqBy(
|
||||
folderGroups.flatMap((folder) => folder).filter((f) => f.path !== ''),
|
||||
'path'
|
||||
);
|
||||
|
||||
const createdFolders: Folder[] = [];
|
||||
for (const folder of uniqueFolders) {
|
||||
const createdFolder = await prisma.folder.upsert({
|
||||
create: {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
serverFolders: {
|
||||
connect: {
|
||||
uniqueServerFolderId: {
|
||||
remoteId: serverFolder.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
serverId: server.id,
|
||||
},
|
||||
update: {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
serverFolders: {
|
||||
connect: {
|
||||
uniqueServerFolderId: {
|
||||
remoteId: serverFolder.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
uniqueFolderId: {
|
||||
path: folder.path,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createdFolders.push(createdFolder);
|
||||
}
|
||||
|
||||
for (const folder of createdFolders) {
|
||||
if (folder.parentId) break;
|
||||
|
||||
const pathSplit = folder.path.split('/');
|
||||
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
|
||||
|
||||
const parentPathData = createdFolders.find(
|
||||
(save) => save.path === parentPath
|
||||
);
|
||||
|
||||
if (parentPathData) {
|
||||
await prisma.folder.update({
|
||||
data: {
|
||||
parentId: parentPathData.id,
|
||||
},
|
||||
where: { id: folder.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await jellyfinUtils.insertArtists(server, serverFolder, songs.Items);
|
||||
await jellyfinUtils.insertImages(songs.Items);
|
||||
await jellyfinUtils.insertExternals(songs.Items);
|
||||
|
||||
const albumSongGroups = groupByProperty(songs.Items, 'AlbumId');
|
||||
const keys = Object.keys(albumSongGroups);
|
||||
|
||||
for (const key of keys) {
|
||||
const songGroup = albumSongGroups[key];
|
||||
await jellyfinUtils.insertSongGroup(server, serverFolder, songGroup, key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkDeleted = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
await prisma.$transaction([
|
||||
prisma.albumArtist.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
prisma.artist.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
prisma.album.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
prisma.song.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const scanAll = async (
|
||||
server: Server,
|
||||
serverFolders: ServerFolder[],
|
||||
task: Task
|
||||
) => {
|
||||
queue.scanner.push({
|
||||
fn: async () => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Beginning scan...' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (const serverFolder of serverFolders) {
|
||||
await scanGenres({ server, serverFolder, task });
|
||||
await scanAlbumArtists(server, serverFolder, task);
|
||||
await scanAlbums(server, serverFolder, task);
|
||||
await scanSongs(server, serverFolder, task);
|
||||
await checkDeleted(server, serverFolder, task);
|
||||
|
||||
await prisma.serverFolder.update({
|
||||
data: { lastScannedAt: new Date() },
|
||||
where: { id: serverFolder.id },
|
||||
});
|
||||
}
|
||||
|
||||
return { task };
|
||||
},
|
||||
id: task.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const jellyfinScanner = {
|
||||
scanAlbumArtists,
|
||||
scanAlbums,
|
||||
scanAll,
|
||||
scanGenres,
|
||||
scanSongs,
|
||||
};
|
||||
@@ -1,127 +1,33 @@
|
||||
export type JFBasePaginatedResponse = {
|
||||
export interface JFBaseResponse {
|
||||
StartIndex: number;
|
||||
TotalRecordCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface JFMusicFolderListResponse extends JFBasePaginatedResponse {
|
||||
export interface JFMusicFoldersResponse extends JFBaseResponse {
|
||||
Items: JFMusicFolder[];
|
||||
}
|
||||
|
||||
export type JFMusicFolderList = JFMusicFolder[];
|
||||
|
||||
export interface JFGenreListResponse extends JFBasePaginatedResponse {
|
||||
export interface JFGenreResponse extends JFBaseResponse {
|
||||
Items: JFGenre[];
|
||||
}
|
||||
|
||||
export type JFGenreList = JFGenreListResponse;
|
||||
|
||||
export type JFAlbumArtistDetailResponse = JFAlbumArtist;
|
||||
|
||||
export type JFAlbumArtistDetail = JFAlbumArtistDetailResponse;
|
||||
|
||||
export interface JFAlbumArtistListResponse extends JFBasePaginatedResponse {
|
||||
export interface JFAlbumArtistsResponse extends JFBaseResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export type JFAlbumArtistList = {
|
||||
items: JFAlbumArtist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export interface JFArtistListResponse extends JFBasePaginatedResponse {
|
||||
export interface JFArtistsResponse extends JFBaseResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export type JFArtistList = JFArtistListResponse;
|
||||
|
||||
export interface JFAlbumListResponse extends JFBasePaginatedResponse {
|
||||
export interface JFAlbumsResponse extends JFBaseResponse {
|
||||
Items: JFAlbum[];
|
||||
}
|
||||
|
||||
export type JFAlbumList = {
|
||||
items: JFAlbum[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export type JFAlbumDetailResponse = JFAlbum;
|
||||
|
||||
export type JFAlbumDetail = JFAlbum & { songs?: JFSong[] };
|
||||
|
||||
export interface JFSongListResponse extends JFBasePaginatedResponse {
|
||||
export interface JFSongsResponse extends JFBaseResponse {
|
||||
Items: JFSong[];
|
||||
}
|
||||
|
||||
export type JFSongList = {
|
||||
items: JFSong[];
|
||||
startIndex: 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 {
|
||||
Items: JFPlaylist[];
|
||||
}
|
||||
|
||||
export type JFPlaylistList = {
|
||||
items: JFPlaylist[];
|
||||
startIndex: number;
|
||||
totalRecordCount: number;
|
||||
};
|
||||
|
||||
export enum JFPlaylistListSort {
|
||||
ALBUM_ARTIST = 'AlbumArtist,SortName',
|
||||
DURATION = 'Runtime',
|
||||
NAME = 'SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
SONG_COUNT = 'ChildCount',
|
||||
}
|
||||
|
||||
export type JFPlaylistDetailResponse = JFPlaylist;
|
||||
|
||||
export type JFPlaylistDetail = JFPlaylist & { songs?: JFSong[] };
|
||||
|
||||
export type JFPlaylist = {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
ChildCount?: number;
|
||||
DateCreated: string;
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: UserData;
|
||||
};
|
||||
|
||||
export type JFRequestParams = {
|
||||
export interface JFRequestParams {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
enableImageTypes?: string;
|
||||
@@ -140,9 +46,9 @@ export type JFRequestParams = {
|
||||
sortOrder?: 'Ascending' | 'Descending';
|
||||
startIndex?: number;
|
||||
userId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type JFMusicFolder = {
|
||||
export interface JFMusicFolder {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
CollectionType: string;
|
||||
@@ -155,9 +61,9 @@ export type JFMusicFolder = {
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: UserData;
|
||||
};
|
||||
}
|
||||
|
||||
export type JFGenre = {
|
||||
export interface JFGenre {
|
||||
BackdropImageTags: any[];
|
||||
ChannelId: null;
|
||||
Id: string;
|
||||
@@ -167,9 +73,9 @@ export type JFGenre = {
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type JFAlbumArtist = {
|
||||
export interface JFAlbumArtist {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
@@ -185,20 +91,9 @@ export type JFAlbumArtist = {
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlayCount: number;
|
||||
PlaybackPositionTicks: number;
|
||||
Played: boolean;
|
||||
};
|
||||
} & {
|
||||
similarArtists: {
|
||||
items: JFAlbumArtist[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type JFArtist = {
|
||||
export interface JFArtist {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
@@ -214,18 +109,15 @@ export type JFArtist = {
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type JFAlbum = {
|
||||
export interface JFAlbum {
|
||||
AlbumArtist: string;
|
||||
AlbumArtists: JFGenericItem[];
|
||||
AlbumPrimaryImageTag: string;
|
||||
ArtistItems: JFGenericItem[];
|
||||
Artists: string[];
|
||||
ChannelId: null;
|
||||
ChildCount?: number;
|
||||
DateCreated: string;
|
||||
DateLastMediaAdded?: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: JFGenericItem[];
|
||||
Genres: string[];
|
||||
@@ -242,12 +134,9 @@ export type JFAlbum = {
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData?: UserData;
|
||||
} & {
|
||||
songs?: JFSong[];
|
||||
};
|
||||
}
|
||||
|
||||
export type JFSong = {
|
||||
export interface JFSong {
|
||||
Album: string;
|
||||
AlbumArtist: string;
|
||||
AlbumArtists: JFGenericItem[];
|
||||
@@ -271,51 +160,49 @@ export type JFSong = {
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
ParentIndexNumber: number;
|
||||
PlaylistItemId?: string;
|
||||
PremiereDate?: string;
|
||||
ProductionYear: number;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
SortName: string;
|
||||
Type: string;
|
||||
UserData?: UserData;
|
||||
};
|
||||
}
|
||||
|
||||
type ImageBlurHashes = {
|
||||
interface ImageBlurHashes {
|
||||
Backdrop?: any;
|
||||
Logo?: any;
|
||||
Primary?: any;
|
||||
};
|
||||
}
|
||||
|
||||
type ImageTags = {
|
||||
interface ImageTags {
|
||||
Logo?: string;
|
||||
Primary?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type UserData = {
|
||||
interface UserData {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlayCount: number;
|
||||
PlaybackPositionTicks: number;
|
||||
Played: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type ExternalURL = {
|
||||
interface ExternalURL {
|
||||
Name: string;
|
||||
Url: string;
|
||||
};
|
||||
}
|
||||
|
||||
type GenreItem = {
|
||||
interface GenreItem {
|
||||
Id: string;
|
||||
Name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type JFGenericItem = {
|
||||
export interface JFGenericItem {
|
||||
Id: string;
|
||||
Name: string;
|
||||
};
|
||||
}
|
||||
|
||||
type MediaSources = {
|
||||
interface MediaSources {
|
||||
Bitrate: number;
|
||||
Container: string;
|
||||
DefaultAudioStreamIndex: number;
|
||||
@@ -344,9 +231,9 @@ type MediaSources = {
|
||||
SupportsProbing: boolean;
|
||||
SupportsTranscoding: boolean;
|
||||
Type: string;
|
||||
};
|
||||
}
|
||||
|
||||
type MediaStream = {
|
||||
interface MediaStream {
|
||||
AspectRatio?: string;
|
||||
BitDepth?: number;
|
||||
BitRate?: number;
|
||||
@@ -374,7 +261,7 @@ type MediaStream = {
|
||||
TimeBase: string;
|
||||
Type: string;
|
||||
Width?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export enum JFExternalType {
|
||||
MUSICBRAINZ = 'MusicBrainz',
|
||||
@@ -403,7 +290,7 @@ export interface JFAuthenticate {
|
||||
User: User;
|
||||
}
|
||||
|
||||
type SessionInfo = {
|
||||
interface SessionInfo {
|
||||
AdditionalUsers: any[];
|
||||
ApplicationVersion: string;
|
||||
Capabilities: Capabilities;
|
||||
@@ -426,25 +313,25 @@ type SessionInfo = {
|
||||
SupportsRemoteControl: boolean;
|
||||
UserId: string;
|
||||
UserName: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Capabilities = {
|
||||
interface Capabilities {
|
||||
PlayableMediaTypes: any[];
|
||||
SupportedCommands: any[];
|
||||
SupportsContentUploading: boolean;
|
||||
SupportsMediaControl: boolean;
|
||||
SupportsPersistentIdentifier: boolean;
|
||||
SupportsSync: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type PlayState = {
|
||||
interface PlayState {
|
||||
CanSeek: boolean;
|
||||
IsMuted: boolean;
|
||||
IsPaused: boolean;
|
||||
RepeatMode: string;
|
||||
};
|
||||
}
|
||||
|
||||
type User = {
|
||||
interface User {
|
||||
Configuration: Configuration;
|
||||
EnableAutoLogin: boolean;
|
||||
HasConfiguredEasyPassword: boolean;
|
||||
@@ -456,9 +343,9 @@ type User = {
|
||||
Name: string;
|
||||
Policy: Policy;
|
||||
ServerId: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Configuration = {
|
||||
interface Configuration {
|
||||
DisplayCollectionsView: boolean;
|
||||
DisplayMissingEpisodes: boolean;
|
||||
EnableLocalPassword: boolean;
|
||||
@@ -473,9 +360,9 @@ type Configuration = {
|
||||
RememberSubtitleSelections: boolean;
|
||||
SubtitleLanguagePreference: string;
|
||||
SubtitleMode: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Policy = {
|
||||
interface Policy {
|
||||
AccessSchedules: any[];
|
||||
AuthenticationProviderId: string;
|
||||
BlockUnratedItems: any[];
|
||||
@@ -514,121 +401,4 @@ type Policy = {
|
||||
PasswordResetProviderId: string;
|
||||
RemoteClientBitrateLimit: number;
|
||||
SyncPlayAccess: string;
|
||||
};
|
||||
|
||||
type JFBaseParams = {
|
||||
enableImageTypes?: JFImageType[];
|
||||
fields?: string;
|
||||
imageTypeLimit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
type JFPaginationParams = {
|
||||
limit?: number;
|
||||
nameStartsWith?: string;
|
||||
sortOrder?: JFSortOrder;
|
||||
startIndex?: number;
|
||||
};
|
||||
|
||||
export enum JFSortOrder {
|
||||
ASC = 'Ascending',
|
||||
DESC = 'Descending',
|
||||
}
|
||||
|
||||
export enum 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',
|
||||
}
|
||||
|
||||
export type JFAlbumListParams = {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
includeItemTypes: 'MusicAlbum';
|
||||
isFavorite?: boolean;
|
||||
searchTerm?: string;
|
||||
sortBy?: JFAlbumListSort;
|
||||
tags?: string;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export enum JFSongListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
ALBUM_ARTIST = 'AlbumArtist,Album,SortName',
|
||||
ARTIST = 'Artist,Album,SortName',
|
||||
COMMUNITY_RATING = 'CommunityRating,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'Name,SortName',
|
||||
PLAY_COUNT = 'PlayCount,SortName',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RECENTLY_PLAYED = 'DatePlayed,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export type JFSongListParams = {
|
||||
albumArtistIds?: string;
|
||||
albumIds?: string;
|
||||
artistIds?: string;
|
||||
contributingArtistIds?: string;
|
||||
filters?: string;
|
||||
genreIds?: string;
|
||||
genres?: string;
|
||||
ids?: string;
|
||||
includeItemTypes: 'Audio';
|
||||
searchTerm?: string;
|
||||
sortBy?: JFSongListSort;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export enum 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',
|
||||
}
|
||||
|
||||
export type JFAlbumArtistListParams = {
|
||||
filters?: string;
|
||||
genres?: string;
|
||||
sortBy?: JFAlbumArtistListSort;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export enum JFArtistListSort {
|
||||
ALBUM = 'Album,SortName',
|
||||
DURATION = 'Runtime,AlbumArtist,Album,SortName',
|
||||
NAME = 'Name,SortName',
|
||||
RANDOM = 'Random,SortName',
|
||||
RECENTLY_ADDED = 'DateCreated,SortName',
|
||||
RELEASE_DATE = 'PremiereDate,AlbumArtist,Album,SortName',
|
||||
}
|
||||
|
||||
export type JFArtistListParams = {
|
||||
filters?: string;
|
||||
genres?: string;
|
||||
sortBy?: JFArtistListSort;
|
||||
years?: string;
|
||||
} & JFBaseParams &
|
||||
JFPaginationParams;
|
||||
|
||||
export type JFCreatePlaylistResponse = {
|
||||
Id: string;
|
||||
};
|
||||
|
||||
export type JFCreatePlaylist = JFCreatePlaylistResponse;
|
||||
@@ -0,0 +1,304 @@
|
||||
import { prisma } from '@lib/prisma';
|
||||
import {
|
||||
ExternalSource,
|
||||
ExternalType,
|
||||
ImageType,
|
||||
Prisma,
|
||||
Server,
|
||||
ServerFolder,
|
||||
} from '@prisma/client';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { uniqueArray } from '../../utils/unique-array';
|
||||
import {
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFExternalType,
|
||||
JFImageType,
|
||||
JFSong,
|
||||
} from './jellyfin.types';
|
||||
|
||||
const insertGenres = async (items: JFSong[] | JFAlbum[] | JFAlbumArtist[]) => {
|
||||
const genresCreateMany = items
|
||||
.flatMap((item) => item.GenreItems)
|
||||
.map((genre) => ({ name: genre.Name }));
|
||||
|
||||
await prisma.genre.createMany({
|
||||
data: genresCreateMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const insertArtists = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
items: JFSong[] | JFAlbum[]
|
||||
) => {
|
||||
const artistItems = uniqBy(
|
||||
items.flatMap((item) => item.ArtistItems),
|
||||
'Id'
|
||||
);
|
||||
|
||||
const createMany = artistItems.map((artist) => ({
|
||||
name: artist.Name,
|
||||
remoteId: artist.Id,
|
||||
serverId: server.id,
|
||||
sortName: artist.Name,
|
||||
}));
|
||||
|
||||
await prisma.artist.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
for (const artist of artistItems) {
|
||||
await prisma.artist.update({
|
||||
data: { serverFolders: { connect: { id: serverFolder.id } } },
|
||||
where: {
|
||||
uniqueArtistId: {
|
||||
remoteId: artist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const insertImages = async (items: JFSong[] | JFAlbum[] | JFAlbumArtist[]) => {
|
||||
const imageItems = uniqBy(
|
||||
items.flatMap((item) => item.ImageTags),
|
||||
'Id'
|
||||
);
|
||||
|
||||
const createMany: Prisma.ImageCreateManyInput[] = [];
|
||||
|
||||
for (const image of imageItems) {
|
||||
if (image.Logo) {
|
||||
createMany.push({
|
||||
remoteUrl: image.Logo,
|
||||
type: ImageType.LOGO,
|
||||
});
|
||||
}
|
||||
if (image.Primary) {
|
||||
createMany.push({
|
||||
remoteUrl: image.Primary,
|
||||
type: ImageType.PRIMARY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.image.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const insertExternals = async (
|
||||
items: JFSong[] | JFAlbum[] | JFAlbumArtist[]
|
||||
) => {
|
||||
const externalItems = uniqBy(
|
||||
items.flatMap((item) => item.ExternalUrls),
|
||||
'Url'
|
||||
);
|
||||
const createMany: Prisma.ExternalCreateManyInput[] = [];
|
||||
|
||||
for (const external of externalItems) {
|
||||
if (
|
||||
external.Name === JFExternalType.MUSICBRAINZ ||
|
||||
external.Name === JFExternalType.THEAUDIODB
|
||||
) {
|
||||
const source =
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB;
|
||||
|
||||
const value = external.Url.split('/').pop() || '';
|
||||
|
||||
createMany.push({ source, type: ExternalType.ID, value });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.external.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const insertSongGroup = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
songs: JFSong[],
|
||||
remoteAlbumId: string
|
||||
) => {
|
||||
const remoteAlbumArtist =
|
||||
songs[0].AlbumArtists.length > 0 ? songs[0].AlbumArtists[0] : undefined;
|
||||
|
||||
let albumArtist = remoteAlbumArtist?.Id
|
||||
? await prisma.albumArtist.findUnique({
|
||||
where: {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: remoteAlbumArtist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// If Jellyfin returns an invalid album artist, we'll just use the first matching one
|
||||
if (remoteAlbumArtist && !albumArtist) {
|
||||
albumArtist = await prisma.albumArtist.findFirst({
|
||||
where: {
|
||||
name: remoteAlbumArtist?.Name,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const albumArtistId = albumArtist ? albumArtist.id : undefined;
|
||||
|
||||
const songsUpsert: Prisma.SongUpsertWithWhereUniqueWithoutAlbumInput[] =
|
||||
songs.map((song) => {
|
||||
const genresConnect = song.Genres.map((genre) => ({ name: genre }));
|
||||
|
||||
const artistsConnect = song.ArtistItems.map((artist) => ({
|
||||
uniqueArtistId: {
|
||||
remoteId: artist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
}));
|
||||
|
||||
const externalsConnect = song.ExternalUrls.map((external) => ({
|
||||
uniqueExternalId: {
|
||||
source:
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB,
|
||||
value: external.Url.split('/').pop() || '',
|
||||
},
|
||||
}));
|
||||
|
||||
const imagesConnectOrCreate = [];
|
||||
for (const [key, value] of Object.entries(song.ImageTags)) {
|
||||
if (key === JFImageType.PRIMARY) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: {
|
||||
remoteUrl: value,
|
||||
type: ImageType.PRIMARY,
|
||||
},
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (key === JFImageType.LOGO) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: {
|
||||
remoteUrl: value,
|
||||
type: ImageType.LOGO,
|
||||
},
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pathSplit = song.MediaSources[0].Path.split('/');
|
||||
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
|
||||
|
||||
return {
|
||||
create: {
|
||||
albumArtistId,
|
||||
artists: { connect: artistsConnect },
|
||||
bitRate: Math.floor(song.MediaSources[0].Bitrate / 1e3),
|
||||
container: song.MediaSources[0].Container,
|
||||
deleted: false,
|
||||
discNumber: song.ParentIndexNumber,
|
||||
duration: Math.floor(song.MediaSources[0].RunTimeTicks / 1e7),
|
||||
externals: { connect: externalsConnect },
|
||||
folders: {
|
||||
connect: {
|
||||
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||
},
|
||||
},
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: song.Name,
|
||||
releaseDate: song.PremiereDate,
|
||||
releaseYear: song.ProductionYear,
|
||||
remoteCreatedAt: song.DateCreated,
|
||||
remoteId: song.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.MediaSources[0].Size,
|
||||
sortName: song.Name,
|
||||
trackNumber: song.IndexNumber,
|
||||
},
|
||||
update: {
|
||||
albumArtistId,
|
||||
artists: { connect: artistsConnect },
|
||||
bitRate: Math.floor(song.MediaSources[0].Bitrate / 1e3),
|
||||
container: song.MediaSources[0].Container,
|
||||
deleted: false,
|
||||
discNumber: song.ParentIndexNumber,
|
||||
duration: Math.floor(song.MediaSources[0].RunTimeTicks / 1e7),
|
||||
externals: { connect: externalsConnect },
|
||||
folders: {
|
||||
connect: {
|
||||
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||
},
|
||||
},
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: song.Name,
|
||||
releaseDate: song.PremiereDate,
|
||||
releaseYear: song.ProductionYear,
|
||||
remoteCreatedAt: song.DateCreated,
|
||||
remoteId: song.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.MediaSources[0].Size,
|
||||
sortName: song.Name,
|
||||
trackNumber: song.IndexNumber,
|
||||
},
|
||||
where: {
|
||||
uniqueSongId: {
|
||||
remoteId: song.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueArtistIds = songs
|
||||
.flatMap((song) => song.ArtistItems.flatMap((artist) => artist.Id))
|
||||
.filter(uniqueArray);
|
||||
|
||||
const artistsConnect = uniqueArtistIds.map((artistId) => ({
|
||||
uniqueArtistId: {
|
||||
remoteId: artistId,
|
||||
serverId: server.id,
|
||||
},
|
||||
}));
|
||||
|
||||
await prisma.album.update({
|
||||
data: {
|
||||
artists: { connect: artistsConnect },
|
||||
deleted: false,
|
||||
songs: { upsert: songsUpsert },
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: remoteAlbumId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const jellyfinUtils = {
|
||||
insertArtists,
|
||||
insertExternals,
|
||||
insertGenres,
|
||||
insertImages,
|
||||
insertSongGroup,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { navidromeApi } from './navidrome.api';
|
||||
import { navidromeScanner } from './navidrome.scanner';
|
||||
|
||||
export const navidrome = {
|
||||
api: navidromeApi,
|
||||
scanner: navidromeScanner,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Server } from '@prisma/client';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
NDAlbumListResponse,
|
||||
NDGenreListResponse,
|
||||
NDAlbumListParams,
|
||||
NDGenreListParams,
|
||||
NDSongListParams,
|
||||
NDSongListResponse,
|
||||
NDArtistListResponse,
|
||||
NDAuthenticate,
|
||||
} from './navidrome.types';
|
||||
|
||||
const api = axios.create();
|
||||
|
||||
const authenticate = async (options: {
|
||||
password: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
const { password, url, username } = options;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const { data } = await api.post<NDAuthenticate>(
|
||||
`${cleanServerUrl}/auth/login`,
|
||||
{ password, username }
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getGenres = async (server: Server, params?: NDGenreListParams) => {
|
||||
const [ndToken] = server.token.split('||');
|
||||
const { data } = await api.get<NDGenreListResponse>(
|
||||
`${server.url}/api/genre`,
|
||||
{
|
||||
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getArtists = async (server: Server, params?: NDGenreListParams) => {
|
||||
const [ndToken] = server.token.split('||');
|
||||
const { data } = await api.get<NDArtistListResponse>(
|
||||
`${server.url}/api/artist`,
|
||||
{
|
||||
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getAlbums = async (server: Server, params?: NDAlbumListParams) => {
|
||||
const [ndToken] = server.token.split('||');
|
||||
const { data } = await api.get<NDAlbumListResponse>(
|
||||
`${server.url}/api/album`,
|
||||
{
|
||||
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getSongs = async (server: Server, params?: NDSongListParams) => {
|
||||
const [ndToken] = server.token.split('||');
|
||||
const { data } = await api.get<NDSongListResponse>(`${server.url}/api/song`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${ndToken}` },
|
||||
params,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const navidromeApi = {
|
||||
authenticate,
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getSongs,
|
||||
};
|
||||