mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 20:40:15 +02:00
Compare commits
291 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ce0aac24 | |||
| 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 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
release/app/node_modules
|
release/app/node_modules
|
||||||
release/app/dist
|
release/app/dist
|
||||||
src/server/node_modules
|
server/node_modules
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import webpack from 'webpack';
|
import webpack from 'webpack';
|
||||||
import { dependencies as externals } from '../../release/app/package.json';
|
import { dependencies as externals } from '../../release/app/package.json';
|
||||||
import webpackPaths from './webpack.paths';
|
import webpackPaths from './webpack.paths';
|
||||||
|
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
|
||||||
|
|
||||||
const configuration: webpack.Configuration = {
|
const configuration: webpack.Configuration = {
|
||||||
externals: [...Object.keys(externals || {})],
|
externals: [...Object.keys(externals || {})],
|
||||||
@@ -48,6 +49,7 @@ const configuration: webpack.Configuration = {
|
|||||||
fallback: {
|
fallback: {
|
||||||
child_process: false,
|
child_process: false,
|
||||||
},
|
},
|
||||||
|
plugins: [new TsconfigPathsPlugin({ baseUrl: webpackPaths.srcPath })],
|
||||||
modules: [webpackPaths.srcPath, 'node_modules'],
|
modules: [webpackPaths.srcPath, 'node_modules'],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
|
extends: ['erb', 'plugin:typescript-sort-keys/recommended'],
|
||||||
|
ignorePatterns: ['.erb/*', 'server'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
createDefaultProgram: true,
|
createDefaultProgram: true,
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
@@ -7,10 +9,14 @@ module.exports = {
|
|||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
},
|
},
|
||||||
plugins: ['import', 'sort-keys-fix'],
|
plugins: ['@typescript-eslint', 'import', 'sort-keys-fix'],
|
||||||
|
root: true,
|
||||||
rules: {
|
rules: {
|
||||||
|
'@typescript-eslint/naming-convention': 'off',
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||||
|
'@typescript-eslint/no-shadow': ['off'],
|
||||||
|
'import/extensions': 'off',
|
||||||
// A temporary hack related to IDE not resolving correct package.json
|
// A temporary hack related to IDE not resolving correct package.json
|
||||||
'import/no-extraneous-dependencies': 'off',
|
'import/no-extraneous-dependencies': 'off',
|
||||||
'import/no-unresolved': 'error',
|
'import/no-unresolved': 'error',
|
||||||
@@ -41,6 +47,7 @@ module.exports = {
|
|||||||
'no-console': 'off',
|
'no-console': 'off',
|
||||||
'no-nested-ternary': 'off',
|
'no-nested-ternary': 'off',
|
||||||
'no-restricted-syntax': 'off',
|
'no-restricted-syntax': 'off',
|
||||||
|
'no-underscore-dangle': 'off',
|
||||||
'react/jsx-props-no-spreading': 'off',
|
'react/jsx-props-no-spreading': 'off',
|
||||||
'react/jsx-sort-props': [
|
'react/jsx-sort-props': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: [electron-react-boilerplate, amilajack]
|
|
||||||
patreon: amilajack
|
|
||||||
open_collective: electron-react-boilerplate-594
|
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
name: Publish
|
name: Publish (Manual)
|
||||||
|
|
||||||
on:
|
on: workflow_dispatch
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
# To enable auto publishing to github, update your electron publisher
|
|
||||||
# config in package.json > "build" and remove the conditional below
|
|
||||||
if: ${{ github.repository_owner == 'electron-react-boilerplate' }}
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
@@ -33,14 +26,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish releases
|
- name: Publish releases
|
||||||
env:
|
env:
|
||||||
# These values are used for auto updates signing
|
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_ID_PASS: ${{ secrets.APPLE_ID_PASS }}
|
|
||||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
|
||||||
# This is used for uploading release assets to github
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
uses: nick-invision/retry@v2.8.2
|
||||||
|
with:
|
||||||
|
timeout_minutes: 30
|
||||||
|
max_attempts: 3
|
||||||
|
retry_on: error
|
||||||
|
command: |
|
||||||
npm run postinstall
|
npm run postinstall
|
||||||
npm run build
|
npm run build
|
||||||
npm exec electron-builder -- --publish always --win --mac --linux
|
npm exec electron-builder -- --publish always --win --mac --linux
|
||||||
|
on_retry_command: npm cache clean --force
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
"empty-line-between-groups": false
|
"empty-line-between-groups": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"string-quotes": "single",
|
||||||
|
"declaration-block-no-redundant-longhand-properties": null,
|
||||||
|
"selector-class-pattern": null,
|
||||||
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
|
"selector-type-case": ["lower", { "ignoreTypes": ["/^\\$\\w+/"] }],
|
||||||
"selector-type-no-unknown": [
|
"selector-type-no-unknown": [
|
||||||
true,
|
true,
|
||||||
|
|||||||
Vendored
+24
@@ -4,6 +4,11 @@
|
|||||||
".prettierrc": "jsonc",
|
".prettierrc": "jsonc",
|
||||||
".eslintignore": "ignore"
|
".eslintignore": "ignore"
|
||||||
},
|
},
|
||||||
|
"eslint.validate": ["typescript"],
|
||||||
|
"eslint.workingDirectories": [
|
||||||
|
{ "directory": "./", "changeProcessCWD": true },
|
||||||
|
{ "directory": "./server", "changeProcessCWD": true }
|
||||||
|
],
|
||||||
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
|
||||||
"editor.tabSize": 2,
|
"editor.tabSize": 2,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
@@ -26,5 +31,24 @@
|
|||||||
"test/**/__snapshots__": true,
|
"test/**/__snapshots__": true,
|
||||||
"package-lock.json": true,
|
"package-lock.json": true,
|
||||||
"*.{css,sass,scss}.d.ts": 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"
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-16
@@ -2,41 +2,50 @@
|
|||||||
FROM node:16.5-alpine as ui-builder
|
FROM node:16.5-alpine as ui-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm install && npm run build:renderer
|
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
|
# Stage 2 - Build server
|
||||||
FROM node:16.5-alpine as server-builder
|
FROM node:16.5-alpine as server-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY src/server .
|
COPY server .
|
||||||
RUN ls -lh
|
RUN npm install && npx prisma generate
|
||||||
RUN npm install
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
RUN npm prune --production
|
||||||
|
RUN npm cache clean --force
|
||||||
|
RUN rm -rf /root/.cache
|
||||||
|
|
||||||
|
|
||||||
# Stage 3 - Deploy
|
# Stage 3 - Deploy
|
||||||
FROM node:16.5-alpine
|
FROM node:16.5-alpine
|
||||||
WORKDIR /root
|
WORKDIR /root
|
||||||
RUN mkdir appdata
|
RUN mkdir appdata
|
||||||
RUN mkdir sonixd-server
|
RUN mkdir feishin-server
|
||||||
RUN mkdir sonixd-client
|
RUN mkdir feishin-client
|
||||||
|
|
||||||
# Install server modules
|
RUN npm cache clean --force
|
||||||
COPY src/server/package.json ./sonixd-server
|
RUN npm prune --production
|
||||||
RUN cd ./sonixd-server && npm install --production
|
|
||||||
|
|
||||||
# Add server build files
|
# Add server build files
|
||||||
COPY --from=server-builder /app/dist ./sonixd-server
|
COPY --from=server-builder /app/dist ./feishin-server
|
||||||
COPY --from=server-builder /app/prisma ./sonixd-server/prisma
|
COPY --from=server-builder /app/node_modules ./feishin-server/node_modules
|
||||||
|
COPY --from=server-builder /app/prisma ./feishin-server/prisma
|
||||||
|
|
||||||
# Add client build files
|
# Add client build files
|
||||||
COPY --from=ui-builder /app/release/app/dist/renderer ./sonixd-client
|
COPY --from=ui-builder /app/release/app/dist/renderer ./feishin-client
|
||||||
|
|
||||||
COPY docker-entrypoint.sh ./sonixd-server/docker-entrypoint.sh
|
COPY docker-entrypoint.sh ./feishin-server/docker-entrypoint.sh
|
||||||
RUN chmod +x ./sonixd-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 cd ./sonixd-server && npx prisma generate
|
|
||||||
RUN npm install pm2 -g
|
RUN npm install pm2 -g
|
||||||
|
|
||||||
WORKDIR /root/sonixd-server
|
WORKDIR /root/feishin-server
|
||||||
|
|
||||||
EXPOSE 9321
|
EXPOSE 9321
|
||||||
CMD ["sh", "docker-entrypoint.sh"]
|
CMD ["sh", "docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,129 +1,185 @@
|
|||||||
<img src="assets/icon.png" alt="sonixd logo" title="sonixd" align="right" height="60px" />
|
# Feishin
|
||||||
|
|
||||||
# Sonixd
|
<p align="center">
|
||||||
|
<a href="https://github.com/jeffvli/feishin/blob/main/LICENSE">
|
||||||
<a href="https://github.com/jeffvli/sonixd/releases">
|
<img src="https://img.shields.io/github/license/jeffvli/feishin?style=flat-square&color=brightgreen"
|
||||||
<img src="https://img.shields.io/github/v/release/jeffvli/sonixd?style=flat-square&color=blue"
|
|
||||||
alt="Release">
|
|
||||||
</a>
|
|
||||||
<a href="https://github.com/jeffvli/sonixd/blob/main/LICENSE">
|
|
||||||
<img src="https://img.shields.io/github/license/jeffvli/sonixd?style=flat-square&color=brightgreen"
|
|
||||||
alt="License">
|
alt="License">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/jeffvli/sonixd/releases">
|
<a href="https://github.com/jeffvli/feishin/releases">
|
||||||
<img src="https://img.shields.io/github/downloads/jeffvli/sonixd/total?style=flat-square&color=orange"
|
<img src="https://img.shields.io/github/v/release/jeffvli/feishin?style=flat-square&color=blue"
|
||||||
|
alt="Release">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/jeffvli/feishin/releases">
|
||||||
|
<img src="https://img.shields.io/github/downloads/jeffvli/feishin/total?style=flat-square&color=orange"
|
||||||
alt="Downloads">
|
alt="Downloads">
|
||||||
</a>
|
</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">
|
<a href="https://discord.gg/FVKpcMDy5f">
|
||||||
<img src="https://img.shields.io/discord/922656312888811530?color=red&label=discord&logo=discord&logoColor=white"
|
<img src="https://img.shields.io/discord/922656312888811530?color=black&label=discord&logo=discord&logoColor=white"
|
||||||
alt="Discord">
|
alt="Discord">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://matrix.to/#/#sonixd:matrix.org">
|
<a href="https://matrix.to/#/#sonixd:matrix.org">
|
||||||
<img src="https://img.shields.io/matrix/sonixd:matrix.org?color=red&label=matrix&logo=matrix&logoColor=white"
|
<img src="https://img.shields.io/matrix/sonixd:matrix.org?color=black&label=matrix&logo=matrix&logoColor=white"
|
||||||
alt="Matrix">
|
alt="Matrix">
|
||||||
</a>
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Sonixd is a cross-platform desktop client built for Subsonic-API (and Jellyfin in 0.8.0+) compatible music servers. This project was inspired by the many existing clients, but aimed to address a few key issues including <strong>scalability</strong>, <strong>library management</strong>, and <strong>user experience</strong>.
|
Repository for the rewrite of [Sonixd](https://github.com/jeffvli/sonixd).
|
||||||
|
|
||||||
- [**Usage documentation & FAQ**](https://github.com/jeffvli/sonixd/discussions/15)
|
## Getting Started
|
||||||
- [**Theming documentation**](https://github.com/jeffvli/sonixd/discussions/61)
|
|
||||||
|
|
||||||
Sonixd has been tested on the following: [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/), [Jellyfin](https://github.com/jellyfin/jellyfin)
|
The default credentials to login will be `admin/admin`.
|
||||||
|
|
||||||
### [Demo Sonixd using Navidrome](https://github.com/jeffvli/sonixd/discussions/244)
|
Download the [latest desktop client](https://github.com/jeffvli/feishin/releases).
|
||||||
|
|
||||||
## Features
|
### Docker Compose
|
||||||
|
|
||||||
- HTML5 audio with crossfading and gapless\* playback
|
**Warning:** Check the environment variable configuration before running the commands below.
|
||||||
- Drag and drop rows with multi-select
|
|
||||||
- Modify and save playlists intuitively
|
|
||||||
- Handles large playlists and queues
|
|
||||||
- Global mediakeys (and partial MPRIS) support
|
|
||||||
- Multi-theme support
|
|
||||||
- Supports all Subsonic/Jellyfin API compatible servers
|
|
||||||
- Built with Electron, React with the [rsuite v4](https://github.com/rsuite/rsuite) component library
|
|
||||||
|
|
||||||
<h5>* Gapless playback is artifically created using the crossfading players so it may not be perfect, YMMV.</h5>
|
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`
|
||||||
|
|
||||||
## Screenshots
|
### Docker
|
||||||
|
|
||||||
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/album.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/album.png" width="49.5%"/></a>
|
**Warning:** Check the environment variable configuration before running the commands below.
|
||||||
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/artist.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/artist.png" width="49.5%"/></a>
|
|
||||||
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/search.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/search.png" width="49.5%"/></a>
|
|
||||||
<a href="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/now_playing.png"><img src="https://raw.githubusercontent.com/jeffvli/sonixd/main/assets/screenshots/0.13.1/now_playing.png" width="49.5%"/></a>
|
|
||||||
|
|
||||||
## Install
|
**Run a postgres database container:**
|
||||||
|
|
||||||
You can install sonixd by downloading the [latest release](https://github.com/jeffvli/sonixd/releases) for your specified operating system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
If you prefer not to download the release binary, you can install using `winget`.
|
|
||||||
|
|
||||||
Using your favorite terminal (cmd/pwsh):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
winget install sonixd
|
docker run postgres:13 \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-e POSTGRES_USER=admin \
|
||||||
|
-e POSTGRES_PASSWORD=admin \
|
||||||
|
-e POSTGRES_DB=feishin
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
**Run the Feishin server container:**
|
||||||
|
|
||||||
### Arch Linux
|
|
||||||
|
|
||||||
There is an AUR package of the latest AppImage release available [here](https://aur.archlinux.org/packages/sonixd-appimage).
|
|
||||||
|
|
||||||
To install it you can use your favourite AUR package manager and install the package: `sonixd-appimage`
|
|
||||||
|
|
||||||
For example using `yay`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
yay -S sonixd-appimage
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
If you encounter any problems please comment on the [AUR](https://aur.archlinux.org/packages/sonixd-appimage) or contact the [maintainer](mailto:robin@blckct.io) directly before you open an issue here.
|
**Docker Environment Variables**
|
||||||
|
|
||||||
---
|
```
|
||||||
|
APP_BASE_URL — The URL the site will be accessible at from your server (needed for CORS)
|
||||||
|
|
||||||
Once installed, run the application and sign in to your music server with the following details. If you are using [airsonic-advanced](https://github.com/airsonic-advanced/airsonic-advanced), you will need to make sure that you create a `decodable` credential for your login user within the admin control panel.
|
DATABASE_PORT — The port of your running postgres container
|
||||||
|
|
||||||
- Server - `e.g. http://localhost:4040/`
|
DATABASE_URL — The connection string to your postgres instance following this format: postgresql://<DB_USERNAME>:<DB_PASSWORD>@<DB_URL>/<DB_NAME>?schema=public
|
||||||
- User name - `e.g. admin`
|
|
||||||
- Password - `e.g. supersecret!`
|
|
||||||
|
|
||||||
If you have any questions, feel free to check out the [Usage Documentation & FAQ](https://github.com/jeffvli/sonixd/discussions/15).
|
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
|
||||||
|
|
||||||
## Development / Contributing
|
Example: postgresql://admin:password@192.168.0.1:5432/feishin?schema=public
|
||||||
|
|
||||||
This project is built off of [electron-react-boilerplate](https://github.com/electron-react-boilerplate/electron-react-boilerplate) v2.3.0.
|
TOKEN_SECRET — The string used to sign auth tokens
|
||||||
If you want to contribute to this project, please first create an [issue](https://github.com/jeffvli/sonixd/issues/new) or [discussion](https://github.com/jeffvli/sonixd/discussions/new) so that we can both discuss the idea and its feasability for integration.
|
|
||||||
|
|
||||||
First, clone the repo via git and install dependencies (Windows development now requires additional setup, see [#232](https://github.com/jeffvli/sonixd/issues/232)):
|
(optional) TOKEN_EXPIRATION — The time before the auth JWT expires
|
||||||
|
|
||||||
```bash
|
(optional) TOKEN_REFRESH_EXPIRATION - The time before the auth JWT refresh token expires
|
||||||
git clone https://github.com/jeffvli/sonixd.git
|
|
||||||
yarn install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Start the app in the `dev` environment:
|
### After installing the server and database
|
||||||
|
|
||||||
```bash
|
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).
|
||||||
yarn start
|
|
||||||
|
## 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 [Subsonic](http://www.subsonic.org/pages/api.jsp), [Navidrome](https://www.navidrome.org/), or [Jellyfin](https://jellyfin.org/) API.
|
||||||
|
|
||||||
|
- [Jellyfin](https://github.com/jellyfin/jellyfin)
|
||||||
|
- [Navidrome](https://github.com/navidrome/navidrome)
|
||||||
|
- [Airsonic](https://github.com/airsonic/airsonic)
|
||||||
|
- [Airsonic-Advanced](https://github.com/airsonic-advanced/airsonic-advanced)
|
||||||
|
- [Gonic](https://github.com/sentriz/gonic)
|
||||||
|
- [Astiga](https://asti.ga/)
|
||||||
|
- [Supysonic](https://github.com/spl0k/supysonic)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
To package apps for the local platform:
|
### NPM Scripts:
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn package
|
|
||||||
```
|
```
|
||||||
|
$ npm run package — Packages the application for the local system
|
||||||
|
|
||||||
If you receive errors while packaging the application, try upgrading/downgrading your Node version (tested on v14.18.0).
|
$ npm run start — Runs the development Electron and web client
|
||||||
|
|
||||||
If you are unable to run via debug in VS Code, check troubleshooting steps [here](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/2757#issuecomment-784200527).
|
$ npm run start:web — Runs the development web client
|
||||||
|
|
||||||
If your devtools extensions are failing to run/install, check troubleshooting steps [here](https://github.com/electron-react-boilerplate/electron-react-boilerplate/issues/2788).
|
$ 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
|
## License
|
||||||
|
|
||||||
[GNU General Public License v3.0 ©](https://github.com/jeffvli/sonixd/blob/main/LICENSE)
|
[GNU General Public License v3.0 ©](https://github.com/jeffvli/sonixd-rewrite/blob/dev/LICENSE)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 45 KiB |
+10
-8
@@ -1,7 +1,7 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
container_name: sonixd_db
|
container_name: feishin_db
|
||||||
image: postgres:13
|
image: postgres:13
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
|
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
|
||||||
@@ -13,12 +13,12 @@ services:
|
|||||||
- '${DATABASE_PORT}:5432'
|
- '${DATABASE_PORT}:5432'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
server:
|
server:
|
||||||
container_name: sonixd_server
|
container_name: feishin_server
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/server:/app # Synchronise docker container with local change
|
- ./server:/app # Synchronise docker container with local change
|
||||||
- /app/node_modules # Avoid re-copying local node_modules. Cache in container.
|
- /app/node_modules # Avoid re-copying local node_modules. Cache in container.
|
||||||
build:
|
build:
|
||||||
context: ./src/server
|
context: ./server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
@@ -27,15 +27,17 @@ services:
|
|||||||
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
|
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
|
||||||
- DATABASE_PORT=${DATABASE_PORT}
|
- DATABASE_PORT=${DATABASE_PORT}
|
||||||
- TOKEN_SECRET=${TOKEN_SECRET}
|
- TOKEN_SECRET=${TOKEN_SECRET}
|
||||||
|
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
|
||||||
|
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
|
||||||
ports:
|
ports:
|
||||||
- '9321:9321'
|
- '8643:9321'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
prisma:
|
prisma:
|
||||||
container_name: sonixd_prisma_studio
|
container_name: feishin_prisma_studio
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/server/prisma:/app/prisma
|
- ./server/prisma:/app/prisma
|
||||||
build:
|
build:
|
||||||
context: ./src/server/prisma
|
context: ./server/prisma
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
@@ -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
|
||||||
+17
-13
@@ -1,25 +1,29 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
container_name: sonixd_db
|
container_name: feishin_db
|
||||||
image: postgres:13
|
image: postgres:13
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${DB_PERSIST_PATH}:/var/lib/postgresql/data
|
- ${DATABASE_PERSIST_PATH}:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${DB_USERNAME}
|
- POSTGRES_USER=${DATABASE_USERNAME}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
|
||||||
- POSTGRES_DB=${DB_NAME}
|
- POSTGRES_DB=${DATABASE_NAME}
|
||||||
|
ports:
|
||||||
|
- '${DATABASE_PORT}:5432'
|
||||||
|
restart: unless-stopped
|
||||||
server:
|
server:
|
||||||
container_name: sonixd
|
container_name: feishin
|
||||||
image: sonixd:latest
|
image: jeffvictorli/feishin:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
- APP_BASE_URL=${APP_BASE_URL}
|
- APP_BASE_URL=${APP_BASE_URL}
|
||||||
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@db/${DB_NAME}?schema=public&connection_limit=14&pool_timeout=20
|
- DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@db/${DATABASE_NAME}?schema=public&connection_limit=14&pool_timeout=20
|
||||||
- DATABASE_SECRET=${DB_SECRET}
|
- DATABASE_PORT=${DATABASE_PORT}
|
||||||
|
- TOKEN_SECRET=${TOKEN_SECRET}
|
||||||
|
- TOKEN_EXPIRATION=${TOKEN_EXPIRATION}
|
||||||
|
- TOKEN_REFRESH_EXPIRATION=${TOKEN_REFRESH_EXPIRATION}
|
||||||
ports:
|
ports:
|
||||||
- '9321:9321'
|
- '8643:9321'
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
./wait-for-it.sh db:$1 --timeout=20 --strict -- echo "db is up"
|
||||||
|
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
npx ts-node prisma/seed.ts
|
npx ts-node prisma/seed.ts
|
||||||
pm2-runtime server.js
|
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
|
||||||
Generated
+1937
-1563
File diff suppressed because it is too large
Load Diff
+59
-35
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "sonixd",
|
"name": "feishin",
|
||||||
"productName": "Sonixd",
|
"productName": "Feishin",
|
||||||
"description": "A full-featured Subsonic/Jellyfin compatible music player",
|
"description": "Feishin music server",
|
||||||
|
"version": "0.0.1-alpha1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||||
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
|
||||||
@@ -19,10 +20,14 @@
|
|||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"i18next": "i18next -c src/renderer/i18n/i18next-parser.config.js",
|
"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\"",
|
||||||
"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: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 sonixd_prisma",
|
"docker:down": "docker compose --file docker-compose.dev.yml --env-file .env.dev down && docker image rm feishin_prisma",
|
||||||
"docker:migrate": "cd src/server && npx prisma generate && docker exec -ti sonixd_server sh -c \"npx prisma generate && npx prisma db push\"",
|
"docker:dbpush": "cd server && npx prisma generate && docker exec -ti feishin_server sh -c \"npx prisma generate && npx prisma db push\"",
|
||||||
"docker:reset": "docker exec -ti sonixd_server sh -c \"npx prisma migrate reset && npx prisma db push && npx ts-node prisma/seed.ts\""
|
"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": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
@@ -39,8 +44,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"productName": "Sonixd",
|
"productName": "Feishin",
|
||||||
"appId": "org.erb.sonixd",
|
"appId": "org.jeffvli.feishin",
|
||||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||||
"asar": true,
|
"asar": true,
|
||||||
"asarUnpack": "**\\*.{node,dll}",
|
"asarUnpack": "**\\*.{node,dll}",
|
||||||
@@ -103,12 +108,12 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
"owner": "jeffvli",
|
"owner": "jeffvli",
|
||||||
"repo": "sonixd"
|
"repo": "feishin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/jeffvli/sonixd.git"
|
"url": "git+https://github.com/jeffvli/feishin.git"
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "jeffvli",
|
"name": "jeffvli",
|
||||||
@@ -117,7 +122,7 @@
|
|||||||
"contributors": [],
|
"contributors": [],
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/jeffvli/sonixd/issues"
|
"url": "https://github.com/jeffvli/feishin/issues"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"subsonic",
|
"subsonic",
|
||||||
@@ -127,7 +132,7 @@
|
|||||||
"react",
|
"react",
|
||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"homepage": "https://github.com/jeffvli/sonixd",
|
"homepage": "https://github.com/jeffvli/feishin",
|
||||||
"jest": {
|
"jest": {
|
||||||
"testURL": "http://localhost/",
|
"testURL": "http://localhost/",
|
||||||
"testEnvironment": "jsdom",
|
"testEnvironment": "jsdom",
|
||||||
@@ -162,19 +167,19 @@
|
|||||||
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@testing-library/react": "^13.0.0",
|
"@testing-library/react": "^13.0.0",
|
||||||
|
"@types/electron-localshortcut": "^3.1.0",
|
||||||
"@types/jest": "^27.4.1",
|
"@types/jest": "^27.4.1",
|
||||||
"@types/lodash": "^4.14.182",
|
"@types/lodash": "^4.14.188",
|
||||||
"@types/md5": "^2.3.2",
|
"@types/md5": "^2.3.2",
|
||||||
"@types/node": "^17.0.23",
|
"@types/node": "^17.0.23",
|
||||||
"@types/react": "^17.0.43",
|
"@types/react": "^18.0.25",
|
||||||
"@types/react-dom": "^17.0.14",
|
"@types/react-dom": "^18.0.8",
|
||||||
"@types/react-lazy-load-image-component": "^1.5.2",
|
|
||||||
"@types/react-slider": "^1.3.1",
|
"@types/react-slider": "^1.3.1",
|
||||||
"@types/react-test-renderer": "^17.0.1",
|
"@types/react-test-renderer": "^17.0.1",
|
||||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||||
"@types/react-window": "^1.8.5",
|
"@types/react-window": "^1.8.5",
|
||||||
"@types/react-window-infinite-loader": "^1.0.6",
|
"@types/react-window-infinite-loader": "^1.0.6",
|
||||||
"@types/styled-components": "^5.1.25",
|
"@types/styled-components": "^5.1.26",
|
||||||
"@types/terser-webpack-plugin": "^5.0.4",
|
"@types/terser-webpack-plugin": "^5.0.4",
|
||||||
"@types/webpack-bundle-analyzer": "^4.4.1",
|
"@types/webpack-bundle-analyzer": "^4.4.1",
|
||||||
"@types/webpack-env": "^1.16.3",
|
"@types/webpack-env": "^1.16.3",
|
||||||
@@ -188,7 +193,7 @@
|
|||||||
"css-loader": "^6.7.1",
|
"css-loader": "^6.7.1",
|
||||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||||
"detect-port": "^1.3.0",
|
"detect-port": "^1.3.0",
|
||||||
"electron": "^18.0.1",
|
"electron": "^21.2.0",
|
||||||
"electron-builder": "^23.0.3",
|
"electron-builder": "^23.0.3",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-notarize": "^1.2.1",
|
"electron-notarize": "^1.2.1",
|
||||||
@@ -236,6 +241,7 @@
|
|||||||
"ts-jest": "^27.1.4",
|
"ts-jest": "^27.1.4",
|
||||||
"ts-loader": "^9.2.8",
|
"ts-loader": "^9.2.8",
|
||||||
"ts-node": "^10.7.0",
|
"ts-node": "^10.7.0",
|
||||||
|
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||||
"typescript": "^4.6.4",
|
"typescript": "^4.6.4",
|
||||||
"typescript-plugin-styled-components": "^2.0.0",
|
"typescript-plugin-styled-components": "^2.0.0",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
@@ -246,13 +252,26 @@
|
|||||||
"webpack-merge": "^5.8.0"
|
"webpack-merge": "^5.8.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.10.4",
|
||||||
"@jellyfin/client-axios": "^10.7.8",
|
"@jellyfin/client-axios": "^10.7.8",
|
||||||
"@mantine/core": "^5.0.0",
|
"@mantine/core": "^5.8.0",
|
||||||
"@mantine/form": "^5.0.0",
|
"@mantine/dates": "^5.8.0",
|
||||||
"@mantine/hooks": "^5.0.0",
|
"@mantine/dropzone": "^5.8.0",
|
||||||
"axios": "^0.26.1",
|
"@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-debug": "^3.2.0",
|
||||||
|
"electron-localshortcut": "^3.2.1",
|
||||||
"electron-log": "^4.4.6",
|
"electron-log": "^4.4.6",
|
||||||
|
"electron-store": "^8.1.0",
|
||||||
"electron-updater": "^4.6.5",
|
"electron-updater": "^4.6.5",
|
||||||
"format-duration": "^2.0.0",
|
"format-duration": "^2.0.0",
|
||||||
"framer-motion": "^6.4.2",
|
"framer-motion": "^6.4.2",
|
||||||
@@ -265,24 +284,23 @@
|
|||||||
"nanoid": "^3.3.3",
|
"nanoid": "^3.3.3",
|
||||||
"net": "^1.0.2",
|
"net": "^1.0.2",
|
||||||
"node-mpv": "^2.0.0-beta.2",
|
"node-mpv": "^2.0.0-beta.2",
|
||||||
"react": "^18.0.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.0.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-helmet-async": "^1.3.0",
|
"react-error-boundary": "^3.1.4",
|
||||||
"react-i18next": "^11.16.7",
|
"react-i18next": "^11.16.7",
|
||||||
"react-lazy-load-image-component": "^1.5.4",
|
"react-icons": "^4.6.0",
|
||||||
"react-player": "^2.10.0",
|
"react-player": "^2.11.0",
|
||||||
"react-query": "^4.0.0-beta.23",
|
|
||||||
"react-router": "^6.3.0",
|
"react-router": "^6.3.0",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.3.0",
|
||||||
"react-slider": "^2.0.0",
|
"react-simple-img": "^3.0.0",
|
||||||
"react-spaces": "^0.3.4",
|
"react-slider": "^2.0.4",
|
||||||
"react-use": "^17.3.2",
|
|
||||||
"react-virtualized-auto-sizer": "^1.0.6",
|
"react-virtualized-auto-sizer": "^1.0.6",
|
||||||
"react-window": "^1.8.7",
|
"react-window": "^1.8.8",
|
||||||
"react-window-infinite-loader": "^1.0.8",
|
"react-window-infinite-loader": "^1.0.8",
|
||||||
"styled-components": "^5.3.5",
|
"socket.io-client": "^4.5.3",
|
||||||
"tabler-icons-react": "^1.46.0",
|
"styled-components": "^5.3.6",
|
||||||
"zustand": "^4.0.0-rc.1"
|
"zod": "^3.19.1",
|
||||||
|
"zustand": "^4.1.4"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"styled-components": "^5"
|
"styled-components": "^5"
|
||||||
@@ -305,5 +323,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"singleQuote": true
|
"singleQuote": true
|
||||||
|
},
|
||||||
|
"electronmon": {
|
||||||
|
"patterns": [
|
||||||
|
"!server",
|
||||||
|
"!src/renderer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sonixd",
|
"name": "feishin",
|
||||||
"version": "1.0.0-alpha1",
|
"version": "0.0.1-alpha1",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sonixd",
|
"name": "feishin",
|
||||||
"version": "1.0.0-alpha1",
|
"version": "0.0.1-alpha1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "sonixd",
|
"name": "feishin",
|
||||||
"version": "1.0.0-alpha1",
|
"version": "0.0.1-alpha1",
|
||||||
"description": "A full-featured Subsonic/Jellyfin compatible desktop client",
|
"description": "",
|
||||||
"main": "./dist/main/main.js",
|
"main": "./dist/main/main.js",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "jeffvli",
|
"name": "jeffvli",
|
||||||
|
|||||||
@@ -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,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,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,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,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,
|
||||||
|
};
|
||||||
@@ -9,16 +9,30 @@ import {
|
|||||||
import { Strategy as LocalStrategy } from 'passport-local';
|
import { Strategy as LocalStrategy } from 'passport-local';
|
||||||
import { prisma } from './prisma';
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
export const generateToken = (userId: number) => {
|
export const generateToken = (
|
||||||
return jwt.sign({ id: userId }, String(process.env.TOKEN_SECRET), {
|
id: string,
|
||||||
|
otherProperties?: { [key: string]: any }
|
||||||
|
) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{ id, ...otherProperties },
|
||||||
|
String(process.env.TOKEN_SECRET),
|
||||||
|
{
|
||||||
expiresIn: String(process.env.TOKEN_EXPIRATION || '15m'),
|
expiresIn: String(process.env.TOKEN_EXPIRATION || '15m'),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateRefreshToken = (userId: number) => {
|
export const generateRefreshToken = (
|
||||||
return jwt.sign({ id: userId }, String(process.env.TOKEN_SECRET), {
|
id: string,
|
||||||
|
otherProperties?: { [key: string]: any }
|
||||||
|
) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{ id, ...otherProperties },
|
||||||
|
String(process.env.TOKEN_SECRET),
|
||||||
|
{
|
||||||
expiresIn: String(process.env.TOKEN_REFRESH_EXPIRATION || '90d'),
|
expiresIn: String(process.env.TOKEN_REFRESH_EXPIRATION || '90d'),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const authenticateUser = async (
|
const authenticateUser = async (
|
||||||
@@ -54,12 +68,13 @@ passport.use(
|
|||||||
new JwtStrategy(jwtOptions, async (jwt_payload: any, done: any) => {
|
new JwtStrategy(jwtOptions, async (jwt_payload: any, done: any) => {
|
||||||
await prisma.user
|
await prisma.user
|
||||||
.findUnique({
|
.findUnique({
|
||||||
where: {
|
include: {
|
||||||
id: jwt_payload.id,
|
serverFolderPermissions: true,
|
||||||
|
serverPermissions: true,
|
||||||
},
|
},
|
||||||
|
where: { id: jwt_payload.id },
|
||||||
})
|
})
|
||||||
.then((user) => {
|
.then((user) => {
|
||||||
// eslint-disable-next-line promise/no-callback-in-promise
|
|
||||||
return done(null, user);
|
return done(null, user);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -72,7 +87,7 @@ passport.serializeUser((user: any, done) => {
|
|||||||
return done(null, user.id);
|
return done(null, user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
passport.deserializeUser(async (id: number, done) => {
|
passport.deserializeUser(async (id: string, done) => {
|
||||||
return done(
|
return done(
|
||||||
null,
|
null,
|
||||||
await prisma.user.findUnique({
|
await prisma.user.findUnique({
|
||||||
@@ -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,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();
|
||||||
|
};
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { ServerFolderPermission, ServerPermission, User } from '@prisma/client';
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import passport from 'passport';
|
import passport from 'passport';
|
||||||
|
|
||||||
export const authenticateLocal = (
|
export type AuthUser = Request['authUser'];
|
||||||
|
|
||||||
|
export const authenticate = (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
@@ -33,15 +36,32 @@ export const authenticateLocal = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
req.auth = {
|
const flatServerFolderPermissions = user.serverFolderPermissions.map(
|
||||||
|
(permission: ServerFolderPermission) => permission.serverFolderId
|
||||||
|
);
|
||||||
|
|
||||||
|
const flatServerPermissions = user.serverPermissions.map(
|
||||||
|
(permission: ServerPermission) => permission.serverId
|
||||||
|
);
|
||||||
|
|
||||||
|
const props = {
|
||||||
createdAt: user?.createdAt,
|
createdAt: user?.createdAt,
|
||||||
|
deviceId: user?.deviceId,
|
||||||
enabled: user?.enabled,
|
enabled: user?.enabled,
|
||||||
|
flatServerFolderPermissions,
|
||||||
|
flatServerPermissions,
|
||||||
id: user?.id,
|
id: user?.id,
|
||||||
isAdmin: user?.isAdmin,
|
isAdmin: user?.isAdmin,
|
||||||
|
isSuperAdmin: user?.isSuperAdmin,
|
||||||
|
serverFolderPermissions: user?.serverFolderPermissions,
|
||||||
|
serverId: req.params.serverId,
|
||||||
|
serverPermissions: user?.serverPermissions,
|
||||||
updatedAt: user?.updatedAt,
|
updatedAt: user?.updatedAt,
|
||||||
username: user?.username,
|
username: user?.username,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
req.authUser = props;
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
})(req, res, next);
|
})(req, res, next);
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import { isJsonString } from '../utils';
|
import { isJsonString } from '@utils/is-json-string';
|
||||||
|
|
||||||
export const errorHandler = (
|
export const errorHandler = (
|
||||||
err: any,
|
err: any,
|
||||||
@@ -9,12 +9,16 @@ export const errorHandler = (
|
|||||||
) => {
|
) => {
|
||||||
let message = '';
|
let message = '';
|
||||||
|
|
||||||
const trace = err.stack.match(/at .* \(.*\)/g).map((e: string) => {
|
const trace = err.stack?.match(/at .* \(.*\)/g).map((e: string) => {
|
||||||
return e.replace(/\(|\)/g, '');
|
return e.replace(/\(|\)/g, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (err.message) {
|
if (err.message) {
|
||||||
message = isJsonString(err.message) ? JSON.parse(err.message) : 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({
|
res.status(err.statusCode || 500).json({
|
||||||
@@ -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';
|
||||||
Generated
+18108
File diff suppressed because it is too large
Load Diff
@@ -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,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;
|
||||||
+14
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,17 +1,41 @@
|
|||||||
|
import { Server } from '@prisma/client';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Server } from '../../types/types';
|
|
||||||
import {
|
import {
|
||||||
JFAlbumArtistsResponse,
|
JFAlbumArtistsResponse,
|
||||||
JFAlbumsResponse,
|
JFAlbumsResponse,
|
||||||
JFArtistsResponse,
|
JFArtistsResponse,
|
||||||
|
JFAuthenticate,
|
||||||
|
JFCollectionType,
|
||||||
JFGenreResponse,
|
JFGenreResponse,
|
||||||
|
JFItemType,
|
||||||
JFMusicFoldersResponse,
|
JFMusicFoldersResponse,
|
||||||
JFRequestParams,
|
JFRequestParams,
|
||||||
JFSongsResponse,
|
JFSongsResponse,
|
||||||
} from './jellyfin-types';
|
} from './jellyfin.types';
|
||||||
|
|
||||||
export const api = axios.create({});
|
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>) => {
|
export const getMusicFolders = async (server: Partial<Server>) => {
|
||||||
const { data } = await api.get<JFMusicFoldersResponse>(
|
const { data } = await api.get<JFMusicFoldersResponse>(
|
||||||
`${server.url}/users/${server.remoteUserId}/items`,
|
`${server.url}/users/${server.remoteUserId}/items`,
|
||||||
@@ -19,7 +43,7 @@ export const getMusicFolders = async (server: Partial<Server>) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const musicFolders = data.Items.filter(
|
const musicFolders = data.Items.filter(
|
||||||
(folder) => folder.CollectionType === 'music'
|
(folder) => folder.CollectionType === JFCollectionType.MUSIC
|
||||||
);
|
);
|
||||||
|
|
||||||
return musicFolders;
|
return musicFolders;
|
||||||
@@ -63,7 +87,7 @@ export const getAlbums = async (server: Server, params: JFRequestParams) => {
|
|||||||
`${server.url}/users/${server.remoteUserId}/items`,
|
`${server.url}/users/${server.remoteUserId}/items`,
|
||||||
{
|
{
|
||||||
headers: { 'X-MediaBrowser-Token': server.token },
|
headers: { 'X-MediaBrowser-Token': server.token },
|
||||||
params: { includeItemTypes: 'MusicAlbum', ...params },
|
params: { includeItemTypes: JFItemType.MUSICALBUM, ...params },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,7 +99,7 @@ export const getSongs = async (server: Server, params: JFRequestParams) => {
|
|||||||
`${server.url}/users/${server.remoteUserId}/items`,
|
`${server.url}/users/${server.remoteUserId}/items`,
|
||||||
{
|
{
|
||||||
headers: { 'X-MediaBrowser-Token': server.token },
|
headers: { 'X-MediaBrowser-Token': server.token },
|
||||||
params: { includeItemTypes: 'Audio', ...params },
|
params: { includeItemTypes: JFItemType.AUDIO, ...params },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -83,6 +107,7 @@ export const getSongs = async (server: Server, params: JFRequestParams) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const jellyfinApi = {
|
export const jellyfinApi = {
|
||||||
|
authenticate,
|
||||||
getAlbumArtists,
|
getAlbumArtists,
|
||||||
getAlbums,
|
getAlbums,
|
||||||
getArtists,
|
getArtists,
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
+151
-10
@@ -49,7 +49,7 @@ export interface JFRequestParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface JFMusicFolder {
|
export interface JFMusicFolder {
|
||||||
BackdropImageTags: any[];
|
BackdropImageTags: string[];
|
||||||
ChannelId: null;
|
ChannelId: null;
|
||||||
CollectionType: string;
|
CollectionType: string;
|
||||||
Id: string;
|
Id: string;
|
||||||
@@ -68,7 +68,7 @@ export interface JFGenre {
|
|||||||
ChannelId: null;
|
ChannelId: null;
|
||||||
Id: string;
|
Id: string;
|
||||||
ImageBlurHashes: any;
|
ImageBlurHashes: any;
|
||||||
ImageTags: any;
|
ImageTags: ImageTags;
|
||||||
LocationType: string;
|
LocationType: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
ServerId: string;
|
ServerId: string;
|
||||||
@@ -84,7 +84,7 @@ export interface JFAlbumArtist {
|
|||||||
Genres: string[];
|
Genres: string[];
|
||||||
Id: string;
|
Id: string;
|
||||||
ImageBlurHashes: any;
|
ImageBlurHashes: any;
|
||||||
ImageTags: string[];
|
ImageTags: ImageTags;
|
||||||
LocationType: string;
|
LocationType: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
Overview?: string;
|
Overview?: string;
|
||||||
@@ -113,13 +113,13 @@ export interface JFArtist {
|
|||||||
|
|
||||||
export interface JFAlbum {
|
export interface JFAlbum {
|
||||||
AlbumArtist: string;
|
AlbumArtist: string;
|
||||||
AlbumArtists: GenericItem[];
|
AlbumArtists: JFGenericItem[];
|
||||||
ArtistItems: GenericItem[];
|
ArtistItems: JFGenericItem[];
|
||||||
Artists: string[];
|
Artists: string[];
|
||||||
ChannelId: null;
|
ChannelId: null;
|
||||||
DateCreated: string;
|
DateCreated: string;
|
||||||
ExternalUrls: ExternalURL[];
|
ExternalUrls: ExternalURL[];
|
||||||
GenreItems: GenericItem[];
|
GenreItems: JFGenericItem[];
|
||||||
Genres: string[];
|
Genres: string[];
|
||||||
Id: string;
|
Id: string;
|
||||||
ImageBlurHashes: ImageBlurHashes;
|
ImageBlurHashes: ImageBlurHashes;
|
||||||
@@ -139,16 +139,16 @@ export interface JFAlbum {
|
|||||||
export interface JFSong {
|
export interface JFSong {
|
||||||
Album: string;
|
Album: string;
|
||||||
AlbumArtist: string;
|
AlbumArtist: string;
|
||||||
AlbumArtists: GenericItem[];
|
AlbumArtists: JFGenericItem[];
|
||||||
AlbumId: string;
|
AlbumId: string;
|
||||||
AlbumPrimaryImageTag: string;
|
AlbumPrimaryImageTag: string;
|
||||||
ArtistItems: GenericItem[];
|
ArtistItems: JFGenericItem[];
|
||||||
Artists: string[];
|
Artists: string[];
|
||||||
BackdropImageTags: string[];
|
BackdropImageTags: string[];
|
||||||
ChannelId: null;
|
ChannelId: null;
|
||||||
DateCreated: string;
|
DateCreated: string;
|
||||||
ExternalUrls: ExternalURL[];
|
ExternalUrls: ExternalURL[];
|
||||||
GenreItems: GenericItem[];
|
GenreItems: JFGenericItem[];
|
||||||
Genres: string[];
|
Genres: string[];
|
||||||
Id: string;
|
Id: string;
|
||||||
ImageBlurHashes: ImageBlurHashes;
|
ImageBlurHashes: ImageBlurHashes;
|
||||||
@@ -164,6 +164,7 @@ export interface JFSong {
|
|||||||
ProductionYear: number;
|
ProductionYear: number;
|
||||||
RunTimeTicks: number;
|
RunTimeTicks: number;
|
||||||
ServerId: string;
|
ServerId: string;
|
||||||
|
SortName: string;
|
||||||
Type: string;
|
Type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ interface GenreItem {
|
|||||||
Name: string;
|
Name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenericItem {
|
export interface JFGenericItem {
|
||||||
Id: string;
|
Id: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
}
|
}
|
||||||
@@ -261,3 +262,143 @@ interface MediaStream {
|
|||||||
Type: string;
|
Type: string;
|
||||||
Width?: number;
|
Width?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum JFExternalType {
|
||||||
|
MUSICBRAINZ = 'MusicBrainz',
|
||||||
|
THEAUDIODB = 'TheAudioDb',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum JFImageType {
|
||||||
|
LOGO = 'Logo',
|
||||||
|
PRIMARY = 'Primary',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum JFItemType {
|
||||||
|
AUDIO = 'Audio',
|
||||||
|
MUSICALBUM = 'MusicAlbum',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum JFCollectionType {
|
||||||
|
MUSIC = 'music',
|
||||||
|
PLAYLISTS = 'playlists',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JFAuthenticate {
|
||||||
|
AccessToken: string;
|
||||||
|
ServerId: string;
|
||||||
|
SessionInfo: SessionInfo;
|
||||||
|
User: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionInfo {
|
||||||
|
AdditionalUsers: any[];
|
||||||
|
ApplicationVersion: string;
|
||||||
|
Capabilities: Capabilities;
|
||||||
|
Client: string;
|
||||||
|
DeviceId: string;
|
||||||
|
DeviceName: string;
|
||||||
|
HasCustomDeviceName: boolean;
|
||||||
|
Id: string;
|
||||||
|
IsActive: boolean;
|
||||||
|
LastActivityDate: string;
|
||||||
|
LastPlaybackCheckIn: string;
|
||||||
|
NowPlayingQueue: any[];
|
||||||
|
NowPlayingQueueFullItems: any[];
|
||||||
|
PlayState: PlayState;
|
||||||
|
PlayableMediaTypes: any[];
|
||||||
|
RemoteEndPoint: string;
|
||||||
|
ServerId: string;
|
||||||
|
SupportedCommands: any[];
|
||||||
|
SupportsMediaControl: boolean;
|
||||||
|
SupportsRemoteControl: boolean;
|
||||||
|
UserId: string;
|
||||||
|
UserName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Capabilities {
|
||||||
|
PlayableMediaTypes: any[];
|
||||||
|
SupportedCommands: any[];
|
||||||
|
SupportsContentUploading: boolean;
|
||||||
|
SupportsMediaControl: boolean;
|
||||||
|
SupportsPersistentIdentifier: boolean;
|
||||||
|
SupportsSync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayState {
|
||||||
|
CanSeek: boolean;
|
||||||
|
IsMuted: boolean;
|
||||||
|
IsPaused: boolean;
|
||||||
|
RepeatMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
Configuration: Configuration;
|
||||||
|
EnableAutoLogin: boolean;
|
||||||
|
HasConfiguredEasyPassword: boolean;
|
||||||
|
HasConfiguredPassword: boolean;
|
||||||
|
HasPassword: boolean;
|
||||||
|
Id: string;
|
||||||
|
LastActivityDate: string;
|
||||||
|
LastLoginDate: string;
|
||||||
|
Name: string;
|
||||||
|
Policy: Policy;
|
||||||
|
ServerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Configuration {
|
||||||
|
DisplayCollectionsView: boolean;
|
||||||
|
DisplayMissingEpisodes: boolean;
|
||||||
|
EnableLocalPassword: boolean;
|
||||||
|
EnableNextEpisodeAutoPlay: boolean;
|
||||||
|
GroupedFolders: any[];
|
||||||
|
HidePlayedInLatest: boolean;
|
||||||
|
LatestItemsExcludes: any[];
|
||||||
|
MyMediaExcludes: any[];
|
||||||
|
OrderedViews: any[];
|
||||||
|
PlayDefaultAudioTrack: boolean;
|
||||||
|
RememberAudioSelections: boolean;
|
||||||
|
RememberSubtitleSelections: boolean;
|
||||||
|
SubtitleLanguagePreference: string;
|
||||||
|
SubtitleMode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Policy {
|
||||||
|
AccessSchedules: any[];
|
||||||
|
AuthenticationProviderId: string;
|
||||||
|
BlockUnratedItems: any[];
|
||||||
|
BlockedChannels: any[];
|
||||||
|
BlockedMediaFolders: any[];
|
||||||
|
BlockedTags: any[];
|
||||||
|
EnableAllChannels: boolean;
|
||||||
|
EnableAllDevices: boolean;
|
||||||
|
EnableAllFolders: boolean;
|
||||||
|
EnableAudioPlaybackTranscoding: boolean;
|
||||||
|
EnableContentDeletion: boolean;
|
||||||
|
EnableContentDeletionFromFolders: any[];
|
||||||
|
EnableContentDownloading: boolean;
|
||||||
|
EnableLiveTvAccess: boolean;
|
||||||
|
EnableLiveTvManagement: boolean;
|
||||||
|
EnableMediaConversion: boolean;
|
||||||
|
EnableMediaPlayback: boolean;
|
||||||
|
EnablePlaybackRemuxing: boolean;
|
||||||
|
EnablePublicSharing: boolean;
|
||||||
|
EnableRemoteAccess: boolean;
|
||||||
|
EnableRemoteControlOfOtherUsers: boolean;
|
||||||
|
EnableSharedDeviceControl: boolean;
|
||||||
|
EnableSyncTranscoding: boolean;
|
||||||
|
EnableUserPreferenceAccess: boolean;
|
||||||
|
EnableVideoPlaybackTranscoding: boolean;
|
||||||
|
EnabledChannels: any[];
|
||||||
|
EnabledDevices: any[];
|
||||||
|
EnabledFolders: any[];
|
||||||
|
ForceRemoteSourceTranscoding: boolean;
|
||||||
|
InvalidLoginAttemptCount: number;
|
||||||
|
IsAdministrator: boolean;
|
||||||
|
IsDisabled: boolean;
|
||||||
|
IsHidden: boolean;
|
||||||
|
LoginAttemptsBeforeLockout: number;
|
||||||
|
MaxActiveSessions: number;
|
||||||
|
PasswordResetProviderId: string;
|
||||||
|
RemoteClientBitrateLimit: number;
|
||||||
|
SyncPlayAccess: string;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import {
|
||||||
|
ExternalSource,
|
||||||
|
ExternalType,
|
||||||
|
Folder,
|
||||||
|
ImageType,
|
||||||
|
Server,
|
||||||
|
ServerFolder,
|
||||||
|
Task,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import uniqBy from 'lodash/uniqBy';
|
||||||
|
import { prisma } from '@lib/prisma';
|
||||||
|
import { groupByProperty } from '@utils/group-by-property';
|
||||||
|
import { queue } from '../queues/index';
|
||||||
|
import { navidromeApi } from './navidrome.api';
|
||||||
|
import { navidromeUtils } from './navidrome.utils';
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 5000;
|
||||||
|
|
||||||
|
export const scanGenres = async (server: Server, task: Task) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning genres' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await navidromeApi.getGenres(server);
|
||||||
|
|
||||||
|
const genres = res.map((genre) => {
|
||||||
|
return { name: genre.name };
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.genre.createMany({
|
||||||
|
data: genres,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scanAlbumArtists = async (
|
||||||
|
server: Server,
|
||||||
|
serverFolder: ServerFolder,
|
||||||
|
task: Task
|
||||||
|
) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning artists' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const artists = await navidromeApi.getArtists(server);
|
||||||
|
|
||||||
|
const externalsCreateMany = artists
|
||||||
|
.filter((artist) => artist.mbzArtistId)
|
||||||
|
.map((artist) => ({
|
||||||
|
source: ExternalSource.MUSICBRAINZ,
|
||||||
|
type: ExternalType.ID,
|
||||||
|
value: artist.mbzArtistId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.external.createMany({
|
||||||
|
data: externalsCreateMany,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const artist of artists) {
|
||||||
|
const genresConnect = artist.genres
|
||||||
|
? artist.genres.map((genre) => ({ name: genre.name }))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const externalsConnect = artist.mbzArtistId
|
||||||
|
? {
|
||||||
|
uniqueExternalId: {
|
||||||
|
source: ExternalSource.MUSICBRAINZ,
|
||||||
|
value: artist.mbzArtistId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await prisma.albumArtist.upsert({
|
||||||
|
create: {
|
||||||
|
deleted: false,
|
||||||
|
externals: { connect: externalsConnect },
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
name: artist.name,
|
||||||
|
remoteId: artist.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: artist.name,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
deleted: false,
|
||||||
|
externals: { connect: externalsConnect },
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
name: artist.name,
|
||||||
|
remoteId: artist.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: artist.name,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: artist.id,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scanAlbums = async (
|
||||||
|
server: Server,
|
||||||
|
serverFolder: ServerFolder,
|
||||||
|
task: Task
|
||||||
|
) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning albums' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let count = 5000;
|
||||||
|
do {
|
||||||
|
const albums = await navidromeApi.getAlbums(server, {
|
||||||
|
_end: start + CHUNK_SIZE,
|
||||||
|
_start: start,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imagesCreateMany = albums
|
||||||
|
.filter((album) => album.coverArtId)
|
||||||
|
.map((album) => ({
|
||||||
|
remoteUrl: album.coverArtId,
|
||||||
|
type: ImageType.PRIMARY,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.image.createMany({
|
||||||
|
data: imagesCreateMany,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const artistIds = (
|
||||||
|
await prisma.artist.findMany({
|
||||||
|
select: { remoteId: true },
|
||||||
|
where: { serverId: server.id },
|
||||||
|
})
|
||||||
|
).map((artist) => artist.remoteId);
|
||||||
|
|
||||||
|
for (const album of albums) {
|
||||||
|
const imagesConnect = album.coverArtId
|
||||||
|
? {
|
||||||
|
uniqueImageId: {
|
||||||
|
remoteUrl: album.coverArtId,
|
||||||
|
type: ImageType.PRIMARY,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const genresConnect = album.genres
|
||||||
|
? album.genres.map((genre) => ({ name: genre.name }))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const validArtistIds = [];
|
||||||
|
const ndArtistIds = album.allArtistIds.split(' ');
|
||||||
|
|
||||||
|
for (const artistId of ndArtistIds) {
|
||||||
|
if (artistIds.includes(artistId)) {
|
||||||
|
validArtistIds.push(artistId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const artistsConnect = validArtistIds.map((id) => ({
|
||||||
|
// uniqueArtistId: {
|
||||||
|
// remoteId: id,
|
||||||
|
// serverId: server.id,
|
||||||
|
// },
|
||||||
|
// }));
|
||||||
|
|
||||||
|
const aaConnect = [];
|
||||||
|
const albumArtistConnect = album.albumArtistId
|
||||||
|
? {
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: album.albumArtistId,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
aaConnect.push(
|
||||||
|
...validArtistIds.map((id) => ({
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: id,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
albumArtistConnect && aaConnect.push(albumArtistConnect);
|
||||||
|
|
||||||
|
const year = album.minYear === 0 ? null : album.minYear;
|
||||||
|
|
||||||
|
await prisma.album.upsert({
|
||||||
|
create: {
|
||||||
|
albumArtists: { connect: aaConnect },
|
||||||
|
// artists: { connect: artistsConnect },
|
||||||
|
deleted: false,
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
images: { connect: imagesConnect },
|
||||||
|
name: album.name,
|
||||||
|
releaseDate: year ? new Date(year, 0).toISOString() : undefined,
|
||||||
|
releaseYear: year,
|
||||||
|
remoteCreatedAt: album.createdAt,
|
||||||
|
remoteId: album.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: album.name,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
albumArtists: { connect: aaConnect },
|
||||||
|
// artists: { connect: artistsConnect },
|
||||||
|
deleted: false,
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
images: { connect: imagesConnect },
|
||||||
|
name: album.name,
|
||||||
|
releaseDate: year ? new Date(year, 0).toISOString() : null,
|
||||||
|
releaseYear: year,
|
||||||
|
remoteCreatedAt: album.createdAt,
|
||||||
|
remoteId: album.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: album.name,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueAlbumId: {
|
||||||
|
remoteId: album.id,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start += CHUNK_SIZE;
|
||||||
|
count = albums.length;
|
||||||
|
} while (count === CHUNK_SIZE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanSongs = async (
|
||||||
|
server: Server,
|
||||||
|
serverFolder: ServerFolder,
|
||||||
|
task: Task
|
||||||
|
) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning songs' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let count = 5000;
|
||||||
|
do {
|
||||||
|
const songs = await navidromeApi.getSongs(server, {
|
||||||
|
_end: start + CHUNK_SIZE,
|
||||||
|
_start: start,
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalsCreateMany = [];
|
||||||
|
const genresCreateMany = [];
|
||||||
|
for (const song of songs) {
|
||||||
|
if (song.mbzTrackId) {
|
||||||
|
externalsCreateMany.push({
|
||||||
|
source: ExternalSource.MUSICBRAINZ,
|
||||||
|
type: ExternalType.ID,
|
||||||
|
value: song.mbzTrackId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (song.genres?.length > 0) {
|
||||||
|
genresCreateMany.push(
|
||||||
|
...song.genres.map((genre) => ({ name: genre.name }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.external.createMany({
|
||||||
|
data: externalsCreateMany,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.genre.createMany({
|
||||||
|
data: genresCreateMany,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderGroups = songs.map((song) => {
|
||||||
|
const songPaths = song.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 || !folder) 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumSongGroups = groupByProperty(songs, 'albumId');
|
||||||
|
const albumIds = Object.keys(albumSongGroups);
|
||||||
|
|
||||||
|
for (const id of albumIds) {
|
||||||
|
const songGroup = albumSongGroups[id];
|
||||||
|
await navidromeUtils.insertSongGroup(server, serverFolder, songGroup, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
start += CHUNK_SIZE;
|
||||||
|
count = songs.length;
|
||||||
|
} while (count === CHUNK_SIZE);
|
||||||
|
};
|
||||||
|
|
||||||
|
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, task);
|
||||||
|
await scanAlbumArtists(server, serverFolder, task);
|
||||||
|
await scanAlbums(server, serverFolder, task);
|
||||||
|
await scanSongs(server, serverFolder, task);
|
||||||
|
|
||||||
|
await prisma.serverFolder.update({
|
||||||
|
data: { lastScannedAt: new Date() },
|
||||||
|
where: { id: serverFolder.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { task };
|
||||||
|
},
|
||||||
|
id: task.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navidromeScanner = {
|
||||||
|
scanAll,
|
||||||
|
scanGenres,
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
export type NDAuthenticate = {
|
||||||
|
id: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
name: string;
|
||||||
|
subsonicSalt: string;
|
||||||
|
subsonicToken: string;
|
||||||
|
token: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDGenre = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDAlbum = {
|
||||||
|
albumArtist: string;
|
||||||
|
albumArtistId: string;
|
||||||
|
allArtistIds: string;
|
||||||
|
artist: string;
|
||||||
|
artistId: string;
|
||||||
|
compilation: boolean;
|
||||||
|
coverArtId: string;
|
||||||
|
coverArtPath: string;
|
||||||
|
createdAt: string;
|
||||||
|
duration: number;
|
||||||
|
fullText: string;
|
||||||
|
genre: string;
|
||||||
|
genres: NDGenre[];
|
||||||
|
id: string;
|
||||||
|
maxYear: number;
|
||||||
|
mbzAlbumArtistId: string;
|
||||||
|
mbzAlbumId: string;
|
||||||
|
minYear: number;
|
||||||
|
name: string;
|
||||||
|
orderAlbumArtistName: string;
|
||||||
|
orderAlbumName: string;
|
||||||
|
playCount: number;
|
||||||
|
playDate: string;
|
||||||
|
rating: number;
|
||||||
|
size: number;
|
||||||
|
songCount: number;
|
||||||
|
sortAlbumArtistName: string;
|
||||||
|
sortArtistName: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDSong = {
|
||||||
|
album: string;
|
||||||
|
albumArtist: string;
|
||||||
|
albumArtistId: string;
|
||||||
|
albumId: string;
|
||||||
|
artist: string;
|
||||||
|
artistId: string;
|
||||||
|
bitRate: number;
|
||||||
|
bookmarkPosition: number;
|
||||||
|
channels: number;
|
||||||
|
compilation: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
discNumber: number;
|
||||||
|
duration: number;
|
||||||
|
fullText: string;
|
||||||
|
genre: string;
|
||||||
|
genres: NDGenre[];
|
||||||
|
hasCoverArt: boolean;
|
||||||
|
id: string;
|
||||||
|
mbzAlbumArtistId: string;
|
||||||
|
mbzAlbumId: string;
|
||||||
|
mbzArtistId: string;
|
||||||
|
mbzTrackId: string;
|
||||||
|
orderAlbumArtistName: string;
|
||||||
|
orderAlbumName: string;
|
||||||
|
orderArtistName: string;
|
||||||
|
orderTitle: string;
|
||||||
|
path: string;
|
||||||
|
playCount: number;
|
||||||
|
playDate: string;
|
||||||
|
rating: number;
|
||||||
|
size: number;
|
||||||
|
sortAlbumArtistName: string;
|
||||||
|
sortArtistName: string;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string;
|
||||||
|
suffix: string;
|
||||||
|
title: string;
|
||||||
|
trackNumber: number;
|
||||||
|
updatedAt: string;
|
||||||
|
year: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDArtist = {
|
||||||
|
albumCount: number;
|
||||||
|
biography: string;
|
||||||
|
externalInfoUpdatedAt: string;
|
||||||
|
externalUrl: string;
|
||||||
|
fullText: string;
|
||||||
|
genres: NDGenre[];
|
||||||
|
id: string;
|
||||||
|
largeImageUrl: string;
|
||||||
|
mbzArtistId: string;
|
||||||
|
mediumImageUrl: string;
|
||||||
|
name: string;
|
||||||
|
orderArtistName: string;
|
||||||
|
playCount: number;
|
||||||
|
playDate: string;
|
||||||
|
rating: number;
|
||||||
|
size: number;
|
||||||
|
smallImageUrl: string;
|
||||||
|
songCount: number;
|
||||||
|
starred: boolean;
|
||||||
|
starredAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDGenreListResponse = NDGenre[];
|
||||||
|
|
||||||
|
export type NDAlbumListResponse = NDAlbum[];
|
||||||
|
|
||||||
|
export type NDSongListResponse = NDSong[];
|
||||||
|
|
||||||
|
export type NDArtistListResponse = NDArtist[];
|
||||||
|
|
||||||
|
export type NDPagination = {
|
||||||
|
_end?: number;
|
||||||
|
_start?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NDOrder = {
|
||||||
|
_order?: 'ASC' | 'DESC';
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum NDGenreSort {
|
||||||
|
NAME = 'name',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NDGenreListParams = {
|
||||||
|
_sort?: NDGenreSort;
|
||||||
|
id?: string;
|
||||||
|
} & NDPagination &
|
||||||
|
NDOrder;
|
||||||
|
|
||||||
|
export enum NDAlbumSort {
|
||||||
|
ARTIST = 'artist',
|
||||||
|
MAX_YEAR = 'max_year',
|
||||||
|
NAME = 'name',
|
||||||
|
RANDOM = 'random',
|
||||||
|
RECENTLY_ADDED = 'recently_added',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NDAlbumListParams = {
|
||||||
|
_sort?: NDAlbumSort;
|
||||||
|
artist_id?: string;
|
||||||
|
compilation?: boolean;
|
||||||
|
genre_id?: string;
|
||||||
|
has_rating?: boolean;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
recently_played?: boolean;
|
||||||
|
starred?: boolean;
|
||||||
|
year?: number;
|
||||||
|
} & NDPagination &
|
||||||
|
NDOrder;
|
||||||
|
|
||||||
|
export type NDSongListParams = {
|
||||||
|
genre_id?: string;
|
||||||
|
starred?: boolean;
|
||||||
|
} & NDPagination &
|
||||||
|
NDOrder;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { ExternalSource, Server, ServerFolder } from '@prisma/client';
|
||||||
|
import { prisma } from '@lib/prisma';
|
||||||
|
import { NDSong } from './navidrome.types';
|
||||||
|
|
||||||
|
const insertSongGroup = async (
|
||||||
|
server: Server,
|
||||||
|
serverFolder: ServerFolder,
|
||||||
|
songs: NDSong[],
|
||||||
|
remoteAlbumId: string
|
||||||
|
) => {
|
||||||
|
const songsWithArtistIds = songs.filter((song) => song.artistId);
|
||||||
|
const artistId =
|
||||||
|
songsWithArtistIds.length > 0 ? songsWithArtistIds[0].artistId : undefined;
|
||||||
|
|
||||||
|
const albumArtist = artistId
|
||||||
|
? await prisma.albumArtist.findUnique({
|
||||||
|
where: {
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: artistId,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const songsUpsert = songs.map((song) => {
|
||||||
|
const genresConnect = song.genres
|
||||||
|
? song.genres.map((genre) => ({ name: genre.name }))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const externalsConnect = song.mbzTrackId
|
||||||
|
? {
|
||||||
|
uniqueExternalId: {
|
||||||
|
source: ExternalSource.MUSICBRAINZ,
|
||||||
|
value: song.mbzTrackId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const pathSplit = song.path.split('/');
|
||||||
|
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
|
||||||
|
|
||||||
|
const year = song.year === 0 ? null : song.year;
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: {
|
||||||
|
albumArtistId: albumArtist?.id,
|
||||||
|
artistName: !song.artistId ? song.artist : undefined,
|
||||||
|
bitRate: song.bitRate,
|
||||||
|
container: song.suffix,
|
||||||
|
deleted: false,
|
||||||
|
discNumber: song.discNumber,
|
||||||
|
duration: song.duration,
|
||||||
|
externals: { connect: externalsConnect },
|
||||||
|
folders: {
|
||||||
|
connect: {
|
||||||
|
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
name: song.title,
|
||||||
|
releaseDate: year ? new Date(year, 0).toISOString() : undefined,
|
||||||
|
releaseYear: year,
|
||||||
|
remoteCreatedAt: song.createdAt,
|
||||||
|
remoteId: song.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
size: song.size,
|
||||||
|
sortName: song.title,
|
||||||
|
trackNumber: song.trackNumber,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
albumArtistId: albumArtist?.id,
|
||||||
|
artistName: !song.artistId ? song.artist : undefined,
|
||||||
|
bitRate: song.bitRate,
|
||||||
|
container: song.suffix,
|
||||||
|
deleted: false,
|
||||||
|
discNumber: song.discNumber,
|
||||||
|
duration: song.duration,
|
||||||
|
externals: { connect: externalsConnect },
|
||||||
|
folders: {
|
||||||
|
connect: {
|
||||||
|
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
name: song.title,
|
||||||
|
releaseDate: year ? new Date(song.year, 0).toISOString() : undefined,
|
||||||
|
releaseYear: year,
|
||||||
|
remoteCreatedAt: song.createdAt,
|
||||||
|
remoteId: song.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
size: song.size,
|
||||||
|
sortName: song.title,
|
||||||
|
trackNumber: song.trackNumber,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueSongId: {
|
||||||
|
remoteId: song.id,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.album.update({
|
||||||
|
data: {
|
||||||
|
deleted: false,
|
||||||
|
songs: { upsert: songsUpsert },
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueAlbumId: {
|
||||||
|
remoteId: remoteAlbumId,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const navidromeUtils = {
|
||||||
|
insertSongGroup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { scannerQueue } from './scanner.queue';
|
||||||
|
|
||||||
|
export const queue = {
|
||||||
|
scanner: scannerQueue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Task } from '@prisma/client';
|
||||||
|
import Queue from 'better-queue';
|
||||||
|
import { prisma } from '../../lib';
|
||||||
|
|
||||||
|
interface QueueTask {
|
||||||
|
fn: any;
|
||||||
|
id: string;
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scannerQueue: Queue | any = new Queue(
|
||||||
|
async (task: QueueTask, cb: any) => {
|
||||||
|
const result = await task.fn();
|
||||||
|
return cb(null, result);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
afterProcessDelay: 1000,
|
||||||
|
cancelIfRunning: true,
|
||||||
|
concurrent: 1,
|
||||||
|
filo: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
scannerQueue.on('task_finish', async (taskId: string) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: {
|
||||||
|
completed: true,
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
where: { id: taskId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scannerQueue.on('task_failed', async (taskId: string, errorMessage: string) => {
|
||||||
|
console.log('errorMessage', errorMessage);
|
||||||
|
await prisma.task.update({
|
||||||
|
data: {
|
||||||
|
completed: true,
|
||||||
|
isError: true,
|
||||||
|
message: errorMessage,
|
||||||
|
},
|
||||||
|
where: { id: taskId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scannerQueue.on('drain', async () => {
|
||||||
|
await prisma.task.updateMany({
|
||||||
|
data: { completed: true },
|
||||||
|
where: { completed: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { subsonicApi } from './subsonic.api';
|
||||||
|
import { subsonicScanner } from './subsonic.scanner';
|
||||||
|
|
||||||
|
export const subsonic = {
|
||||||
|
api: subsonicApi,
|
||||||
|
scanner: subsonicScanner,
|
||||||
|
};
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Server } from '@prisma/client';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Server } from '../../types/types';
|
import md5 from 'md5';
|
||||||
|
import { randomString } from '../../utils/random-string';
|
||||||
import {
|
import {
|
||||||
SSAlbumListEntry,
|
SSAlbumListEntry,
|
||||||
SSAlbumListResponse,
|
SSAlbumListResponse,
|
||||||
@@ -10,7 +12,7 @@ import {
|
|||||||
SSArtistsResponse,
|
SSArtistsResponse,
|
||||||
SSGenresResponse,
|
SSGenresResponse,
|
||||||
SSMusicFoldersResponse,
|
SSMusicFoldersResponse,
|
||||||
} from './subsonic-types';
|
} from './subsonic.types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
validateStatus: (status) => status >= 200,
|
validateStatus: (status) => status >= 200,
|
||||||
@@ -26,9 +28,34 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const authenticate = async (options: {
|
||||||
|
legacy?: boolean;
|
||||||
|
password: string;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
}) => {
|
||||||
|
let token;
|
||||||
|
|
||||||
|
const cleanServerUrl = options.url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (options.legacy) {
|
||||||
|
token = `u=${options.username}&p=${options.password}`;
|
||||||
|
} else {
|
||||||
|
const salt = randomString(12);
|
||||||
|
const hash = md5(options.password + salt);
|
||||||
|
token = `u=${options.username}&s=${salt}&t=${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.get(
|
||||||
|
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${token}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { token, ...data };
|
||||||
|
};
|
||||||
|
|
||||||
const getMusicFolders = async (server: Partial<Server>) => {
|
const getMusicFolders = async (server: Partial<Server>) => {
|
||||||
const { data } = await api.get<SSMusicFoldersResponse>(
|
const { data } = await api.get<SSMusicFoldersResponse>(
|
||||||
`${server.url}/rest/getMusicFolders.view?v=1.13.0&c=sonixd&f=json&${server.token}`
|
`${server.url}/rest/getMusicFolders.view?v=1.13.0&c=Feishin&f=json&${server.token}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return data.musicFolders.musicFolder;
|
return data.musicFolders.musicFolder;
|
||||||
@@ -36,7 +63,7 @@ const getMusicFolders = async (server: Partial<Server>) => {
|
|||||||
|
|
||||||
const getArtists = async (server: Server, musicFolderId: string) => {
|
const getArtists = async (server: Server, musicFolderId: string) => {
|
||||||
const { data } = await api.get<SSArtistsResponse>(
|
const { data } = await api.get<SSArtistsResponse>(
|
||||||
`${server.url}/rest/getArtists.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
|
`${server.url}/rest/getArtists.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||||
{ params: { musicFolderId } }
|
{ params: { musicFolderId } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,7 +76,7 @@ const getArtists = async (server: Server, musicFolderId: string) => {
|
|||||||
|
|
||||||
const getGenres = async (server: Server) => {
|
const getGenres = async (server: Server) => {
|
||||||
const { data: genres } = await api.get<SSGenresResponse>(
|
const { data: genres } = await api.get<SSGenresResponse>(
|
||||||
`${server.url}/rest/getGenres.view?v=1.13.0&c=sonixd&f=json&${server.token}`
|
`${server.url}/rest/getGenres.view?v=1.13.0&c=Feishin&f=json&${server.token}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return genres;
|
return genres;
|
||||||
@@ -57,7 +84,7 @@ const getGenres = async (server: Server) => {
|
|||||||
|
|
||||||
const getAlbum = async (server: Server, id: string) => {
|
const getAlbum = async (server: Server, id: string) => {
|
||||||
const { data: album } = await api.get<SSAlbumResponse>(
|
const { data: album } = await api.get<SSAlbumResponse>(
|
||||||
`${server.url}/rest/getAlbum.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
|
`${server.url}/rest/getAlbum.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||||
{ params: { id } }
|
{ params: { id } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,13 +98,13 @@ const getAlbums = async (
|
|||||||
) => {
|
) => {
|
||||||
const albums: any = api
|
const albums: any = api
|
||||||
.get<SSAlbumListResponse>(
|
.get<SSAlbumListResponse>(
|
||||||
`${server.url}/rest/getAlbumList2.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
|
`${server.url}/rest/getAlbumList2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||||
{ params }
|
{ params }
|
||||||
)
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (
|
if (
|
||||||
!res.data.albumList2.album ||
|
!res.data.albumList2?.album ||
|
||||||
res.data.albumList2.album.length === 0
|
res.data.albumList2?.album?.length === 0
|
||||||
) {
|
) {
|
||||||
// Flatten and return once there are no more albums left
|
// Flatten and return once there are no more albums left
|
||||||
return recursiveData.flatMap((album) => album);
|
return recursiveData.flatMap((album) => album);
|
||||||
@@ -104,7 +131,7 @@ const getAlbums = async (
|
|||||||
|
|
||||||
const getArtistInfo = async (server: Server, id: string) => {
|
const getArtistInfo = async (server: Server, id: string) => {
|
||||||
const { data: artistInfo } = await api.get<SSArtistInfoResponse>(
|
const { data: artistInfo } = await api.get<SSArtistInfoResponse>(
|
||||||
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
|
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||||
{ params: { id } }
|
{ params: { id } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,6 +147,7 @@ const getArtistInfo = async (server: Server, id: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const subsonicApi = {
|
export const subsonicApi = {
|
||||||
|
authenticate,
|
||||||
getAlbum,
|
getAlbum,
|
||||||
getAlbums,
|
getAlbums,
|
||||||
getArtistInfo,
|
getArtistInfo,
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import { ImageType, Server, ServerFolder, Task } from '@prisma/client';
|
||||||
|
import { prisma, throttle } from '../../lib/index';
|
||||||
|
import { uniqueArray } from '../../utils/index';
|
||||||
|
import { queue } from '../queues';
|
||||||
|
import { subsonicApi } from './subsonic.api';
|
||||||
|
import { subsonicUtils } from './subsonic.utils';
|
||||||
|
|
||||||
|
export const scanGenres = async (server: Server, task: Task) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning genres' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await subsonicApi.getGenres(server);
|
||||||
|
|
||||||
|
const genres = res.genres.genre.map((genre) => {
|
||||||
|
return { name: genre.value };
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.genre.createMany({
|
||||||
|
data: genres,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scanAlbumArtists = async (
|
||||||
|
server: Server,
|
||||||
|
serverFolder: ServerFolder,
|
||||||
|
task: Task
|
||||||
|
) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning artists' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const artists = await subsonicApi.getArtists(server, serverFolder.remoteId);
|
||||||
|
|
||||||
|
for (const artist of artists) {
|
||||||
|
await prisma.albumArtist.upsert({
|
||||||
|
create: {
|
||||||
|
name: artist.name,
|
||||||
|
remoteId: artist.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: artist.name,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: artist.name,
|
||||||
|
remoteId: artist.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: artist.name,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: artist.id,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scanAlbums = async (
|
||||||
|
server: Server,
|
||||||
|
serverFolder: ServerFolder,
|
||||||
|
task: Task
|
||||||
|
) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning albums' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const albums = await subsonicApi.getAlbums(server, {
|
||||||
|
musicFolderId: serverFolder.remoteId,
|
||||||
|
offset: 0,
|
||||||
|
size: 500,
|
||||||
|
type: 'newest',
|
||||||
|
});
|
||||||
|
|
||||||
|
await subsonicUtils.insertImages(albums);
|
||||||
|
|
||||||
|
for (const album of albums) {
|
||||||
|
const imagesConnect = album.coverArt
|
||||||
|
? {
|
||||||
|
uniqueImageId: {
|
||||||
|
remoteUrl: album.coverArt,
|
||||||
|
type: ImageType.PRIMARY,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const albumArtistConnect = album.artistId
|
||||||
|
? {
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: album.artistId,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await prisma.album.upsert({
|
||||||
|
create: {
|
||||||
|
albumArtists: { connect: albumArtistConnect },
|
||||||
|
deleted: false,
|
||||||
|
genres: { connect: album.genre ? { name: album.genre } : undefined },
|
||||||
|
images: { connect: imagesConnect },
|
||||||
|
name: album.name,
|
||||||
|
releaseDate: album?.year
|
||||||
|
? new Date(Number(String(album.year).slice(4)), 0).toISOString()
|
||||||
|
: undefined,
|
||||||
|
releaseYear: album.year,
|
||||||
|
remoteCreatedAt: album.created,
|
||||||
|
remoteId: album.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: album.name,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
albumArtists: { connect: albumArtistConnect },
|
||||||
|
deleted: false,
|
||||||
|
genres: { connect: album.genre ? { name: album.genre } : undefined },
|
||||||
|
images: { connect: imagesConnect },
|
||||||
|
name: album.name,
|
||||||
|
releaseDate: album?.year
|
||||||
|
? new Date(Number(String(album.year).slice(4)), 0).toISOString()
|
||||||
|
: undefined,
|
||||||
|
releaseYear: album.year,
|
||||||
|
remoteCreatedAt: album.created,
|
||||||
|
remoteId: album.id,
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
serverId: server.id,
|
||||||
|
sortName: album.name,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueAlbumId: {
|
||||||
|
remoteId: album.id,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const throttledAlbumFetch = throttle(
|
||||||
|
async (server: Server, serverFolder: ServerFolder, album: any) => {
|
||||||
|
const albumRes = await subsonicApi.getAlbum(server, album.remoteId);
|
||||||
|
|
||||||
|
if (albumRes) {
|
||||||
|
await subsonicUtils.insertSongImages(albumRes);
|
||||||
|
const songsUpsert = albumRes.album.song.map((song) => {
|
||||||
|
const genresConnect = song.genre ? { name: song.genre } : undefined;
|
||||||
|
|
||||||
|
const imagesConnect = song.coverArt
|
||||||
|
? {
|
||||||
|
uniqueImageId: {
|
||||||
|
remoteUrl: song.coverArt,
|
||||||
|
type: ImageType.PRIMARY,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const albumArtistsConnect = song.artistId
|
||||||
|
? {
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: song.artistId,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: {
|
||||||
|
// albumArtistId: song.artistId ? song.artistId : undefined,
|
||||||
|
albumArtist: { connect: albumArtistsConnect },
|
||||||
|
artistName: !song.artistId ? song.artist : undefined,
|
||||||
|
bitRate: song.bitRate ? song.bitRate : undefined,
|
||||||
|
container: song.suffix,
|
||||||
|
deleted: false,
|
||||||
|
discNumber: song.discNumber,
|
||||||
|
duration: song.duration,
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
images: { connect: imagesConnect },
|
||||||
|
name: song.title,
|
||||||
|
releaseDate: song?.year
|
||||||
|
? new Date(Number(String(song.year).slice(4)), 0).toISOString()
|
||||||
|
: undefined,
|
||||||
|
releaseYear: song.year,
|
||||||
|
remoteCreatedAt: song.created,
|
||||||
|
remoteId: song.id,
|
||||||
|
server: { connect: { id: server.id } },
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
// serverId: server.id,
|
||||||
|
size: song.size,
|
||||||
|
sortName: song.title,
|
||||||
|
trackNumber: song.track,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
albumArtist: { connect: albumArtistsConnect },
|
||||||
|
// albumArtistId: song.artistId ? song.artistId : undefined,
|
||||||
|
artistName: !song.artistId ? song.artist : undefined,
|
||||||
|
|
||||||
|
bitRate: song.bitRate ? song.bitRate : undefined,
|
||||||
|
container: song.suffix,
|
||||||
|
deleted: false,
|
||||||
|
discNumber: song.discNumber,
|
||||||
|
duration: song.duration,
|
||||||
|
genres: { connect: genresConnect },
|
||||||
|
images: { connect: imagesConnect },
|
||||||
|
name: song.title,
|
||||||
|
releaseDate: song?.year
|
||||||
|
? new Date(Number(String(song.year).slice(4)), 0).toISOString()
|
||||||
|
: undefined,
|
||||||
|
releaseYear: song.year,
|
||||||
|
remoteCreatedAt: song.created,
|
||||||
|
remoteId: song.id,
|
||||||
|
server: { connect: { id: server.id } },
|
||||||
|
serverFolders: { connect: { id: serverFolder.id } },
|
||||||
|
|
||||||
|
// serverId: server.id,
|
||||||
|
size: song.size,
|
||||||
|
sortName: song.title,
|
||||||
|
trackNumber: song.track,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueSongId: {
|
||||||
|
remoteId: song.id,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueArtistIds = albumRes.album.song
|
||||||
|
.map((song) => song.artistId)
|
||||||
|
.filter(uniqueArray);
|
||||||
|
|
||||||
|
const artistsConnect = uniqueArtistIds.map((artistId) => {
|
||||||
|
return {
|
||||||
|
uniqueAlbumArtistId: {
|
||||||
|
remoteId: artistId!,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.album.update({
|
||||||
|
data: {
|
||||||
|
// albumArtists: { connect: artistsConnect },
|
||||||
|
songs: { upsert: songsUpsert },
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uniqueAlbumId: {
|
||||||
|
remoteId: album.remoteId,
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const scanAlbumDetail = async (
|
||||||
|
server: Server,
|
||||||
|
serverFolder: ServerFolder,
|
||||||
|
task: Task
|
||||||
|
) => {
|
||||||
|
await prisma.task.update({
|
||||||
|
data: { message: 'Scanning songs' },
|
||||||
|
where: { id: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
const dbAlbums = await prisma.album.findMany({
|
||||||
|
where: {
|
||||||
|
serverId: server.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < dbAlbums.length; i += 1) {
|
||||||
|
await throttledAlbumFetch(server, serverFolder, dbAlbums[i]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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, task);
|
||||||
|
await scanAlbumArtists(server, serverFolder, task);
|
||||||
|
await scanAlbums(server, serverFolder, task);
|
||||||
|
await scanAlbumDetail(server, serverFolder, task);
|
||||||
|
|
||||||
|
await prisma.serverFolder.update({
|
||||||
|
data: { lastScannedAt: new Date() },
|
||||||
|
where: { id: serverFolder.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { task };
|
||||||
|
},
|
||||||
|
id: task.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subsonicScanner = {
|
||||||
|
scanAll,
|
||||||
|
scanGenres,
|
||||||
|
};
|
||||||
+3
-3
@@ -54,8 +54,8 @@ export interface SSMusicFolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SSGenre {
|
export interface SSGenre {
|
||||||
albumCount: number;
|
albumCount?: number;
|
||||||
songCount: number;
|
songCount?: number;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ export interface SSSong {
|
|||||||
export interface SSAlbumsParams {
|
export interface SSAlbumsParams {
|
||||||
fromYear?: number;
|
fromYear?: number;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
musicFolderId?: number;
|
musicFolderId?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
toYear?: number;
|
toYear?: number;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ImageType } from '@prisma/client';
|
||||||
|
import { prisma } from '../../lib';
|
||||||
|
import { SSAlbumListEntry, SSAlbumResponse } from './subsonic.types';
|
||||||
|
|
||||||
|
const insertImages = async (items: SSAlbumListEntry[]) => {
|
||||||
|
const createMany = items
|
||||||
|
.filter((item) => item.coverArt)
|
||||||
|
.map((item) => ({
|
||||||
|
remoteUrl: item.coverArt,
|
||||||
|
type: ImageType.PRIMARY,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.image.createMany({
|
||||||
|
data: createMany,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertSongImages = async (item: SSAlbumResponse) => {
|
||||||
|
const createMany = item.album.song
|
||||||
|
.filter((song) => song.coverArt)
|
||||||
|
.map((song) => ({
|
||||||
|
remoteUrl: song.coverArt,
|
||||||
|
type: ImageType.PRIMARY,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.image.createMany({
|
||||||
|
data: createMany,
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subsonicUtils = {
|
||||||
|
insertImages,
|
||||||
|
insertSongImages,
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import express, { Router } from 'express';
|
||||||
|
import { controller } from '@controllers/index';
|
||||||
|
import { validateRequest, validation } from '@validations/index';
|
||||||
|
|
||||||
|
export const router: Router = express.Router({
|
||||||
|
mergeParams: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', controller.albumArtists.getList);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
':serverId',
|
||||||
|
validateRequest(validation.albumArtists.detail),
|
||||||
|
controller.albumArtists.getDetail
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import express, { Router } from 'express';
|
||||||
|
import { controller } from '@controllers/index';
|
||||||
|
import { validateRequest, validation } from '@validations/index';
|
||||||
|
|
||||||
|
export const router: Router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
validateRequest(validation.albums.list),
|
||||||
|
controller.albums.getList
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:albumId',
|
||||||
|
validateRequest(validation.albums.detail),
|
||||||
|
controller.albums.getDetail
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:albumId/songs',
|
||||||
|
validateRequest(validation.albums.detail),
|
||||||
|
controller.albums.getDetailSongList
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import express, { Router } from 'express';
|
||||||
|
import { controller } from '@controllers/index';
|
||||||
|
|
||||||
|
export const router: Router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
router.get('/', controller.artists.getList);
|
||||||
|
|
||||||
|
router.get(':serverId', controller.artists.getDetail);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import express, { Router } from 'express';
|
||||||
|
import passport from 'passport';
|
||||||
|
import { controller } from '@controllers/index';
|
||||||
|
import { authenticate } from '@middleware/authenticate';
|
||||||
|
import { validation, validateRequest } from '@validations/index';
|
||||||
|
|
||||||
|
export const router: Router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/login',
|
||||||
|
validateRequest(validation.auth.login),
|
||||||
|
passport.authenticate('local'),
|
||||||
|
controller.auth.login
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/register',
|
||||||
|
validateRequest(validation.auth.register),
|
||||||
|
controller.auth.register
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/logout', authenticate, controller.auth.logout);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/refresh',
|
||||||
|
validateRequest(validation.auth.refresh),
|
||||||
|
controller.auth.refresh
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/ping', controller.auth.ping);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import express, { Router } from 'express';
|
||||||
|
import { controller } from '@controllers/index';
|
||||||
|
import { validation } from '@validations/index';
|
||||||
|
import { validateRequest } from '@validations/shared.validation';
|
||||||
|
|
||||||
|
export const router: Router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
validateRequest(validation.genres.list),
|
||||||
|
controller.genres.getList
|
||||||
|
);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { helpers } from '../helpers';
|
||||||
|
import { authenticate } from '../middleware';
|
||||||
|
import { router as albumArtistsRouter } from './album-artists.route';
|
||||||
|
import { router as albumsRouter } from './albums.route';
|
||||||
|
import { router as artistsRouter } from './artists.route';
|
||||||
|
import { router as authRouter } from './auth.route';
|
||||||
|
import { router as genresRouter } from './genres.route';
|
||||||
|
import { router as serversRouter } from './servers.route';
|
||||||
|
import { router as songsRouter } from './songs.route';
|
||||||
|
import { router as tasksRouter } from './tasks.route';
|
||||||
|
import { router as usersRouter } from './users.route';
|
||||||
|
|
||||||
|
export const routes = Router({ mergeParams: true });
|
||||||
|
|
||||||
|
routes.use('/api/auth', authRouter);
|
||||||
|
|
||||||
|
routes.use(authenticate, (_req, _res, next) => {
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.use('/api/tasks', tasksRouter);
|
||||||
|
routes.use('/api/users', usersRouter);
|
||||||
|
routes.use('/api/servers', serversRouter);
|
||||||
|
|
||||||
|
routes.param('serverId', (req, _res, next, serverId) => {
|
||||||
|
const { serverFolderId } = req.query as {
|
||||||
|
serverFolderId?: string[] | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
req.authUser.serverId = serverId;
|
||||||
|
|
||||||
|
helpers.shared.checkServerPermissions(req.authUser, { serverId });
|
||||||
|
|
||||||
|
helpers.shared.checkServerFolderPermissions(req.authUser, {
|
||||||
|
serverFolderId,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof req.query.serverFolderId === 'string') {
|
||||||
|
req.query.serverFolderId = [req.query.serverFolderId];
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.use('/api/servers/:serverId/album-artists', albumArtistsRouter);
|
||||||
|
routes.use('/api/servers/:serverId/artists', artistsRouter);
|
||||||
|
routes.use('/api/servers/:serverId/albums', albumsRouter);
|
||||||
|
routes.use('/api/servers/:serverId/genres', genresRouter);
|
||||||
|
routes.use('/api/servers/:serverId/songs', songsRouter);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user