Move server directory outside of frontend src

This commit is contained in:
jeffvli
2022-10-25 16:52:45 -07:00
parent 863dce88b7
commit 0438f2d5f2
105 changed files with 16946 additions and 6901 deletions
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-7
View File
@@ -1,7 +0,0 @@
module.exports = {
rules: {
'@typescript-eslint/lines-between-class-members': 'off',
'import/no-cycle': 'error',
'import/no-unresolved': 'error',
},
};
-2
View File
@@ -1,2 +0,0 @@
node_modules
dist
-22
View File
@@ -1,22 +0,0 @@
@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}}
-22
View File
@@ -1,22 +0,0 @@
@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}}
-22
View File
@@ -1,22 +0,0 @@
@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}}
-52
View File
@@ -1,52 +0,0 @@
###
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 =
-66
View File
@@ -1,66 +0,0 @@
@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": [""]
}
-14
View File
@@ -1,14 +0,0 @@
@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}}
-21
View File
@@ -1,21 +0,0 @@
@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}}
-28
View File
@@ -1,28 +0,0 @@
FROM node:16.5-alpine
ARG DATABASE_PORT
ADD docker-entrypoint.sh /
RUN chmod +x /docker-entrypoint.sh
COPY ./wait-for-it.sh /wait-for-it.sh
RUN chmod +x /wait-for-it.sh
# Change directory so that our commands run inside this new directory
WORKDIR /app
# Copy dependency definitions
COPY package.*json ./
COPY prisma ./
# Install dependecies
RUN npm install
# Get all the code needed to run the app
COPY . .
# Expose the port the app runs in
EXPOSE 9321
# Serve the app
ENTRYPOINT ./docker-entrypoint.sh $DATABASE_PORT
@@ -1,45 +0,0 @@
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,
};
-102
View File
@@ -1,102 +0,0 @@
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 } = req.params;
const album = await service.albums.findById(req.authUser, { id: albumId });
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 } = req.query;
const albums = await service.albums.findMany({
...req.query,
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,
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,
};
@@ -1,50 +0,0 @@
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,
};
-67
View File
@@ -1,67 +0,0 @@
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 };
-17
View File
@@ -1,17 +0,0 @@
import { albumArtistsController } from './album-artists.controller';
import { albumsController } from './albums.controller';
import { artistsController } from './artists.controller';
import { authController } from './auth.controller';
import { serversController } from './servers.controller';
import { songsController } from './songs.controller';
import { usersController } from './users.controller';
export const controller = {
albumArtists: albumArtistsController,
albums: albumsController,
artists: artistsController,
auth: authController,
servers: serversController,
songs: songsController,
users: usersController,
};
@@ -1,177 +0,0 @@
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 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 data = await service.servers.findMany(req.authUser);
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 } = 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, ...remoteServerLoginRes }
);
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, 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 scanServer = async (
req: TypedRequest<typeof validation.servers.scan>,
res: Response
) => {
const { serverId } = req.params;
const { serverFolderId } = req.body;
const data = await service.servers.fullScan({
id: serverId,
serverFolderId,
});
const success = ApiSuccess.ok({ data });
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.noContent({ 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.noContent({ 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.noContent({ data: null });
return res.status(success.statusCode).json(getSuccessResponse(success));
};
export const serversController = {
createServer,
createServerUrl,
deleteServer,
deleteServerUrl,
disableServerUrl,
enableServerUrl,
getServerDetail,
getServerList,
refreshServer,
scanServer,
updateServer,
};
@@ -1,33 +0,0 @@
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,
};
@@ -1,22 +0,0 @@
import { Request, Response } from 'express';
import { ApiSuccess, getSuccessResponse } from '@/utils';
import { toApiModel } from '@helpers/api-model';
import { service } from '@services/index';
const getUserDetail = async (req: Request, res: Response) => {
const { id } = req.params;
const user = await service.users.findById(req.authUser, { id });
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));
};
export const usersController = {
getUserDetail,
getUserList,
};
-10
View File
@@ -1,10 +0,0 @@
apk add --no-cache bash
./wait-for-it.sh db:$1 --timeout=20 --strict -- echo "db is up"
npx prisma generate
npx prisma migrate deploy
npx ts-node prisma/seed.ts
npm run dev
-86
View File
@@ -1,86 +0,0 @@
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 = (options: { songs?: boolean; user?: AuthUser }) => {
// Prisma.AlbumInclude
const props = {
_count: {
select: {
favorites: true,
songs: true,
},
},
albumArtists: true,
artists: true,
favorites: { where: { userId: options.user?.id } },
genres: true,
images: true,
ratings: {
where: {
userId: options.user?.id,
},
},
server: true,
serverFolders: true,
songs: options?.songs && songHelpers.findMany(),
};
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, year: 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 const albumHelpers = {
include,
sort,
};
-520
View File
@@ -1,520 +0,0 @@
/* eslint-disable no-underscore-dangle */
import {
Album,
AlbumArtist,
AlbumArtistRating,
AlbumRating,
Artist,
ArtistRating,
External,
Genre,
Image,
ImageType,
Server,
ServerFolder,
ServerFolderPermission,
ServerPermission,
ServerType,
ServerUrl,
Song,
SongRating,
User,
UserServerUrl,
} from '@prisma/client';
const getSubsonicStreamUrl = (
remoteId: string,
url: string,
token: string,
deviceId: string
) => {
return (
`${url}/rest/stream.view` +
`?id=${remoteId}` +
`&${token}` +
`&v=1.13.0` +
`&c=sonixd_${deviceId}`
);
};
const getJellyfinStreamUrl = (
remoteId: string,
url: string,
token: string,
userId: string,
deviceId: string
) => {
return (
`${url}/audio` +
`/${remoteId}/universal` +
`?userId=${userId}` +
`&audioCodec=aac` +
`&container=opus,mp3,aac,m4a,m4b,flac,wav,ogg` +
`&transcodingContainer=ts` +
`&transcodingProtocol=hls` +
`&deviceId=sonixd_${deviceId}` +
`&playSessionId=${deviceId}` +
`&api_key=${token}`
);
};
const streamUrl = (
type: ServerType,
args: {
deviceId: string;
remoteId: string;
token: string;
url: string;
userId?: string;
}
) => {
if (type === ServerType.JELLYFIN) {
return getJellyfinStreamUrl(
args.remoteId,
args.url,
args.token,
args.userId || '',
args.deviceId
);
}
return getSubsonicStreamUrl(
args.remoteId,
args.url,
args.token,
args.deviceId
);
};
const imageUrl = (
type: ServerType,
baseUrl: string,
imageId: string,
token?: string
) => {
if (type === ServerType.JELLYFIN) {
return (
`${baseUrl}/Items` +
`/${imageId}` +
`/Images/Primary` +
'?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=sonixd` +
`&${token}`
);
}
return null;
};
const relatedAlbum = (item: Album) => {
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 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 relatedServerFolders = (items: ServerFolder[]) => {
const serverFolders = items?.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
name: item.name,
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 image = (
images: Image[],
type: ServerType,
imageType: ImageType,
url: string,
remoteId: string,
token?: string
) => {
const imageRemoteUrl = images.find((i) => i.type === imageType)?.remoteUrl;
if (!imageRemoteUrl) return null;
if (type === ServerType.JELLYFIN) {
return imageUrl(type, url, remoteId);
}
if (type === ServerType.SUBSONIC || type === ServerType.NAVIDROME) {
return imageUrl(type, url, imageRemoteUrl, token);
}
return null;
};
type DbSong = Song & DbSongInclude;
type DbSongInclude = {
album: Album;
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;
}
) => {
return (
items?.map((item) => {
const url = options.url ? options.url : item.server.serverUrls[0].url;
const stream = streamUrl(options.type, {
deviceId: options.deviceId,
remoteId: item.remoteId,
token: options.token,
url: options.url,
userId: options.userId,
});
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: image(
item.images,
options.type,
ImageType.PRIMARY,
url,
item.remoteId
),
releaseDate: item.releaseDate,
releaseYear: item.releaseYear,
remoteCreatedAt: item.remoteCreatedAt,
remoteId: item.remoteId,
// serverFolderId: item.serverFolderId,
serverId: item.serverId,
streamUrl: stream,
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: User;
}) => {
const { items, serverUrl, user } = options;
return (
items?.map((item) => {
const { type, token, remoteUserId } = item.server;
const url = serverUrl || item.server.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: image(
item.images,
type,
ImageType.PRIMARY,
url,
item.remoteId
),
backdropImageUrl: image(
item.images,
type,
ImageType.BACKDROP,
url,
item.remoteId
),
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, {
deviceId: user.deviceId,
token,
type,
url,
userId: remoteUserId,
}),
/* eslint-enable sort-keys-fix/sort-keys-fix */
};
}) || []
);
};
// const relatedServerCredentials = (items: ServerCredential[]) => {
// return (
// items.map((item) => {
// return {
// /* eslint-disable sort-keys-fix/sort-keys-fix */
// id: item.id,
// enabled: item.enabled,
// username: item.username,
// credential: item.credential,
// /* eslint-enable sort-keys-fix/sort-keys-fix */
// };
// }) || []
// );
// };
// const serverCredentials = (items: ServerCredential[]) => {
// return (
// items.map((item) => {
// return {
// /* eslint-disable sort-keys-fix/sort-keys-fix */
// id: item.id,
// username: item.username,
// enabled: item.enabled,
// credential: item.credential,
// createdAt: item.createdAt,
// updatedAt: item.updatedAt,
// /* 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,
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 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 users = (
items: (User & {
accessToken?: string;
refreshToken?: string;
serverFolderPermissions?: ServerFolderPermission[];
serverPermissions?: ServerPermission[];
})[]
) => {
return (
items.map((item) => {
return {
/* eslint-disable sort-keys-fix/sort-keys-fix */
id: item.id,
username: item.username,
accessToken: item.accessToken,
refreshToken: item.refreshToken,
enabled: item.enabled,
isAdmin: item.isAdmin,
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 */
};
}) || []
);
};
export const toApiModel = {
albums,
servers,
songs,
users,
};
-9
View File
@@ -1,9 +0,0 @@
import { albumHelpers } from './albums.helpers';
import { sharedHelpers } from './shared.helpers';
import { songHelpers } from './songs.helpers';
export const helpers = {
albums: albumHelpers,
shared: sharedHelpers,
songs: songHelpers,
};
-108
View File
@@ -1,108 +0,0 @@
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 }
) => {
const { serverFolderId } = options;
if (user.isAdmin || !serverFolderId) {
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: { serverId },
});
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
(serverFolder) => serverFolder.id
);
return serverFoldersWithAccessIds;
}
const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: {
OR: [
{
AND: [
{
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,
};
-45
View File
@@ -1,45 +0,0 @@
import { Prisma } from '@prisma/client';
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 = () => {
const props: Prisma.SongFindManyArgs = {
include: {
album: true,
artists: true,
externals: true,
genres: true,
images: true,
ratings: true,
server: {
include: { serverUrls: true },
},
},
orderBy: [
// { albumId: Prisma.SortOrder.asc },
{ discNumber: Prisma.SortOrder.asc },
{ trackNumber: Prisma.SortOrder.asc },
],
};
return props;
};
export const songHelpers = {
findMany,
include,
};
-2
View File
@@ -1,2 +0,0 @@
export * from './prisma';
export { default as throttle } from './throttle';
-100
View File
@@ -1,100 +0,0 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import passport from 'passport';
import {
Strategy as JwtStrategy,
ExtractJwt,
StrategyOptions,
} from 'passport-jwt';
import { Strategy as LocalStrategy } from 'passport-local';
import { prisma } from './prisma';
export const generateToken = (
id: string,
otherProperties?: { [key: string]: any }
) => {
return jwt.sign(
{ id, ...otherProperties },
String(process.env.TOKEN_SECRET),
{
expiresIn: String(process.env.TOKEN_EXPIRATION || '15m'),
}
);
};
export const generateRefreshToken = (
id: string,
otherProperties?: { [key: string]: any }
) => {
return jwt.sign(
{ id, ...otherProperties },
String(process.env.TOKEN_SECRET),
{
expiresIn: String(process.env.TOKEN_REFRESH_EXPIRATION || '90d'),
}
);
};
const authenticateUser = async (
username: string,
password: string,
done: any
) => {
const user = await prisma.user.findUnique({ where: { username } });
if (user === null || user === undefined) {
return done(null, false);
}
if (!user.enabled) {
return done(null, false, { message: 'The user is not enabled.' });
}
if (await bcrypt.compare(password, user.password)) {
return done(null, user);
}
return done(null, false, { message: 'Invalid credentials.' });
};
passport.use(new LocalStrategy(authenticateUser));
const jwtOptions: StrategyOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: String(process.env.TOKEN_SECRET),
};
passport.use(
new JwtStrategy(jwtOptions, async (jwt_payload: any, done: any) => {
await prisma.user
.findUnique({
include: {
serverFolderPermissions: true,
serverPermissions: true,
},
where: { id: jwt_payload.id },
})
.then((user) => {
// eslint-disable-next-line promise/no-callback-in-promise
return done(null, user);
})
.catch((err) => {
console.log(err.message);
});
})
);
passport.serializeUser((user: any, done) => {
return done(null, user.id);
});
passport.deserializeUser(async (id: string, done) => {
return done(
null,
await prisma.user.findUnique({
where: {
id,
},
})
);
});
-53
View File
@@ -1,53 +0,0 @@
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;
// });
-8
View File
@@ -1,8 +0,0 @@
import pThrottle from 'p-throttle';
const throttle = pThrottle({
interval: 1000,
limit: 10,
});
export default throttle;
@@ -1,20 +0,0 @@
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();
};
-71
View File
@@ -1,71 +0,0 @@
import { ServerFolderPermission, ServerPermission, User } from '@prisma/client';
import { NextFunction, Request, Response } from 'express';
import passport from 'passport';
export type AuthUser = User & {
flatServerFolderPermissions: string[];
flatServerPermissions: string[];
serverFolderPermissions: ServerFolderPermission[];
serverId?: string;
serverPermissions: ServerPermission[];
};
export const authenticate = (
req: Request,
res: Response,
next: NextFunction
) => {
passport.authenticate('jwt', { session: false }, (err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(401).json({
error: {
message: info?.message || 'Invalid authorization.',
path: req.path,
},
response: 'Error',
statusCode: 401,
});
}
if (!user.enabled) {
return res.status(401).json({
error: {
message: 'Your account is not enabled.',
path: req.path,
},
response: 'Error',
statusCode: 401,
});
}
const flatServerFolderPermissions = user.serverFolderPermissions.map(
(permission: ServerFolderPermission) => permission.serverFolderId
);
const flatServerPermissions = user.serverPermissions.map(
(permission: ServerPermission) => permission.serverId
);
const props = {
createdAt: user?.createdAt,
enabled: user?.enabled,
flatServerFolderPermissions,
flatServerPermissions,
id: user?.id,
isAdmin: user?.isAdmin,
server: req.params.serverId,
serverFolderPermissions: user?.serverFolderPermissions,
serverPermissions: user?.serverPermissions,
updatedAt: user?.updatedAt,
username: user?.username,
};
req.authUser = props;
return next();
})(req, res, next);
};
-35
View File
@@ -1,35 +0,0 @@
import { NextFunction, Request, Response } from 'express';
import { isJsonString } from '@utils/is-json-string';
export const errorHandler = (
err: any,
req: Request,
res: Response,
next: NextFunction
) => {
let message = '';
const trace = err.stack?.match(/at .* \(.*\)/g).map((e: string) => {
return e.replace(/\(|\)/g, '');
});
if (err.message) {
message = isJsonString(err.message)
? Array.isArray(JSON.parse(err.message))
? JSON.parse(err.message)[0].message // Handles errors sent from zod preprocess
: JSON.parse(err.message)
: err.message;
}
res.status(err.statusCode || 500).json({
error: {
message,
path: req.path,
trace,
},
response: 'Error',
statusCode: err.statusCode || 500,
});
next();
};
-3
View File
@@ -1,3 +0,0 @@
export * from './error-handler';
export * from './authenticate';
export * from './authenticate-admin';
-6874
View File
File diff suppressed because it is too large Load Diff
-68
View File
@@ -1,68 +0,0 @@
{
"name": "sonixd-server",
"version": "1.0.0-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 -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": "ISC",
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/bcryptjs": "^2.4.2",
"@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/node": "^18.8.4",
"@types/passport-jwt": "^3.0.7",
"@types/passport-local": "^1.0.34",
"@typescript-eslint/parser": "^5.40.0",
"eslint-plugin-import": "^2.26.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",
"@types/better-queue": "^3.8.3",
"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",
"p-throttle": "^4.1.1",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"zod": "^3.19.1"
}
}
-7
View File
@@ -1,7 +0,0 @@
FROM node:16.5-alpine
WORKDIR /app
COPY ./ ./prisma/
CMD ["npx", "prisma", "studio"]
@@ -1,938 +0,0 @@
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;
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
-531
View File
@@ -1,531 +0,0 @@
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
}
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
username String @unique
password String
enabled Boolean @default(false)
isAdmin 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[]
serverFolderPermissions ServerFolderPermission[]
serverPermissions ServerPermission[]
// serverCredentials ServerCredential[]
albumArtistFavorites AlbumArtistFavorite[]
artistFavorites ArtistFavorite[]
albumFavorites AlbumFavorite[]
songFavorites SongFavorite[]
userServerUrls UserServerUrl[]
}
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
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[]
// serverCredentials ServerCredential[]
tasks Task[]
userServerUrls UserServerUrl[]
}
// model ServerCredential {
// id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
// username String
// enabled Boolean @default(false)
// credential String
// 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], onDelete: Cascade)
// userId String @db.Uuid
// }
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 @unique
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
name String
type TaskType
message String?
progress 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
}
-31
View File
@@ -1,31 +0,0 @@
/* eslint-disable promise/catch-or-return */
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { randomString } from '../utils';
const prisma = new PrismaClient();
async function main() {
const hashedPassword = await bcrypt.hash('admin', 12);
await prisma.user.upsert({
create: {
deviceId: `admin_${randomString(10)}`,
enabled: true,
isAdmin: true,
password: hashedPassword,
username: 'admin',
},
update: {},
where: { username: 'admin' },
});
}
main()
.catch((e) => {
console.error(e);
// process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
-2
View File
@@ -1,2 +0,0 @@
export * from './subsonic';
export * from './jellyfin';
-7
View File
@@ -1,7 +0,0 @@
import { jellyfinApi } from './jellyfin.api';
import { jellyfinScanner } from './jellyfin.scanner';
export const jellyfin = {
api: jellyfinApi,
scanner: jellyfinScanner,
};
-117
View File
@@ -1,117 +0,0 @@
import { Server } from '@prisma/client';
import axios from 'axios';
import {
JFAlbumArtistsResponse,
JFAlbumsResponse,
JFArtistsResponse,
JFAuthenticate,
JFCollectionType,
JFGenreResponse,
JFItemType,
JFMusicFoldersResponse,
JFRequestParams,
JFSongsResponse,
} from './jellyfin.types';
export const api = axios.create({});
export const authenticate = async (options: {
password: string;
url: string;
username: string;
}) => {
const { password, url, username } = options;
const cleanServerUrl = url.replace(/\/$/, '');
const { data } = await api.post<JFAuthenticate>(
`${cleanServerUrl}/users/authenticatebyname`,
{ pw: password, username },
{
headers: {
'X-Emby-Authorization': `MediaBrowser Client="Sonixd", Device="PC", DeviceId="Sonixd", Version="1.0.0-alpha1"`,
},
}
);
return data;
};
export const getMusicFolders = async (server: Partial<Server>) => {
const { data } = await api.get<JFMusicFoldersResponse>(
`${server.url}/users/${server.remoteUserId}/items`,
{ headers: { 'X-MediaBrowser-Token': server.token! } }
);
const musicFolders = data.Items.filter(
(folder) => folder.CollectionType === JFCollectionType.MUSIC
);
return musicFolders;
};
export const getGenres = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFGenreResponse>(`${server.url}/genres`, {
headers: { 'X-MediaBrowser-Token': server.token },
params,
});
return data;
};
export const getAlbumArtists = async (
server: Server,
params: JFRequestParams
) => {
const { data } = await api.get<JFAlbumArtistsResponse>(
`${server.url}/artists/albumArtists`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params,
}
);
return data;
};
export const getArtists = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFArtistsResponse>(`${server.url}/artists`, {
headers: { 'X-MediaBrowser-Token': server.token },
params,
});
return data;
};
export const getAlbums = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFAlbumsResponse>(
`${server.url}/users/${server.remoteUserId}/items`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params: { includeItemTypes: JFItemType.MUSICALBUM, ...params },
}
);
return data;
};
export const getSongs = async (server: Server, params: JFRequestParams) => {
const { data } = await api.get<JFSongsResponse>(
`${server.url}/users/${server.remoteUserId}/items`,
{
headers: { 'X-MediaBrowser-Token': server.token },
params: { includeItemTypes: JFItemType.AUDIO, ...params },
}
);
return data;
};
export const jellyfinApi = {
authenticate,
getAlbumArtists,
getAlbums,
getArtists,
getGenres,
getMusicFolders,
getSongs,
};
@@ -1,495 +0,0 @@
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);
}
return { task };
},
id: task.id,
});
};
export const jellyfinScanner = {
scanAlbumArtists,
scanAlbums,
scanAll,
scanGenres,
scanSongs,
};
-404
View File
@@ -1,404 +0,0 @@
export interface JFBaseResponse {
StartIndex: number;
TotalRecordCount: number;
}
export interface JFMusicFoldersResponse extends JFBaseResponse {
Items: JFMusicFolder[];
}
export interface JFGenreResponse extends JFBaseResponse {
Items: JFGenre[];
}
export interface JFAlbumArtistsResponse extends JFBaseResponse {
Items: JFAlbumArtist[];
}
export interface JFArtistsResponse extends JFBaseResponse {
Items: JFAlbumArtist[];
}
export interface JFAlbumsResponse extends JFBaseResponse {
Items: JFAlbum[];
}
export interface JFSongsResponse extends JFBaseResponse {
Items: JFSong[];
}
export interface JFRequestParams {
albumArtistIds?: string;
artistIds?: string;
enableImageTypes?: string;
enableTotalRecordCount?: boolean;
enableUserData?: boolean;
excludeItemTypes?: string;
fields?: string;
imageTypeLimit?: number;
includeItemTypes?: string;
isFavorite?: boolean;
limit?: number;
parentId?: string;
recursive?: boolean;
searchTerm?: string;
sortBy?: string;
sortOrder?: 'Ascending' | 'Descending';
startIndex?: number;
userId?: string;
}
export interface JFMusicFolder {
BackdropImageTags: string[];
ChannelId: null;
CollectionType: string;
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
UserData: UserData;
}
export interface JFGenre {
BackdropImageTags: any[];
ChannelId: null;
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
ServerId: string;
Type: string;
}
export interface JFAlbumArtist {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: ImageTags;
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
}
export interface JFArtist {
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: GenreItem[];
Genres: string[];
Id: string;
ImageBlurHashes: any;
ImageTags: string[];
LocationType: string;
Name: string;
Overview?: string;
RunTimeTicks: number;
ServerId: string;
Type: string;
}
export interface JFAlbum {
AlbumArtist: string;
AlbumArtists: JFGenericItem[];
ArtistItems: JFGenericItem[];
Artists: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IsFolder: boolean;
LocationType: string;
Name: string;
ParentLogoImageTag: string;
ParentLogoItemId: string;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
ServerId: string;
Type: string;
}
export interface JFSong {
Album: string;
AlbumArtist: string;
AlbumArtists: JFGenericItem[];
AlbumId: string;
AlbumPrimaryImageTag: string;
ArtistItems: JFGenericItem[];
Artists: string[];
BackdropImageTags: string[];
ChannelId: null;
DateCreated: string;
ExternalUrls: ExternalURL[];
GenreItems: JFGenericItem[];
Genres: string[];
Id: string;
ImageBlurHashes: ImageBlurHashes;
ImageTags: ImageTags;
IndexNumber: number;
IsFolder: boolean;
LocationType: string;
MediaSources: MediaSources[];
MediaType: string;
Name: string;
ParentIndexNumber: number;
PremiereDate?: string;
ProductionYear: number;
RunTimeTicks: number;
ServerId: string;
SortName: string;
Type: string;
}
interface ImageBlurHashes {
Backdrop?: any;
Logo?: any;
Primary?: any;
}
interface ImageTags {
Logo?: string;
Primary?: string;
}
interface UserData {
IsFavorite: boolean;
Key: string;
PlayCount: number;
PlaybackPositionTicks: number;
Played: boolean;
}
interface ExternalURL {
Name: string;
Url: string;
}
interface GenreItem {
Id: string;
Name: string;
}
export interface JFGenericItem {
Id: string;
Name: string;
}
interface MediaSources {
Bitrate: number;
Container: string;
DefaultAudioStreamIndex: number;
ETag: string;
Formats: any[];
GenPtsInput: boolean;
Id: string;
IgnoreDts: boolean;
IgnoreIndex: boolean;
IsInfiniteStream: boolean;
IsRemote: boolean;
MediaAttachments: any[];
MediaStreams: MediaStream[];
Name: string;
Path: string;
Protocol: string;
ReadAtNativeFramerate: boolean;
RequiredHttpHeaders: any;
RequiresClosing: boolean;
RequiresLooping: boolean;
RequiresOpening: boolean;
RunTimeTicks: number;
Size: number;
SupportsDirectPlay: boolean;
SupportsDirectStream: boolean;
SupportsProbing: boolean;
SupportsTranscoding: boolean;
Type: string;
}
interface MediaStream {
AspectRatio?: string;
BitDepth?: number;
BitRate?: number;
ChannelLayout?: string;
Channels?: number;
Codec: string;
CodecTimeBase: string;
ColorSpace?: string;
Comment?: string;
DisplayTitle?: string;
Height?: number;
Index: number;
IsDefault: boolean;
IsExternal: boolean;
IsForced: boolean;
IsInterlaced: boolean;
IsTextSubtitleStream: boolean;
Level: number;
PixelFormat?: string;
Profile?: string;
RealFrameRate?: number;
RefFrames?: number;
SampleRate?: number;
SupportsExternalStream: boolean;
TimeBase: string;
Type: string;
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;
}
-304
View File
@@ -1,304 +0,0 @@
import {
ExternalSource,
ExternalType,
ImageType,
Prisma,
Server,
ServerFolder,
} from '@prisma/client';
import uniqBy from 'lodash/uniqBy';
import { prisma } from '@lib/prisma';
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,
};
-7
View File
@@ -1,7 +0,0 @@
import { navidromeApi } from './navidrome.api';
import { navidromeScanner } from './navidrome.scanner';
export const navidrome = {
api: navidromeApi,
scanner: navidromeScanner,
};
@@ -1,83 +0,0 @@
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 { data } = await api.get<NDGenreListResponse>(
`${server.url}/api/genre`,
{
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
params,
}
);
return data;
};
const getArtists = async (server: Server, params?: NDGenreListParams) => {
const { data } = await api.get<NDArtistListResponse>(
`${server.url}/api/artist`,
{
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
params,
}
);
return data;
};
const getAlbums = async (server: Server, params?: NDAlbumListParams) => {
const { data } = await api.get<NDAlbumListResponse>(
`${server.url}/api/album`,
{
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
params,
}
);
return data;
};
const getSongs = async (server: Server, params?: NDSongListParams) => {
const { data } = await api.get<NDSongListResponse>(`${server.url}/api/song`, {
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
params,
});
return data;
};
export const navidromeApi = {
authenticate,
getAlbums,
getArtists,
getGenres,
getSongs,
};
@@ -1,376 +0,0 @@
/* 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
) => {
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
) => {
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 albumArtistConnect = album.artistId
? {
uniqueAlbumArtistId: {
remoteId: album.artistId,
serverId: server.id,
},
}
: undefined;
await prisma.album.upsert({
create: {
albumArtists: { connect: albumArtistConnect },
artists: { connect: artistsConnect },
deleted: false,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: album.name,
releaseDate: album?.minYear
? new Date(album.minYear, 0).toISOString()
: undefined,
releaseYear: album.minYear,
remoteCreatedAt: album.createdAt,
remoteId: album.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.name,
},
update: {
albumArtists: { connect: albumArtistConnect },
artists: { connect: artistsConnect },
deleted: false,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: album.name,
releaseDate: album?.minYear
? new Date(album.minYear, 0).toISOString()
: undefined,
releaseYear: album.minYear,
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) => {
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) 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);
await scanAlbums(server, serverFolder);
await scanSongs(server, serverFolder);
}
return { task };
},
id: task.id,
});
};
export const navidromeScanner = {
scanAll,
scanGenres,
};
@@ -1,169 +0,0 @@
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;
@@ -1,125 +0,0 @@
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('/');
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: song?.year
? new Date(song.year, 0).toISOString()
: undefined,
releaseYear: song?.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: song?.year
? new Date(song.year, 0).toISOString()
: undefined,
releaseYear: song?.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,
};
-5
View File
@@ -1,5 +0,0 @@
import { scannerQueue } from './scanner.queue';
export const queue = {
scanner: scannerQueue,
};
-57
View File
@@ -1,57 +0,0 @@
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 = new Queue(
async (task: QueueTask, cb: any) => {
const result = await task.fn();
return cb(null, result);
},
{
afterProcessDelay: 1000,
cancelIfRunning: true,
concurrent: 1,
filo: false,
maxRetries: 5,
maxTimeout: 600000,
retryDelay: 2000,
}
);
scannerQueue.on('task_finish', async (taskId) => {
await prisma.task.update({
data: {
completed: true,
isError: false,
progress: null,
},
where: { id: taskId },
});
});
scannerQueue.on('task_failed', async (taskId, errorMessage) => {
const dbTaskId = taskId.split('(')[1].split(')')[0];
console.log('errorMessage', errorMessage);
await prisma.task.update({
data: {
completed: true,
isError: true,
message: errorMessage,
},
where: { id: dbTaskId },
});
});
scannerQueue.on('drain', async () => {
await prisma.task.updateMany({
data: { completed: true, progress: null },
where: { completed: false },
});
});
-7
View File
@@ -1,7 +0,0 @@
import { subsonicApi } from './subsonic.api';
import { subsonicScanner } from './subsonic.scanner';
export const subsonic = {
api: subsonicApi,
scanner: subsonicScanner,
};
-157
View File
@@ -1,157 +0,0 @@
import { Server } from '@prisma/client';
import axios from 'axios';
import md5 from 'md5';
import { randomString } from '@utils/index';
import {
SSAlbumListEntry,
SSAlbumListResponse,
SSAlbumResponse,
SSAlbumsParams,
SSArtistIndex,
SSArtistInfoResponse,
SSArtistsResponse,
SSGenresResponse,
SSMusicFoldersResponse,
} from './subsonic.types';
const api = axios.create({
validateStatus: (status) => status >= 200,
});
api.interceptors.response.use(
(res: any) => {
res.data = res.data['subsonic-response'];
return res;
},
(err: any) => {
return Promise.reject(err);
}
);
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=sonixd&f=json&${token}`
);
return { token, ...data };
};
const getMusicFolders = async (server: Partial<Server>) => {
const { data } = await api.get<SSMusicFoldersResponse>(
`${server.url}/rest/getMusicFolders.view?v=1.13.0&c=sonixd&f=json&${server.token}`
);
return data.musicFolders.musicFolder;
};
const getArtists = async (server: Server, musicFolderId: string) => {
const { data } = await api.get<SSArtistsResponse>(
`${server.url}/rest/getArtists.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
{ params: { musicFolderId } }
);
const artists = (data.artists?.index || []).flatMap(
(index: SSArtistIndex) => index.artist
);
return artists;
};
const getGenres = async (server: Server) => {
const { data: genres } = await api.get<SSGenresResponse>(
`${server.url}/rest/getGenres.view?v=1.13.0&c=sonixd&f=json&${server.token}`
);
return genres;
};
const getAlbum = async (server: Server, id: string) => {
const { data: album } = await api.get<SSAlbumResponse>(
`${server.url}/rest/getAlbum.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
{ params: { id } }
);
return album;
};
const getAlbums = async (
server: Server,
params: SSAlbumsParams,
recursiveData: any[] = []
) => {
const albums: any = api
.get<SSAlbumListResponse>(
`${server.url}/rest/getAlbumList2.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
{ params }
)
.then((res) => {
if (
!res.data.albumList2.album ||
res.data.albumList2.album.length === 0
) {
// Flatten and return once there are no more albums left
return recursiveData.flatMap((album) => album);
}
// On every iteration, push the existing combined album array and increase the offset
recursiveData.push(res.data.albumList2.album);
return getAlbums(
server,
{
musicFolderId: params.musicFolderId,
offset: (params.offset || 0) + (params.size || 0),
size: params.size,
type: 'newest',
},
recursiveData
);
})
.catch((err) => console.log(err));
return albums as SSAlbumListEntry[];
};
const getArtistInfo = async (server: Server, id: string) => {
const { data: artistInfo } = await api.get<SSArtistInfoResponse>(
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=sonixd&f=json&${server.token}`,
{ params: { id } }
);
return {
...artistInfo,
artistInfo2: {
...artistInfo.artistInfo2,
biography: artistInfo.artistInfo2.biography
.replaceAll(/<a target.*<\/a>/gm, '')
.replace('Biography not available', ''),
},
};
};
export const subsonicApi = {
authenticate,
getAlbum,
getAlbums,
getArtistInfo,
getArtists,
getGenres,
getMusicFolders,
};
@@ -1,288 +0,0 @@
/* 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
) => {
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
) => {
const albums = await subsonicApi.getAlbums(server, {
musicFolderId: serverFolder.id,
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 },
genres: { connect: album.genre ? { name: album.genre } : undefined },
images: { connect: imagesConnect },
name: album.title,
releaseDate: album?.year
? new Date(album.year, 0).toISOString()
: undefined,
releaseYear: album.year,
remoteCreatedAt: album.created,
remoteId: album.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.title,
},
update: {
albumArtists: { connect: albumArtistConnect },
genres: { connect: album.genre ? { name: album.genre } : undefined },
images: { connect: imagesConnect },
name: album.title,
releaseDate: album?.year
? new Date(album.year, 0).toISOString()
: undefined,
releaseYear: album.year,
remoteCreatedAt: album.created,
remoteId: album.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
sortName: album.title,
},
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: {
albumArtists: { connect: albumArtistsConnect },
artistName: !song.artistId ? song.artist : undefined,
bitRate: song.bitRate,
container: song.suffix,
discNumber: song.discNumber,
duration: song.duration,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: song.title,
releaseDate: song?.year
? new Date(song.year, 0).toISOString()
: undefined,
releaseYear: song.year,
remoteCreatedAt: song.created,
remoteId: song.id,
serverFolders: { connect: { id: serverFolder.id } },
serverId: server.id,
size: song.size,
sortName: song.title,
trackNumber: song.track,
},
update: {
albumArtists: { connect: albumArtistsConnect },
artistName: !song.artistId ? song.artist : undefined,
bitRate: song.bitRate,
container: song.suffix,
discNumber: song.discNumber,
duration: song.duration,
genres: { connect: genresConnect },
images: { connect: imagesConnect },
name: song.title,
releaseDate: song?.year
? new Date(song.year, 0).toISOString()
: undefined,
releaseYear: song.year,
remoteCreatedAt: song.created,
remoteId: song.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 {
uniqueArtistId: {
remoteId: artistId!,
serverId: server.id,
},
};
});
await prisma.album.update({
data: {
artists: { connect: artistsConnect },
songs: { upsert: songsUpsert },
},
where: {
uniqueAlbumId: {
remoteId: albumRes.album.id,
serverId: server.id,
},
},
});
}
}
);
export const scanAlbumDetail = async (
server: Server,
serverFolder: ServerFolder
) => {
const promises = [];
const dbAlbums = await prisma.album.findMany({
where: {
serverId: server.id,
},
});
for (let i = 0; i < dbAlbums.length; i += 1) {
promises.push(throttledAlbumFetch(server, serverFolder, dbAlbums[i]));
}
await Promise.all(promises);
};
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);
await scanAlbums(server, serverFolder);
await scanAlbumDetail(server, serverFolder);
}
return { task };
},
id: task.id,
});
};
export const subsonicScanner = {
scanAll,
scanGenres,
};
-139
View File
@@ -1,139 +0,0 @@
export interface SSBaseResponse {
serverVersion?: 'string';
status: 'string';
type?: 'string';
version: 'string';
}
export interface SSMusicFoldersResponse extends SSBaseResponse {
musicFolders: {
musicFolder: SSMusicFolder[];
};
}
export interface SSGenresResponse extends SSBaseResponse {
genres: {
genre: SSGenre[];
};
}
export interface SSArtistsResponse extends SSBaseResponse {
artists: {
ignoredArticles: string;
index: SSArtistIndex[];
lastModified: number;
};
}
export interface SSAlbumListResponse extends SSBaseResponse {
albumList2: {
album: SSAlbumListEntry[];
};
}
export interface SSAlbumResponse extends SSBaseResponse {
album: SSAlbum;
}
export interface SSArtistInfoResponse extends SSBaseResponse {
artistInfo2: SSArtistInfo;
}
export interface SSArtistInfo {
biography: string;
largeImageUrl?: string;
lastFmUrl?: string;
mediumImageUrl?: string;
musicBrainzId?: string;
smallImageUrl?: string;
}
export interface SSMusicFolder {
id: number;
name: string;
}
export interface SSGenre {
albumCount?: number;
songCount?: number;
value: string;
}
export interface SSArtistIndex {
artist: SSArtistListEntry[];
name: string;
}
export interface SSArtistListEntry {
albumCount: string;
artistImageUrl?: string;
coverArt?: string;
id: string;
name: string;
}
export interface SSAlbumListEntry {
album: string;
artist: string;
artistId: string;
coverArt: string;
created: string;
duration: number;
genre?: string;
id: string;
isDir: boolean;
isVideo: boolean;
name: string;
parent: string;
songCount: number;
starred?: boolean;
title: string;
userRating?: number;
year: number;
}
export interface SSAlbum extends SSAlbumListEntry {
song: SSSong[];
}
export interface SSSong {
album: string;
albumId: string;
artist: string;
artistId?: string;
bitRate: number;
contentType: string;
coverArt: string;
created: string;
discNumber?: number;
duration: number;
genre: string;
id: string;
isDir: boolean;
isVideo: boolean;
parent: string;
path: string;
playCount: number;
size: number;
starred?: boolean;
suffix: string;
title: string;
track: number;
type: string;
userRating?: number;
year: number;
}
export interface SSAlbumsParams {
fromYear?: number;
genre?: string;
musicFolderId?: string;
offset?: number;
size?: number;
toYear?: number;
type: string;
}
export interface SSArtistsParams {
musicFolderId?: number;
}
@@ -1,36 +0,0 @@
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,
};
-13
View File
@@ -1,13 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
import { validation, validateRequest } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.albumArtists.getList);
router.get(
':serverId',
validateRequest(validation.albumArtists.detail),
controller.albumArtists.getDetail
);
-23
View File
@@ -1,23 +0,0 @@
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
);
-8
View File
@@ -1,8 +0,0 @@
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);
-30
View File
@@ -1,30 +0,0 @@
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);
-47
View File
@@ -1,47 +0,0 @@
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 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,
});
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/songs', songsRouter);
-87
View File
@@ -1,87 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
import { authenticateAdmin } from '@middleware/authenticate-admin';
import { service } from '@services/index';
import { validateRequest, validation } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true });
router
.route('/')
.get(
validateRequest(validation.servers.list),
controller.servers.getServerList
)
.post(
authenticateAdmin,
validateRequest(validation.servers.create),
controller.servers.createServer
);
router
.route('/:serverId')
.get(
validateRequest(validation.servers.detail),
controller.servers.getServerDetail
)
.patch(
authenticateAdmin,
validateRequest(validation.servers.update),
controller.servers.updateServer
)
.delete(
authenticateAdmin,
validateRequest(validation.servers.deleteServer),
controller.servers.deleteServer
);
router
.route('/:serverId/refresh')
.get(
authenticateAdmin,
validateRequest(validation.servers.refresh),
controller.servers.refreshServer
);
router
.route('/:serverId/scan')
.post(
validateRequest(validation.servers.scan),
authenticateAdmin,
controller.servers.scanServer
);
router
.route('/:serverId/url')
.post(
authenticateAdmin,
validateRequest(validation.servers.createUrl),
controller.servers.createServerUrl
);
router.param('urlId', async (_req, _res, next, urlId) => {
await service.servers.findUrlById({ id: urlId });
next();
});
router
.route('/:serverId/url/:urlId')
.delete(
authenticateAdmin,
validateRequest(validation.servers.deleteUrl),
controller.servers.deleteServerUrl
);
router
.route('/:serverId/url/:urlId/enable')
.post(
validateRequest(validation.servers.enableUrl),
controller.servers.enableServerUrl
);
router
.route('/:serverId/url/:urlId/disable')
.post(
validateRequest(validation.servers.disableUrl),
controller.servers.disableServerUrl
);
-11
View File
@@ -1,11 +0,0 @@
import express, { Router } from 'express';
import { validation, validateRequest } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true });
router.get('/', validateRequest(validation.songs.list), async (req, res) => {
// const data = await controller.songs.getSongList(req.authUser, req.query);
return res.status(200).json({});
// return res.status(success.statusCode).json(getSuccessResponse(success));
});
-11
View File
@@ -1,11 +0,0 @@
import express, { Router } from 'express';
export const router: Router = express.Router({ mergeParams: true });
router.post('/scan', async (_req, res) => {
return res.status(200);
});
router.post('/', async (_req, res) => {
return res.status(200).json({});
});
-14
View File
@@ -1,14 +0,0 @@
import express, { Router } from 'express';
import { controller } from '@controllers/index';
import { validateRequest, validation } from '@validations/index';
import { authenticateAdmin } from '../middleware/authenticate-admin';
export const router: Router = express.Router({ mergeParams: true });
router.get('/', authenticateAdmin, controller.users.getUserList);
router.get(
':serverId',
validateRequest(validation.users.detail),
controller.users.getUserDetail
);
-40
View File
@@ -1,40 +0,0 @@
import path from 'path';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import passport from 'passport';
import 'express-async-errors';
import { errorHandler } from '@/middleware';
import { routes } from '@routes/index';
require('./lib/passport');
const PORT = 9321;
const app = express();
const staticPath = path.join(__dirname, '../sonixd-client/');
app.use(express.static(staticPath));
app.use(
cors({
credentials: false,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
origin: [`http://localhost:4343`, `${process.env.APP_BASE_URL}`],
})
);
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(passport.initialize());
app.use(passport.session());
app.get('/', (_req, res) => {
res.sendFile(path.join(staticPath, 'index.html'));
});
app.use(routes);
app.use(errorHandler);
app.listen(9321, () => console.log(`Listening on port ${PORT}`));
@@ -1,68 +0,0 @@
import { User } from '@prisma/client';
import { Request } from 'express';
import { OffsetPagination } from '@/types/types';
import { ApiError } from '@/utils';
import { prisma } from '@lib/prisma';
import { folderPermissions } from '@utils/folder-permissions';
const findById = async (options: { id: string; user: User }) => {
const { id, user } = options;
const albumArtist = await prisma.albumArtist.findUnique({
include: {
albums: { include: { songs: true } },
genres: true,
images: true,
serverFolders: true,
},
where: { id },
});
if (!albumArtist) {
throw ApiError.notFound('');
}
const serverFolderIds = albumArtist.serverFolders.map(
(serverFolder) => serverFolder.id
);
if (!(await folderPermissions(serverFolderIds, user))) {
throw ApiError.forbidden('');
}
return albumArtist;
};
const findMany = async (
req: Request,
options: { serverFolderIds: string; user: User } & OffsetPagination
) => {
const { user, take, serverFolderIds: rServerFolderIds, skip } = options;
const serverFolderIds = rServerFolderIds.split(',');
if (!(await folderPermissions(serverFolderIds!, user))) {
throw ApiError.forbidden('');
}
const serverFoldersFilter = serverFolderIds!.map((serverFolderId) => ({
serverFolders: { some: { id: { equals: serverFolderId } } },
}));
const [totalEntries, albumArtists] = await prisma.$transaction([
prisma.albumArtist.count({
where: { OR: serverFoldersFilter },
}),
prisma.albumArtist.findMany({
include: { genres: true },
skip,
take,
where: { OR: serverFoldersFilter },
}),
]);
return { data: albumArtists, totalEntries };
};
export const albumArtistsService = {
findById,
findMany,
};
-113
View File
@@ -1,113 +0,0 @@
import { AuthUser } from '@/middleware';
import { OffsetPagination, SortOrder } from '@/types/types';
import { ApiError } from '@/utils';
import { AlbumSort } from '@helpers/albums.helpers';
import { helpers } from '@helpers/index';
import { prisma } from '@lib/prisma';
const findById = async (user: AuthUser, options: { id: string }) => {
const { id } = options;
const album = await prisma.album.findUnique({
include: helpers.albums.include({ songs: true }),
where: { id },
});
if (!album) {
throw ApiError.notFound('');
}
const serverFolderId = album.serverFolders.map((s) => s.id);
helpers.shared.checkServerFolderPermissions(user, { serverFolderId });
return album;
};
export type AlbumFindManyOptions = {
orderBy: SortOrder;
serverFolderId?: string[];
serverId: string;
sortBy: AlbumSort;
user: AuthUser;
} & OffsetPagination;
const findMany = async (options: AlbumFindManyOptions) => {
const { take, serverFolderId, skip, sortBy, orderBy, user, serverId } =
options;
const serverFolderIds =
serverFolderId ||
(await helpers.shared.getAvailableServerFolderIds(user, { serverId }));
let totalEntries = 0;
let albums;
if (sortBy === AlbumSort.RATING) {
const [count, result] = await prisma.$transaction([
prisma.albumRating.count({
where: {
album: { OR: helpers.shared.serverFolderFilter(serverFolderIds) },
user: { id: user.id },
},
}),
prisma.albumRating.findMany({
include: {
album: {
include: helpers.albums.include({ songs: false, user }),
},
},
orderBy: { value: orderBy },
skip,
take,
where: {
album: { OR: helpers.shared.serverFolderFilter(serverFolderIds) },
user: { id: user.id },
},
}),
]);
albums = result.map((rating) => rating.album);
totalEntries = count;
} else if (sortBy === AlbumSort.FAVORITE) {
[totalEntries, albums] = await prisma.$transaction([
prisma.album.count({
where: {
AND: [
helpers.shared.serverFolderFilter(serverFolderIds),
{ favorites: { some: { userId: user.id } } },
],
},
}),
prisma.album.findMany({
include: helpers.albums.include({ songs: false, user }),
skip,
take,
where: {
AND: [
helpers.shared.serverFolderFilter(serverFolderIds),
{ favorites: { some: { userId: user.id } } },
],
},
}),
]);
} else {
[totalEntries, albums] = await prisma.$transaction([
prisma.album.count({
where: { OR: helpers.shared.serverFolderFilter(serverFolderIds) },
}),
prisma.album.findMany({
include: helpers.albums.include({ songs: false, user }),
orderBy: [helpers.albums.sort(sortBy, orderBy)],
skip,
take,
where: { OR: helpers.shared.serverFolderFilter(serverFolderIds) },
}),
]);
}
return { data: albums, totalEntries };
};
export const albumsService = {
findById,
findMany,
};
-59
View File
@@ -1,59 +0,0 @@
import { User } from '@prisma/client';
import { Request } from 'express';
import { prisma } from '../lib';
import { OffsetPagination } from '../types/types';
import { ApiError, folderPermissions } from '../utils';
const findById = async (options: { id: string; user: User }) => {
const { id, user } = options;
const artist = await prisma.artist.findUnique({
include: { genres: true, serverFolders: true },
where: { id },
});
if (!artist) throw ApiError.notFound('');
const serverFolderIds = artist.serverFolders.map(
(serverFolder) => serverFolder.id
);
if (!(await folderPermissions(serverFolderIds, user)))
throw ApiError.forbidden('');
return artist;
};
const findMany = async (
req: Request,
options: { serverFolderIds: string; user: User } & OffsetPagination
) => {
const { user, skip, take, serverFolderIds: rServerFolderIds } = options;
const serverFolderIds = rServerFolderIds.split(',');
if (!(await folderPermissions(serverFolderIds!, user)))
throw ApiError.forbidden('');
const serverFoldersFilter = serverFolderIds!.map((serverFolderId) => ({
serverFolders: { some: { id: { equals: serverFolderId } } },
}));
const [totalEntries, artists] = await prisma.$transaction([
prisma.artist.count({
where: { OR: serverFoldersFilter },
}),
prisma.artist.findMany({
include: { genres: true },
skip,
take,
where: { OR: serverFoldersFilter },
}),
]);
return { data: artists, totalEntries };
};
export const artistsService = {
findById,
findMany,
};
-89
View File
@@ -1,89 +0,0 @@
import { User } from '@prisma/client';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../lib';
import { generateRefreshToken, generateToken } from '../lib/passport';
import { ApiSuccess, randomString } from '../utils';
import { ApiError } from '../utils/api-error';
const login = async (options: { username: string }) => {
const { username } = options;
const user = await prisma.user.findUnique({
include: { serverFolderPermissions: true, serverPermissions: true },
where: { username },
});
if (!user) {
throw ApiError.notFound('The user does not exist.');
}
const serverPermissions = user.serverPermissions.map((p) => p.id);
const otherProperties = {
isAdmin: user.isAdmin,
serverFolderPermissions: user.serverFolderPermissions.map((p) => p.id),
serverPermissions,
username: user.username,
};
const accessToken = generateToken(user.id, otherProperties);
const refreshToken = generateRefreshToken(user.id, otherProperties);
await prisma.refreshToken.create({
data: { token: refreshToken, userId: user.id },
});
return { ...user, accessToken, refreshToken };
};
const register = async (options: { password: string; username: string }) => {
const { username, password } = options;
const userExists = await prisma.user.findUnique({ where: { username } });
if (userExists) {
throw ApiError.conflict('The user already exists.');
}
const hashedPassword = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
deviceId: `${username}_${randomString(10)}`,
enabled: false,
password: hashedPassword,
username,
},
});
return user;
};
const logout = async (options: { user: User }) => {
const { user } = options;
await prisma.refreshToken.deleteMany({
where: { userId: user.id },
});
return ApiSuccess.noContent({ data: {} });
};
const refresh = async (options: { refreshToken: string }) => {
const { refreshToken } = options;
const user = jwt.verify(refreshToken, String(process.env.TOKEN_SECRET));
const { id } = user as { exp: number; iat: number; id: string };
const token = await prisma.refreshToken.findUnique({
where: { token: refreshToken },
});
if (!token) throw ApiError.unauthorized('Invalid refresh token.');
const newToken = generateToken(id);
return { accessToken: newToken };
};
export const authService = {
login,
logout,
refresh,
register,
};
-15
View File
@@ -1,15 +0,0 @@
import { albumArtistsService } from './album-artists.service';
import { albumsService } from './albums.service';
import { artistsService } from './artists.service';
import { authService } from './auth.service';
import { serversService } from './servers.service';
import { usersService } from './users.service';
export const service = {
albumArtists: albumArtistsService,
albums: albumsService,
artists: artistsService,
auth: authService,
servers: serversService,
users: usersService,
};
-562
View File
@@ -1,562 +0,0 @@
import { ServerType, TaskType } from '@prisma/client';
import { SortOrder } from '@/types/types';
import { helpers } from '../helpers';
import { prisma } from '../lib';
import { AuthUser } from '../middleware';
import { subsonic } from '../queue';
import { jellyfin } from '../queue/jellyfin';
import { navidrome } from '../queue/navidrome';
import { ApiError } from '../utils';
const remoteServerLogin = async (options: {
legacy?: boolean;
password: string;
type: ServerType;
url: string;
username: string;
}) => {
if (options.type === ServerType.JELLYFIN) {
const res = await jellyfin.api.authenticate({
password: options.password,
url: options.url,
username: options.username,
});
if (!res) {
throw ApiError.badRequest('Invalid credentials.');
}
return {
remoteUserId: res.User.Id,
token: res.AccessToken,
type: ServerType.JELLYFIN,
url: options.url,
username: options.username,
};
}
if (options.type === ServerType.SUBSONIC) {
const res = await subsonic.api.authenticate({
legacy: options.legacy,
password: options.password,
url: options.url,
username: options.username,
});
if (res.status === 'failed') {
throw ApiError.badRequest('Invalid credentials.');
}
return {
remoteUserId: '',
token: res.token,
type: ServerType.SUBSONIC,
url: options.url,
username: options.username,
};
}
if (options.type === ServerType.NAVIDROME) {
const res = await navidrome.api.authenticate({
password: options.password,
url: options.url,
username: options.username,
});
return {
altToken: `u=${res.name}&s=${res.subsonicSalt}&t=${res.subsonicToken}`,
remoteUserId: res.id,
token: res.token,
type: ServerType.NAVIDROME,
url: options.url,
username: options.username,
};
}
throw ApiError.badRequest('Server type invalid.');
};
const findById = async (user: AuthUser, options: { id: string }) => {
const { id } = options;
helpers.shared.checkServerPermissions(user, { serverId: id });
const server = await prisma.server.findUnique({
include: {
serverFolders: user.isAdmin
? true
: {
where: {
OR: [{ serverFolderPermissions: { some: { userId: user.id } } }],
},
},
serverPermissions: {
where: { userId: user.id },
},
},
where: { id },
});
if (!server) {
throw ApiError.notFound('');
}
return server;
};
const findMany = async (user: AuthUser) => {
if (user.isAdmin) {
return prisma.server.findMany({
include: {
serverFolders: {
orderBy: { name: SortOrder.ASC },
},
serverPermissions: {
orderBy: { createdAt: SortOrder.ASC },
where: { userId: user.id },
},
serverUrls: {
include: {
userServerUrls: {
where: { userId: user.id },
},
},
},
},
orderBy: { createdAt: SortOrder.ASC },
});
}
const servers = await prisma.server.findMany({
include: {
serverFolders: {
orderBy: { name: SortOrder.ASC },
where: { id: { in: user.flatServerFolderPermissions } },
},
serverPermissions: {
orderBy: { createdAt: SortOrder.ASC },
where: { userId: user.id },
},
serverUrls: true,
},
orderBy: { createdAt: SortOrder.ASC },
where: { id: { in: user.flatServerPermissions } },
});
return servers;
};
const create = async (options: {
altToken?: string; // Used for Navidrome only
name: string;
remoteUserId: string;
token: string;
type: ServerType;
url: string;
username: string;
}) => {
const isDuplicate = await prisma.server.findUnique({
where: { url: options.url },
});
if (isDuplicate) {
throw ApiError.conflict('Server already exists.');
}
const serverFolders: {
name: string;
remoteId: string;
serverId: string;
}[] = [];
if (options.type === ServerType.SUBSONIC) {
const serverFoldersRes = await subsonic.api.getMusicFolders({
token: options.token,
url: options.url,
});
if (!serverFoldersRes) {
throw ApiError.badRequest('Server is inaccessible.');
}
const serverFoldersCreate = serverFoldersRes.map((folder) => {
return {
name: folder.name,
remoteId: String(folder.id),
};
});
const server = await prisma.server.create({
data: {
...options,
serverFolders: { create: serverFoldersCreate },
serverUrls: { create: { url: options.url } },
},
});
// for (const serverFolder of serverFolders) {
// await prisma.serverFolder.upsert({
// create: serverFolder,
// update: { name: serverFolder.name },
// where: {
// uniqueServerFolderId: {
// remoteId: serverFolder.remoteId,
// serverId: serverFolder.serverId,
// },
// },
// });
// }
return server;
}
if (options.type === ServerType.NAVIDROME) {
const serverFoldersRes = await subsonic.api.getMusicFolders({
token: options.altToken,
url: options.url,
});
if (!serverFoldersRes) {
throw ApiError.badRequest('Server is inaccessible.');
}
const serverFoldersCreate = serverFoldersRes.map((folder) => {
return {
name: folder.name,
remoteId: String(folder.id),
};
});
const server = await prisma.server.create({
data: {
name: options.name,
remoteUserId: options.remoteUserId,
serverFolders: { create: serverFoldersCreate },
serverUrls: { create: { url: options.url } },
token: options.token,
type: options.type,
url: options.url,
username: options.username,
},
});
for (const serverFolder of serverFolders) {
await prisma.serverFolder.upsert({
create: serverFolder,
update: { name: serverFolder.name },
where: {
uniqueServerFolderId: {
remoteId: serverFolder.remoteId,
serverId: serverFolder.serverId,
},
},
});
}
return server;
}
if (options.type === ServerType.JELLYFIN) {
const musicFoldersRes = await jellyfin.api.getMusicFolders({
remoteUserId: options.remoteUserId,
token: options.token,
url: options.url,
});
if (!musicFoldersRes) {
throw ApiError.badRequest('Server is inaccessible.');
}
const serverFoldersCreate = musicFoldersRes.map((musicFolder) => {
return {
name: musicFolder.Name,
remoteId: String(musicFolder.Id),
};
});
const server = await prisma.server.create({
data: {
name: options.name,
remoteUserId: options.remoteUserId,
serverFolders: { create: serverFoldersCreate },
serverUrls: { create: { url: options.url } },
token: options.token,
type: options.type,
url: options.url,
username: options.username,
},
});
return server;
}
throw ApiError.badRequest('Server type invalid.');
};
const update = async (
options: { id: string },
data: {
altToken?: string; // Used for Navidrome only
name?: string;
remoteUserId?: string;
token?: string;
type?: ServerType;
url?: string;
username?: string;
}
) => {
return prisma.server.update({
data,
where: { id: options.id },
});
};
const deleteById = async (options: { id: string }) => {
return prisma.server.delete({
where: { id: options.id },
});
};
const refresh = async (options: { id: string }) => {
const server = await prisma.server.findUnique({ where: { id: options.id } });
if (!server) {
throw ApiError.notFound('');
}
let serverFolders: {
name: string;
remoteId: string;
serverId: string;
}[] = [];
if (server.type === ServerType.SUBSONIC) {
const serverFoldersRes = await subsonic.api.getMusicFolders(server);
serverFolders = serverFoldersRes.map((musicFolder) => {
return {
name: musicFolder.name,
remoteId: String(musicFolder.id),
serverId: server.id,
};
});
}
if (server.type === ServerType.JELLYFIN) {
const musicFoldersRes = await jellyfin.api.getMusicFolders(server);
serverFolders = musicFoldersRes.map((musicFolder) => {
return {
name: musicFolder.Name,
remoteId: String(musicFolder.Id),
serverId: server.id,
};
});
}
// mark as deleted if not found
for (const serverFolder of serverFolders) {
await prisma.serverFolder.upsert({
create: serverFolder,
update: { name: serverFolder.name },
where: {
uniqueServerFolderId: {
remoteId: serverFolder.remoteId,
serverId: serverFolder.serverId,
},
},
});
}
return server;
};
const fullScan = async (options: { id: string; serverFolderId?: string[] }) => {
const { id, serverFolderId } = options;
const server = await prisma.server.findUnique({
include: { serverFolders: true },
where: { id },
});
if (!server) {
throw ApiError.notFound('Server does not exist.');
}
let serverFolders;
if (serverFolderId) {
serverFolders = server.serverFolders.filter((f) =>
serverFolderId?.includes(f.id)
);
} else {
serverFolders = server.serverFolders;
}
if (serverFolders.length === 0) {
throw ApiError.notFound('No matching server folders found.');
}
const task = await prisma.task.create({
data: {
completed: false,
name: 'Full scan',
server: { connect: { id: server.id } },
type: TaskType.FULL_SCAN,
},
});
if (server.type === ServerType.JELLYFIN) {
await jellyfin.scanner.scanAll(server, serverFolders, task);
}
if (server.type === ServerType.SUBSONIC) {
await subsonic.scanner.scanAll(server, serverFolders, task);
}
if (server.type === ServerType.NAVIDROME) {
await navidrome.scanner.scanAll(server, serverFolders, task);
}
return {};
};
const findServerUrlById = async (options: { id: string }) => {
const serverUrl = await prisma.serverUrl.findUnique({
where: { id: options.id },
});
return serverUrl;
};
// const findCredentialById = async (options: { id: string }) => {
// const credential = await prisma.serverCredential.findUnique({
// where: { id: options.id },
// });
// if (!credential) {
// throw ApiError.notFound('Credential not found.');
// }
// return credential;
// };
// const createCredential = async (options: {
// credential: string;
// serverId: string;
// userId: string;
// username: string;
// }) => {
// const { credential, serverId, userId, username } = options;
// const serverCredential = await prisma.serverCredential.create({
// data: {
// credential,
// serverId,
// userId,
// username,
// },
// });
// return serverCredential;
// };
// const deleteCredentialById = async (options: { id: string }) => {
// await prisma.serverCredential.delete({
// where: { id: options.id },
// });
// };
// const enableCredentialById = async (options: { id: string }) => {
// const serverCredential = await prisma.serverCredential.update({
// data: { enabled: true },
// where: { id: options.id },
// });
// const { id, userId, serverId } = serverCredential;
// await prisma.serverCredential.updateMany({
// data: { enabled: false },
// where: { AND: [{ serverId, userId }, { NOT: { id } }] },
// });
// return serverCredential;
// };
// const disableCredentialById = async (options: { id: string }) => {
// const serverCredential = await prisma.serverCredential.update({
// data: { enabled: false },
// where: { id: options.id },
// });
// return serverCredential;
// };
const createUrl = async (options: { serverId: string; url: string }) => {
const { serverId, url } = options;
const serverUrl = await prisma.serverUrl.create({
data: {
serverId,
url,
},
});
return serverUrl;
};
const findUrlById = async (options: { id: string }) => {
const url = await prisma.serverUrl.findUnique({
where: { id: options.id },
});
if (!url) {
throw ApiError.notFound('Url not found.');
}
return url;
};
const deleteUrlById = async (options: { id: string }) => {
await prisma.serverUrl.delete({
where: { id: options.id },
});
return null;
};
const enableUrlById = async (
user: AuthUser,
options: { id: string; serverId: string }
) => {
await prisma.userServerUrl.deleteMany({ where: { userId: user.id } });
await prisma.userServerUrl.create({
data: {
serverId: options.serverId,
serverUrlId: options.id,
userId: user.id,
},
});
return null;
};
const disableUrlById = async (user: AuthUser) => {
await prisma.userServerUrl.deleteMany({
where: { userId: user.id },
});
return null;
};
export const serversService = {
create,
createUrl,
deleteById,
deleteUrlById,
disableUrlById,
enableUrlById,
findById,
findMany,
findServerUrlById,
findUrlById,
fullScan,
refresh,
remoteServerLogin,
update,
};
-111
View File
@@ -1,111 +0,0 @@
import { User } from '@prisma/client';
import { Request } from 'express';
import { prisma } from '../lib';
import { SortOrder } from '../types/types';
import { ApiError, ApiSuccess, folderPermissions } from '../utils';
// import { toRes } from './response';
import { SongRequestParams } from './types';
const findById = async (options: { id: string; user: User }) => {
const { id } = options;
const album = await prisma.album.findUnique({
include: {
_count: true,
albumArtists: true,
genres: true,
songs: {
include: {
album: true,
artists: true,
externals: true,
genres: true,
images: true,
},
orderBy: [
{ discNumber: SortOrder.ASC },
{ trackNumber: SortOrder.ASC },
],
},
},
where: { id },
});
if (!album) throw ApiError.notFound('');
// if (!(await folderPermissions([album?.serverFolderId], user))) {
// throw ApiError.forbidden('');
// }
return ApiSuccess.ok({ data: album });
};
const findMany = async (
req: Request,
options: SongRequestParams & { user: User }
) => {
const {
albumIds: rawAlbumIds,
// artistIds: rawArtistIds,
serverId,
songIds: rawSongIds,
user,
skip,
take,
serverFolderIds: rServerFolderIds,
} = options;
const serverFolderIds = rServerFolderIds.split(',');
const albumIds = rawAlbumIds && rawAlbumIds.split(',');
// const artistIds = rawArtistIds && rawArtistIds.split(',');
const songIds = rawSongIds && rawSongIds.split(',');
if (serverFolderIds) {
if (!(await folderPermissions(serverFolderIds, user)))
throw ApiError.forbidden('');
}
// const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => {
// return { serverFolders: { id: { equals: serverFolderId } } };
// });
// const serverFoldersFilter = {
// serverFolders: { some: { id: { in: serverFolderIds } } },
// };
const [totalEntries, songs] = await prisma.$transaction([
prisma.song.count({
where: {
OR: [
// serverFoldersFilter,
{
albumId: { in: albumIds },
id: { in: songIds },
},
],
},
}),
prisma.song.findMany({
include: {
_count: { select: { favorites: true } },
genres: true,
images: true,
serverFolders: { include: { server: true } },
},
skip,
take,
where: {
AND: {
// OR: serverFoldersFilter,
serverId,
},
},
}),
]);
return { data: songs, totalEntries };
};
export const songsService = {
findById,
findMany,
};
-9
View File
@@ -1,9 +0,0 @@
import { OffsetPagination } from '../types/types';
export interface SongRequestParams extends OffsetPagination {
albumIds?: string;
artistIds?: string;
serverFolderIds: string;
serverId: string;
songIds?: string;
}
-32
View File
@@ -1,32 +0,0 @@
import { prisma } from '../lib';
import { AuthUser } from '../middleware';
import { ApiError } from '../utils';
const findById = async (user: AuthUser, options: { id: string }) => {
const { id } = options;
if (!user.isAdmin && user.id !== id) {
throw ApiError.forbidden();
}
const uniqueUser = await prisma.user.findUnique({
include: { serverFolderPermissions: true },
where: { id },
});
if (!uniqueUser) {
throw ApiError.notFound('');
}
return uniqueUser;
};
const findMany = async () => {
const users = await prisma.user.findMany({});
return users;
};
export const usersService = {
findById,
findMany,
};
-38
View File
@@ -1,38 +0,0 @@
{
"compilerOptions": {
"target": "es2021",
"module": "commonjs",
"lib": ["dom", "es2021"],
"baseUrl": "./",
"paths": {
"@controllers/*": ["controllers/*"],
"@services/*": ["services/*"],
"@validations/*": ["validations/*"],
"@middleware/*": ["middleware/*"],
"@helpers/*": ["helpers/*"],
"@routes/*": ["routes/*"],
"@queues/*": ["queues/*"],
"@lib/*": ["lib/*"],
"@utils/*": ["utils/*"],
"@/*": ["*"]
},
"declaration": true,
"declarationMap": true,
"strict": true,
"pretty": true,
"sourceMap": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"allowJs": true,
"outDir": "./dist",
"rootDir": ".",
"typeRoots": ["./types"]
},
"exclude": ["dist"]
}
-1
View File
@@ -1 +0,0 @@
declare module 'express-async-errors';
-5
View File
@@ -1,5 +0,0 @@
declare namespace Express {
export interface Request {
authUser: any;
}
}
-44
View File
@@ -1,44 +0,0 @@
export enum SortOrder {
ASC = 'asc',
DESC = 'desc',
}
export enum Item {
ALBUM = 'album',
ALBUM_ARTIST = 'albumArtist',
ARTIST = 'artist',
FOLDER = 'folder',
GENRE = 'genre',
PLAYLIST = 'playlist',
SONG = 'song',
}
export enum AlbumFilter {
FAVORITED,
NOT_FAVORITED,
}
export type OffsetPagination = {
skip: number;
take: number;
};
export type PaginationResponse = {
currentPage: number;
nextPage: string;
prevPage: string;
startIndex: number;
totalEntries: number;
};
export type SuccessResponse = {
data: any;
paginationItems?: PaginationItems;
};
export type PaginationItems = {
skip: number;
take: number;
totalEntries: number;
url: string;
};
-47
View File
@@ -1,47 +0,0 @@
export class ApiError extends Error {
message: string;
statusCode: number;
constructor(options: { message: string; statusCode: number }) {
super(options.message);
this.message = options.message;
this.statusCode = options.statusCode;
}
static badRequest(message?: string) {
return new ApiError({
message: message || 'Bad request.',
statusCode: 400,
});
}
static unauthorized(message?: string) {
return new ApiError({
message: message || 'Unauthorized.',
statusCode: 401,
});
}
static forbidden(message?: string) {
return new ApiError({ message: message || 'Forbidden.', statusCode: 403 });
}
static notFound(message?: string) {
return new ApiError({ message: message || 'Not found.', statusCode: 404 });
}
static conflict(message?: string) {
return new ApiError({ message: message || 'Conflict.', statusCode: 409 });
}
static gone(message?: string) {
return new ApiError({ message: message || 'Gone.', statusCode: 410 });
}
static internal(message?: string) {
return new ApiError({
message: message || 'Internal error.',
statusCode: 500,
});
}
}
-45
View File
@@ -1,45 +0,0 @@
import { PaginationItems, SuccessResponse } from '../types/types';
export class ApiSuccess {
data: any;
statusCode: number;
paginationItems?: PaginationItems;
constructor(options: {
data: any;
paginationItems?: PaginationItems;
statusCode: number;
}) {
this.data = options.data;
this.statusCode = options.statusCode;
this.paginationItems = options.paginationItems;
}
static ok({ data, paginationItems }: SuccessResponse) {
return new ApiSuccess({
data,
paginationItems,
statusCode: 200,
});
}
static created({ data, paginationItems }: SuccessResponse) {
return new ApiSuccess({ data, paginationItems, statusCode: 201 });
}
static accepted({ data, paginationItems }: SuccessResponse) {
return new ApiSuccess({ data, paginationItems, statusCode: 202 });
}
static noContent({ data, paginationItems }: SuccessResponse) {
return new ApiSuccess({ data, paginationItems, statusCode: 204 });
}
static resetContent({ data, paginationItems }: SuccessResponse) {
return new ApiSuccess({ data, paginationItems, statusCode: 205 });
}
static partialContent({ data, paginationItems }: SuccessResponse) {
return new ApiSuccess({ data, paginationItems, statusCode: 206 });
}
}
-83
View File
@@ -1,83 +0,0 @@
import { User } from '@prisma/client';
import { prisma } from '../lib';
export enum Roles {
NONE = 0,
GUEST = 1,
USER = 2,
ADMIN = 4,
SUPERADMIN = 8,
}
export enum FolderRoles {
NONE = 0,
READ = 1,
WRITE = 2,
ADMIN = 4,
}
export const folderPermissions = async (serverFolderIds: any[], user: User) => {
if (user.isAdmin) {
return true;
}
const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: {
OR: [
{
AND: [
{
serverFolderPermissions: {
some: { userId: { equals: user.id } },
},
},
],
},
],
},
});
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
(serverFolder) => serverFolder.id
);
const hasAccess = serverFolderIds.every((id) =>
serverFoldersWithAccessIds.includes(id)
);
return hasAccess;
};
export const getFolderPermissions = async (user: User) => {
if (user.isAdmin) {
const serverFoldersWithAccess = await prisma.serverFolder.findMany();
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
(serverFolder) => serverFolder.id
);
return serverFoldersWithAccessIds;
}
const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: {
OR: [
{
AND: [
{
serverFolderPermissions: {
some: { userId: { equals: user.id } },
},
},
],
},
],
},
});
const serverFoldersWithAccessIds = serverFoldersWithAccess.map(
(serverFolder) => serverFolder.id
);
return serverFoldersWithAccessIds;
};
-45
View File
@@ -1,45 +0,0 @@
import { PaginationItems } from '../types/types';
const getPaginationUrl = (
url: string,
skip: number,
take: number,
action: 'next' | 'prev'
) => {
if (action === 'next') {
return url.replace(/skip=(\d+)/gm, `skip=${skip + take}`);
}
return url.replace(/skip=(\d+)/gm, `skip=${skip - take}`);
};
export const getSuccessResponse = (options: {
data: any;
paginationItems?: PaginationItems;
statusCode: number;
}) => {
const { statusCode, data, paginationItems } = options;
let pagination;
if (paginationItems) {
const { skip, totalEntries, take, url } = paginationItems;
const hasPrevPage = skip - take >= 0;
const hasNextPage = skip + take <= totalEntries;
pagination = {
nextPage: hasNextPage ? getPaginationUrl(url, skip, take, 'next') : null,
prevPage: hasPrevPage ? getPaginationUrl(url, skip, take, 'prev') : null,
skip,
totalEntries,
};
}
return {
data,
pagination,
response: 'Success',
statusCode,
};
};
-8
View File
@@ -1,8 +0,0 @@
export const groupByProperty = (object: any, property: string) => {
return object.reduce((groups: any, item: any) => {
const group = groups[item[property]] || [];
group.push(item);
groups[item[property]] = group;
return groups;
}, {});
};
-11
View File
@@ -1,11 +0,0 @@
export * from './api-error';
export * from './api-success';
export * from './get-success-response';
export * from './group-by-property';
export * from './split-number-string';
export * from './split-text-string';
export * from './folder-permissions';
export * from './is-array-equal';
export * from './is-json-string';
export * from './unique-array';
export * from './random-string';
-13
View File
@@ -1,13 +0,0 @@
export const validateArrayEqual = (array1: any[], array2: any[]) => {
if (array1.length === array2.length) {
return array1.every((element) => {
if (array2.includes(element)) {
return true;
}
return false;
});
}
return false;
};
-8
View File
@@ -1,8 +0,0 @@
export const isJsonString = (string: string) => {
try {
JSON.parse(string);
} catch (e) {
return false;
}
return true;
};
-10
View File
@@ -1,10 +0,0 @@
export const randomString = (length: number) => {
const charSet =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let string = '';
for (let i = 0; i < length; i += 1) {
const randomPoz = Math.floor(Math.random() * charSet.length);
string += charSet.substring(randomPoz, randomPoz + 1);
}
return string;
};
-9
View File
@@ -1,9 +0,0 @@
export const splitNumberString = (string?: string, delimiter = ',') => {
if (!string) {
return undefined;
}
return string.split(delimiter).map((s: string) => {
return Number(s);
});
};
-5
View File
@@ -1,5 +0,0 @@
export const splitTextString = (string: string, delimiter = ',') => {
return string.split(delimiter).map((s: string) => {
return String(s);
});
};
-3
View File
@@ -1,3 +0,0 @@
export const uniqueArray = (value: any, index: any, self: any) => {
return self.indexOf(value) === index && value !== undefined;
};
@@ -1,22 +0,0 @@
import { z } from 'zod';
import { idValidation, paginationValidation } from './shared.validation';
export const list = {
body: z.object({}),
params: z.object({ ...idValidation('serverId') }),
query: z.object({
...paginationValidation,
serverFolderIds: z.string().min(1),
}),
};
export const detail = {
body: z.object({}),
params: z.object({ ...idValidation('id') }),
query: z.object({}),
};
export const albumArtistsValidation = {
detail,
list,
};
@@ -1,53 +0,0 @@
import { z } from 'zod';
import { AlbumSort } from '@helpers/albums.helpers';
import {
idValidation,
orderByValidation,
paginationValidation,
serverFolderIdValidation,
serverUrlIdValidation,
} from './shared.validation';
const list = {
body: z.object({}),
params: z.object({ ...idValidation('serverId') }),
query: z.object({
...paginationValidation,
...serverFolderIdValidation,
...orderByValidation,
...serverUrlIdValidation,
sortBy: z.nativeEnum(AlbumSort),
}),
};
const detail = {
body: z.object({}),
params: z.object({
...idValidation('albumId'),
...idValidation('serverId'),
}),
query: z.object({
...serverUrlIdValidation,
}),
};
const detailSongList = {
body: z.object({}),
params: z.object({
...idValidation('albumId'),
...idValidation('serverId'),
}),
query: z.object({
...paginationValidation,
...serverFolderIdValidation,
...orderByValidation,
...serverUrlIdValidation,
sortBy: z.nativeEnum(AlbumSort),
}),
};
export const albumsValidation = {
detail,
detailSongList,
list,
};
@@ -1,30 +0,0 @@
import { z } from 'zod';
import { AlbumSort } from '@helpers/albums.helpers';
import {
idValidation,
orderByValidation,
paginationValidation,
serverFolderIdValidation,
} from './shared.validation';
const list = {
body: z.object({}),
params: z.object({}),
query: z.object({
...paginationValidation,
...serverFolderIdValidation,
...orderByValidation,
sortBy: z.nativeEnum(AlbumSort),
}),
};
const detail = {
body: z.object({}),
params: z.object({ ...idValidation('id') }),
query: z.object({}),
};
export const artistsValidation = {
detail,
list,
};
-33
View File
@@ -1,33 +0,0 @@
import { z } from 'zod';
const login = {
body: z.object({
password: z.string(),
username: z.string(),
}),
params: z.object({}),
query: z.object({}),
};
const register = {
body: z.object({
password: z.string().min(6).max(255),
username: z.string().min(4).max(26),
}),
params: z.object({}),
query: z.object({}),
};
const refresh = {
body: z.object({
refreshToken: z.string(),
}),
params: z.object({}),
query: z.object({}),
};
export const authValidation = {
login,
refresh,
register,
};
-19
View File
@@ -1,19 +0,0 @@
import { albumArtistsValidation } from './album-artists.validation';
import { albumsValidation } from './albums.validation';
import { artistsValidation } from './artists.validation';
import { authValidation } from './auth.validation';
import { serversValidation } from './servers.validation';
import { songsValidation } from './songs.validation';
import { usersValidation } from './users.validation';
export { validateRequest, TypedRequest } from './shared.validation';
export const validation = {
albumArtists: albumArtistsValidation,
albums: albumsValidation,
artists: artistsValidation,
auth: authValidation,
servers: serversValidation,
songs: songsValidation,
users: usersValidation,
};
@@ -1,154 +0,0 @@
import { ServerType } from '@prisma/client';
import { z } from 'zod';
import { idValidation } from './shared.validation';
const detail = {
body: z.object({}),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const list = {
body: z.object({}),
params: z.object({}),
query: z.object({}),
};
const deleteServer = {
body: z.object({}),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const update = {
body: z.object({
legacy: z.boolean().optional(),
name: z.string().optional(),
password: z.string().optional(),
type: z.nativeEnum(ServerType),
url: z.string().optional(),
username: z.string().optional(),
}),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const create = {
body: z.object({
legacy: z.boolean().optional(),
name: z.string(),
password: z.string(),
type: z.enum([
ServerType.JELLYFIN,
ServerType.SUBSONIC,
ServerType.NAVIDROME,
]),
url: z.string(),
username: z.string(),
}),
params: z.object({}),
query: z.object({}),
};
const scan = {
body: z.object({ serverFolderId: z.string().array().optional() }),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const refresh = {
body: z.object({}),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const createCredential = {
body: z.object({ credential: z.string(), username: z.string() }),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const getCredentialDetail = {
body: z.object({}),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const deleteCredential = {
body: z.object({}),
params: z.object({
...idValidation('serverId'),
...idValidation('credentialId'),
}),
query: z.object({}),
};
const enableCredential = {
body: z.object({}),
params: z.object({
...idValidation('serverId'),
...idValidation('credentialId'),
}),
query: z.object({}),
};
const disableCredential = {
body: z.object({}),
params: z.object({
...idValidation('serverId'),
...idValidation('credentialId'),
}),
query: z.object({}),
};
const createUrl = {
body: z.object({ url: z.string() }),
params: z.object({ ...idValidation('serverId') }),
query: z.object({}),
};
const deleteUrl = {
body: z.object({}),
params: z.object({
...idValidation('serverId'),
...idValidation('urlId'),
}),
query: z.object({}),
};
const enableUrl = {
body: z.object({}),
params: z.object({
...idValidation('serverId'),
...idValidation('urlId'),
}),
query: z.object({}),
};
const disableUrl = {
body: z.object({}),
params: z.object({
...idValidation('serverId'),
...idValidation('urlId'),
}),
query: z.object({}),
};
export const serversValidation = {
create,
createCredential,
createUrl,
deleteCredential,
deleteServer,
deleteUrl,
detail,
disableCredential,
disableUrl,
enableCredential,
enableUrl,
getCredentialDetail,
list,
refresh,
scan,
update,
};
-130
View File
@@ -1,130 +0,0 @@
import { Request, RequestHandler } from 'express';
import { z, ZodError, ZodSchema } from 'zod';
import { SortOrder } from '@/types/types';
import { ApiError } from '@/utils';
// Modified from zod-express-middleware: https://github.com/Aquila169/zod-express-middleware
export type TypedRequest<
S extends {
body: z.AnyZodObject;
params: z.AnyZodObject;
query: z.AnyZodObject;
}
> = Request<z.infer<S['params']>, any, z.infer<S['body']>, z.infer<S['query']>>;
export enum ValidationType {
BODY = 'Body',
PARAMS = 'Params',
QUERY = 'Query',
}
type RequestValidation<TParams, TQuery, TBody> = {
body?: ZodSchema<TBody>;
params?: ZodSchema<TParams>;
query?: ZodSchema<TQuery>;
};
type ErrorListItem = {
errors: ZodError<any>;
type: ValidationType;
value: string;
};
const getErrorValue = (object: any, error: ZodError<any>) => {
const e = error.errors[0];
if (e.code === z.ZodIssueCode.invalid_type) {
return e.expected;
}
return object[e.path[0]][e.path[1]];
};
export const validateRequest: <TParams = any, TQuery = any, TBody = any>(
schemas: RequestValidation<TParams, TQuery, TBody>
) => RequestHandler<TParams, any, TBody, TQuery> =
({ params, query, body }) =>
(req, _res, next) => {
const errors: Array<ErrorListItem> = [];
if (params) {
const parsed = params.safeParse(req.params);
if (!parsed.success) {
errors.push({
errors: parsed.error,
type: ValidationType.PARAMS,
value: getErrorValue(req.params, parsed.error),
});
}
}
if (query) {
const parsed = query.safeParse(req.query);
if (!parsed.success) {
const value = getErrorValue(req.query, parsed.error);
errors.push({
errors: parsed.error,
type: ValidationType.QUERY,
value,
});
}
}
if (body) {
const parsed = body.safeParse(req.body);
if (!parsed.success) {
errors.push({
errors: parsed.error,
type: ValidationType.BODY,
value: getErrorValue(req.body, parsed.error),
});
}
}
if (errors.length > 0) {
const message = JSON.stringify(
[
// `(${errors[0].type})`,
`(${errors[0].type}: ${errors[0].errors.issues[0].path[0]})`,
errors[0].value && `[${errors[0].value}]`,
errors[0].errors.issues[0].message,
]
.filter((x) => x)
.join(' ')
);
throw ApiError.badRequest(message);
}
return next();
};
// const requiredErrorMessage = (
// type: 'Query' | 'Body' | 'Params',
// key: string
// ) => {
// return `(${type}) [${key}] Required`;
// };
export const paginationValidation = {
skip: z.string().refine((value) => {
const parsed = Number(value);
return !Number.isNaN(parsed) && parsed >= 0;
}),
take: z.string().refine((value) => {
const parsed = Number(value);
return !Number.isNaN(parsed) && parsed >= 0;
}),
};
export const serverUrlIdValidation = {
serverUrlId: z.optional(z.string().uuid()),
};
export const idValidation = (property: string) => {
return { [property]: z.string().uuid() };
};
export const serverFolderIdValidation = {
serverFolderId: z.optional(z.string().uuid().array()),
};
export const orderByValidation = {
orderBy: z.nativeEnum(SortOrder),
};

Some files were not shown because too many files have changed in this diff Show More