Redo server functionality

This commit is contained in:
jeffvli
2022-10-24 21:41:47 -07:00
parent db8a7d6a63
commit 4a3ce02805
40 changed files with 1986 additions and 952 deletions
@@ -1,41 +1,45 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod'; import { ApiSuccess, getSuccessResponse } from '@/utils';
import { albumArtistsService } from '../services'; import { service } from '@services/index';
import { import { validation, TypedRequest } from '@validations/index';
getSuccessResponse,
idValidation,
paginationValidation,
validateRequest,
} from '../utils';
const getAlbumArtists = async (req: Request, res: Response) => {
validateRequest(req, {
query: z.object({
...paginationValidation,
serverFolderIds: z.string().min(1),
}),
});
const getList = async (req: Request, res: Response) => {
const { take, skip, serverFolderIds } = req.query; const { take, skip, serverFolderIds } = req.query;
const data = await albumArtistsService.findMany(req, { const albumArtists = await service.albumArtists.findMany(req, {
serverFolderIds: String(serverFolderIds), serverFolderIds: String(serverFolderIds),
skip: Number(skip), skip: Number(skip),
take: Number(take), take: Number(take),
user: req.auth, user: req.authUser,
}); });
return res.status(data.statusCode).json(getSuccessResponse(data)); 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 getAlbumArtistById = async (req: Request, res: Response) => { const getDetail = async (
validateRequest(req, { params: z.object({ ...idValidation }) }); req: TypedRequest<typeof validation.albumArtists.detail>,
res: Response
) => {
const { id } = req.params; const { id } = req.params;
const data = await albumArtistsService.findById({ const albumArtist = await service.albumArtists.findById({
id: Number(id), id,
user: req.auth, user: req.authUser,
}); });
return res.status(data.statusCode).json(getSuccessResponse(data));
const success = ApiSuccess.ok({ data: albumArtist });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
export const albumArtistsController = { getAlbumArtistById, getAlbumArtists }; export const albumArtistsController = {
getDetail,
getList,
};
+88 -46
View File
@@ -1,60 +1,102 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import { z } from 'zod'; import { ApiSuccess, getSuccessResponse } from '@/utils';
import { AlbumSort } from '../helpers/albums.helpers'; import { toApiModel } from '@helpers/api-model';
import { albumsService } from '../services'; import { service } from '@services/index';
import { SortOrder } from '../types/types'; import { TypedRequest, validation } from '@validations/index';
import {
getSuccessResponse,
idValidation,
paginationValidation,
validateRequest,
} from '../utils';
const getAlbumById = async (req: Request, res: Response) => { const getDetail = async (
validateRequest(req, { req: TypedRequest<typeof validation.albums.detail>,
params: z.object({ ...idValidation }), res: Response
query: z.object({ serverUrls: z.optional(z.string().min(1)) }), ) => {
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],
}); });
const { id } = req.params; return res.status(success.statusCode).json(getSuccessResponse(success));
const { serverUrls } = req.query;
const data = await albumsService.findById({
id: Number(id),
serverUrls: serverUrls && String(serverUrls),
user: req.auth,
});
return res.status(data.statusCode).json(getSuccessResponse(data));
}; };
const getAlbums = async (req: Request, res: Response) => { const getList = async (
validateRequest(req, { req: TypedRequest<typeof validation.albums.list>,
query: z.object({ res: Response
...paginationValidation, ) => {
orderBy: z.nativeEnum(SortOrder), const { serverId } = req.params;
serverFolderIds: z.optional(z.string().min(1)), const { take, skip, serverUrlId } = req.query;
serverUrls: z.optional(z.string().min(1)),
sortBy: z.nativeEnum(AlbumSort),
}),
});
const { take, serverFolderIds, serverUrls, sortBy, orderBy, skip } = const albums = await service.albums.findMany({
req.query; ...req.query,
serverId,
const data = await albumsService.findMany(req, {
orderBy: orderBy as SortOrder,
serverFolderIds: serverFolderIds && String(serverFolderIds),
serverUrls: serverUrls && String(serverUrls),
skip: Number(skip), skip: Number(skip),
sortBy: sortBy as AlbumSort,
take: Number(take), take: Number(take),
user: req.auth, user: req.authUser,
}); });
return res.status(data.statusCode).json(getSuccessResponse(data)); 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 = { export const albumsController = {
getAlbumById, getDetail,
getAlbums, getDetailSongList,
getList,
}; };
+41 -32
View File
@@ -1,41 +1,50 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import { z } from 'zod'; import { ApiSuccess, getSuccessResponse } from '@/utils';
import { artistsService } from '../services'; import { service } from '@services/index';
import { import { validation, TypedRequest } from '@validations/index';
getSuccessResponse,
idValidation,
paginationValidation,
validateRequest,
} from '../utils';
const getArtistById = async (req: Request, res: Response) => {
validateRequest(req, { params: z.object({ ...idValidation }) });
const getDetail = async (
req: TypedRequest<typeof validation.artists.detail>,
res: Response
) => {
const { id } = req.params; const { id } = req.params;
const data = await artistsService.findById({
id: Number(id), const artist = await service.artists.findById({
user: req.auth, id,
user: req.authUser,
}); });
return res.status(data.statusCode).json(getSuccessResponse(data));
const success = ApiSuccess.ok({ data: artist });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
const getArtists = async (req: Request, res: Response) => { const getList = async (
validateRequest(req, { req: TypedRequest<typeof validation.artists.list>,
query: z.object({ res: Response
...paginationValidation, ) => {
serverFolderIds: z.string().min(1), const { take, skip, serverFolderId } = req.query;
}),
});
const { take, skip, serverFolderIds } = req.query; // const artists = await service.artists.findMany(req, {
const data = await artistsService.findMany(req, { // serverFolderIds: String(serverFolderIds),
serverFolderIds: String(serverFolderIds), // skip: Number(skip),
skip: Number(skip), // take: Number(take),
take: Number(take), // user: req.authUser,
user: req.auth, // });
});
return res.status(data.statusCode).json(getSuccessResponse(data)); // 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 = { getArtistById, getArtists }; export const artistsController = {
getDetail,
getList,
};
+31 -29
View File
@@ -1,38 +1,42 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod'; 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'; import packageJson from '../package.json';
import { authService } from '../services';
import { getSuccessResponse, validateRequest } from '../utils';
const login = async (req: Request, res: Response) => {
validateRequest(req, { body: z.object({ username: z.string() }) });
const login = async (
req: TypedRequest<typeof validation.auth.login>,
res: Response
) => {
const { username } = req.body; const { username } = req.body;
const { statusCode, data } = await authService.login({ username }); const user = await service.auth.login({ username });
return res.status(statusCode).json(getSuccessResponse({ data, statusCode })); const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
const register = async (req: Request, res: Response) => { const register = async (
validateRequest(req, { req: TypedRequest<typeof validation.auth.register>,
body: z.object({ res: Response
password: z.string().min(6).max(255), ) => {
username: z.string().min(4).max(26),
}),
});
const { username, password } = req.body; const { username, password } = req.body;
const { statusCode, data } = await authService.register({ const user = await service.auth.register({
password, password,
username, username,
}); });
return res.status(statusCode).json(getSuccessResponse({ data, statusCode })); const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
const logout = async (req: Request, res: Response) => { const logout = async (req: Request, res: Response) => {
const { statusCode, data } = await authService.logout({ user: req.auth }); await service.auth.logout({
return res.status(statusCode).json(getSuccessResponse({ data, statusCode })); user: req.authUser,
});
const success = ApiSuccess.noContent({ data: {} });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
const ping = async (_req: Request, res: Response) => { const ping = async (_req: Request, res: Response) => {
@@ -48,18 +52,16 @@ const ping = async (_req: Request, res: Response) => {
); );
}; };
const refresh = async (req: Request, res: Response) => { const refresh = async (
validateRequest(req, { req: TypedRequest<typeof validation.auth.refresh>,
body: z.object({ res: Response
refreshToken: z.string(), ) => {
}), const refresh = await service.auth.refresh({
});
const { data, statusCode } = await authService.refresh({
refreshToken: req.body.refreshToken, refreshToken: req.body.refreshToken,
}); });
return res.status(statusCode).json(getSuccessResponse({ data, statusCode })); const success = ApiSuccess.ok({ data: refresh });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
export const authController = { login, logout, ping, refresh, register }; export const authController = { login, logout, ping, refresh, register };
+17 -6
View File
@@ -1,6 +1,17 @@
export * from './album-artists.controller'; import { albumArtistsController } from './album-artists.controller';
export * from './auth.controller'; import { albumsController } from './albums.controller';
export * from './servers.controller'; import { artistsController } from './artists.controller';
export * from './users.controller'; import { authController } from './auth.controller';
export * from './artists.controller'; import { serversController } from './servers.controller';
export * from './albums.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,
};
+151 -46
View File
@@ -1,72 +1,177 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import { z } from 'zod'; import { ApiSuccess, getSuccessResponse } from '@/utils';
import { prisma } from '../lib'; import { toApiModel } from '@helpers/api-model';
import { serversService } from '../services'; import { service } from '@services/index';
import { getSuccessResponse, idValidation, validateRequest } from '../utils'; import { TypedRequest, validation } from '@validations/index';
const getServerById = async (req: Request, res: Response) => { const getServerDetail = async (
validateRequest(req, { params: z.object({ ...idValidation }) }); 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 { id } = req.params; const getServerList = async (
const data = await serversService.findById(req.auth, { req: TypedRequest<typeof validation.servers.list>,
id: Number(id), 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,
}); });
return res.status(data.statusCode).json(getSuccessResponse(data)); const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
const getServers = async (req: Request, res: Response) => { const updateServer = async (
const data = await serversService.findMany(req.auth); req: TypedRequest<typeof validation.servers.update>,
res: Response
) => {
const { serverId } = req.params;
const { username, password, name, legacy, type, url } = req.body;
return res.status(data.statusCode).json(getSuccessResponse(data)); 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 createServer = async (req: Request, res: Response) => { const refreshServer = async (
const data = await serversService.create(req.body); req: TypedRequest<typeof validation.servers.refresh>,
res: Response
) => {
const { serverId } = req.params;
const data = await service.servers.refresh({ id: serverId });
return res.status(data.statusCode).json(getSuccessResponse(data)); const success = ApiSuccess.ok({ data: toApiModel.servers([data])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
const refreshServer = async (req: Request, res: Response) => { const scanServer = async (
const { id } = req.params; req: TypedRequest<typeof validation.servers.scan>,
const data = await serversService.refresh({ id: Number(id) }); res: Response
) => {
const { serverId } = req.params;
const { serverFolderId } = req.body;
return res.status(data.statusCode).json(getSuccessResponse(data)); const data = await service.servers.fullScan({
}; id: serverId,
serverFolderId,
const scanServer = async (req: Request, res: Response) => {
validateRequest(req, {
query: z.object({ serverFolderIds: z.string().optional() }),
}); });
const { id } = req.params; const success = ApiSuccess.ok({ data });
const { serverFolderIds } = req.query; return res.status(success.statusCode).json(getSuccessResponse(success));
const data = await serversService.fullScan({
id: Number(id),
serverFolderIds: serverFolderIds && String(serverFolderIds),
userId: Number(req.auth.id),
});
return res.status(data.statusCode).json(getSuccessResponse(data));
}; };
const getFolder = async (req: Request, res: Response) => { const createServerUrl = async (
const data = await prisma.folder.findUnique({ req: TypedRequest<typeof validation.servers.createUrl>,
include: { res: Response
children: true, ) => {
}, const { serverId } = req.params;
where: { id: Number(req.params.id) }, const { url } = req.body;
const data = await service.servers.createUrl({
serverId,
url,
}); });
return res.status(200).json(getSuccessResponse({ data, statusCode: 200 })); 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 = { export const serversController = {
createServer, createServer,
getFolder, createServerUrl,
getServerById, deleteServer,
getServers, deleteServerUrl,
disableServerUrl,
enableServerUrl,
getServerDetail,
getServerList,
refreshServer, refreshServer,
scanServer, scanServer,
updateServer,
}; };
+24 -26
View File
@@ -1,35 +1,33 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod';
import { songsService } from '../services/songs.service';
import {
getSuccessResponse,
paginationValidation,
validateRequest,
} from '../utils';
const getSongs = async (req: Request, res: Response) => { const getSongList = async (req: Request, res: Response) => {
validateRequest(req, { const { serverId } = req.params;
query: z.object({ const { take, skip, serverFolderId } = req.query;
...paginationValidation,
albumIds: z.optional(z.string()),
artistIds: z.optional(z.string()),
serverFolderIds: z.optional(z.string().min(1)),
songIds: z.optional(z.string()),
}),
});
const { take, skip, serverFolderIds } = req.query; // const songs = await songsService.findMany(req, {
// serverFolderIds: String(serverFolderId),
// serverId,
// skip: Number(skip),
// take: Number(take),
// user: req.authUser,
// });
const data = await songsService.findMany(req, { // const success = ApiSuccess.ok({
serverFolderIds: String(serverFolderIds), // // data: toRes.songs(songs.data, req.authUser),
skip: Number(skip), // data: songs.data,
take: Number(take), // paginationItems: {
user: req.auth, // skip: Number(skip),
}); // take: Number(take),
// totalEntries: songs.totalEntries,
// url: req.originalUrl,
// },
// });
return res.status(data.statusCode).json(getSuccessResponse(data)); return {};
// return res.status(data.statusCode).json(getSuccessResponse(data));
}; };
export const songsController = { export const songsController = {
getSongs, getSongList,
}; };
+13 -12
View File
@@ -1,21 +1,22 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod'; import { ApiSuccess, getSuccessResponse } from '@/utils';
import { usersService } from '../services'; import { toApiModel } from '@helpers/api-model';
import { getSuccessResponse, idValidation, validateRequest } from '../utils'; import { service } from '@services/index';
const getUser = async (req: Request, res: Response) => { const getUserDetail = async (req: Request, res: Response) => {
validateRequest(req, { params: z.object({ ...idValidation }) });
const { id } = req.params; const { id } = req.params;
const data = await usersService.getOne({ id: Number(id) }); const user = await service.users.findById(req.authUser, { id });
return res.status(data.statusCode).json(getSuccessResponse(data)); const success = ApiSuccess.ok({ data: toApiModel.users([user])[0] });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
const getUsers = async (_req: Request, res: Response) => { const getUserList = async (_req: Request, res: Response) => {
const data = await usersService.getMany(); const users = await service.users.findMany();
return res.status(data.statusCode).json(getSuccessResponse(data)); const success = ApiSuccess.ok({ data: toApiModel.users(users) });
return res.status(success.statusCode).json(getSuccessResponse(success));
}; };
export const usersController = { export const usersController = {
getUser, getUserDetail,
getUsers, getUserList,
}; };
+41 -27
View File
@@ -1,34 +1,40 @@
import { Prisma } from '@prisma/client'; import { AuthUser } from '@/middleware';
import { SortOrder } from '../types/types'; import { SortOrder } from '@/types/types';
import { splitNumberString } from '../utils'; import { songHelpers } from '@helpers/songs.helpers';
import { songHelpers } from './songs.helpers';
export enum AlbumSort { export enum AlbumSort {
DATE_ADDED = 'date_added', DATE_ADDED = 'added',
DATE_ADDED_REMOTE = 'date_added_remote', DATE_ADDED_REMOTE = 'addedRemote',
DATE_RELEASED = 'date_released', DATE_RELEASED = 'released',
DATE_RELEASED_YEAR = 'year',
FAVORITE = 'favorite',
NAME = 'name',
RANDOM = 'random', RANDOM = 'random',
RATING = 'rating', RATING = 'rating',
TITLE = 'title',
YEAR = 'year',
} }
const include = (options?: { serverUrls?: string; songs: boolean }) => { const include = (options: { songs?: boolean; user?: AuthUser }) => {
const props: Prisma.AlbumInclude = { // Prisma.AlbumInclude
_count: { select: { favorites: true, songs: true } }, const props = {
albumArtist: true, _count: {
genres: true, select: {
images: true, favorites: true,
ratings: true, songs: true,
server: {
include: {
serverUrls: options?.serverUrls
? { where: { id: { in: splitNumberString(options.serverUrls) } } }
: true,
}, },
}, },
albumArtists: true,
songs: options?.songs ? songHelpers.include() : false, 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; return props;
@@ -38,7 +44,7 @@ const sort = (sortBy: AlbumSort, orderBy: SortOrder) => {
let order; let order;
switch (sortBy) { switch (sortBy) {
case AlbumSort.TITLE: case AlbumSort.NAME:
order = { name: orderBy }; order = { name: orderBy };
break; break;
@@ -51,11 +57,19 @@ const sort = (sortBy: AlbumSort, orderBy: SortOrder) => {
break; break;
case AlbumSort.DATE_RELEASED: case AlbumSort.DATE_RELEASED:
order = { date: orderBy, year: orderBy }; order = { releaseDate: orderBy, year: orderBy };
break; break;
case AlbumSort.YEAR: case AlbumSort.DATE_RELEASED_YEAR:
order = { year: orderBy }; order = { releaseYear: orderBy };
break;
case AlbumSort.RATING:
order = { rating: orderBy };
break;
case AlbumSort.FAVORITE:
order = { favorite: orderBy };
break; break;
default: default:
+520
View File
@@ -0,0 +1,520 @@
/* 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
@@ -0,0 +1,9 @@
import { albumHelpers } from './albums.helpers';
import { sharedHelpers } from './shared.helpers';
import { songHelpers } from './songs.helpers';
export const helpers = {
albums: albumHelpers,
shared: sharedHelpers,
songs: songHelpers,
};
+102 -5
View File
@@ -1,11 +1,108 @@
const serverFolderFilter = (serverFolderIds: number[]) => { import { AuthUser } from '@/middleware';
return serverFolderIds!.map((serverFolderId: number) => { import { ApiError } from '@/utils';
return { import { prisma } from '@lib/prisma';
serverFolders: { some: { id: { equals: Number(serverFolderId) } } },
}; 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 = { export const sharedHelpers = {
checkServerFolderPermissions,
checkServerPermissions,
getAvailableServerFolderIds,
params: {
pagination: paginationParams,
},
serverFolderFilter, serverFolderFilter,
}; };
+28 -3
View File
@@ -1,20 +1,45 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
const include = () => { const include = () => {
const body = { 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: { include: {
album: true, album: true,
artists: true, artists: true,
externals: true, externals: true,
genres: true, genres: true,
images: true, images: true,
ratings: true,
server: {
include: { serverUrls: true },
},
}, },
orderBy: [{ disc: Prisma.SortOrder.asc }, { track: Prisma.SortOrder.asc }], orderBy: [
// { albumId: Prisma.SortOrder.asc },
{ discNumber: Prisma.SortOrder.asc },
{ trackNumber: Prisma.SortOrder.asc },
],
}; };
return body; return props;
}; };
export const songHelpers = { export const songHelpers = {
findMany,
include, include,
}; };
+1 -14
View File
@@ -1,9 +1,4 @@
import { import { ServerFolderPermission, ServerPermission, User } from '@prisma/client';
ServerCredential,
ServerFolderPermission,
ServerPermission,
User,
} from '@prisma/client';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import passport from 'passport'; import passport from 'passport';
@@ -55,13 +50,6 @@ export const authenticate = (
(permission: ServerPermission) => permission.serverId (permission: ServerPermission) => permission.serverId
); );
const serverCredentials = user.serverCredentials.map(
(credential: ServerCredential) => ({
id: credential.id,
serverId: credential.serverId,
})
);
const props = { const props = {
createdAt: user?.createdAt, createdAt: user?.createdAt,
enabled: user?.enabled, enabled: user?.enabled,
@@ -70,7 +58,6 @@ export const authenticate = (
id: user?.id, id: user?.id,
isAdmin: user?.isAdmin, isAdmin: user?.isAdmin,
server: req.params.serverId, server: req.params.serverId,
serverCredentials,
serverFolderPermissions: user?.serverFolderPermissions, serverFolderPermissions: user?.serverFolderPermissions,
serverPermissions: user?.serverPermissions, serverPermissions: user?.serverPermissions,
updatedAt: user?.updatedAt, updatedAt: user?.updatedAt,
+2 -2
View File
@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { isJsonString } from '../utils'; import { isJsonString } from '@utils/is-json-string';
export const errorHandler = ( export const errorHandler = (
err: any, err: any,
@@ -9,7 +9,7 @@ export const errorHandler = (
) => { ) => {
let message = ''; let message = '';
const trace = err.stack.match(/at .* \(.*\)/g).map((e: string) => { const trace = err.stack?.match(/at .* \(.*\)/g).map((e: string) => {
return e.replace(/\(|\)/g, ''); return e.replace(/\(|\)/g, '');
}); });
+8 -3
View File
@@ -1,8 +1,13 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import { controller } from '../controllers'; import { controller } from '@controllers/index';
import { validation, validateRequest } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true }); export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.albumArtists.getAlbumArtists); router.get('/', controller.albumArtists.getList);
router.get('/:id', controller.albumArtists.getAlbumArtistById); router.get(
':serverId',
validateRequest(validation.albumArtists.detail),
controller.albumArtists.getDetail
);
+18 -3
View File
@@ -1,8 +1,23 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import { controller } from '../controllers'; import { controller } from '@controllers/index';
import { validateRequest, validation } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true }); export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.albums.getAlbumList); router.get(
'/',
validateRequest(validation.albums.list),
controller.albums.getList
);
router.get('/:id', controller.albums.getAlbumDetail); router.get(
'/:albumId',
validateRequest(validation.albums.detail),
controller.albums.getDetail
);
router.get(
'/:albumId/songs',
validateRequest(validation.albums.detail),
controller.albums.getDetailSongList
);
+3 -3
View File
@@ -1,8 +1,8 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import { controller } from '../controllers'; import { controller } from '@controllers/index';
export const router: Router = express.Router({ mergeParams: true }); export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.artists.getArtists); router.get('/', controller.artists.getList);
router.get('/:id', controller.artists.getArtistById); router.get(':serverId', controller.artists.getDetail);
+19 -5
View File
@@ -1,16 +1,30 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import passport from 'passport'; import passport from 'passport';
import { controller } from '../controllers'; import { controller } from '@controllers/index';
import { authenticate } from '../middleware'; import { authenticate } from '@middleware/authenticate';
import { validation, validateRequest } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true }); export const router: Router = express.Router({ mergeParams: true });
router.post('/login', passport.authenticate('local'), controller.auth.login); router.post(
'/login',
validateRequest(validation.auth.login),
passport.authenticate('local'),
controller.auth.login
);
router.post('/register', controller.auth.register); router.post(
'/register',
validateRequest(validation.auth.register),
controller.auth.register
);
router.post('/logout', authenticate, controller.auth.logout); router.post('/logout', authenticate, controller.auth.logout);
router.post('/refresh', controller.auth.refresh); router.post(
'/refresh',
validateRequest(validation.auth.refresh),
controller.auth.refresh
);
router.get('/ping', controller.auth.ping); router.get('/ping', controller.auth.ping);
+15 -1
View File
@@ -23,7 +23,21 @@ routes.use('/api/users', usersRouter);
routes.use('/api/servers', serversRouter); routes.use('/api/servers', serversRouter);
routes.param('serverId', (req, _res, next, serverId) => { routes.param('serverId', (req, _res, next, serverId) => {
helpers.shared.checkServerPermissions(req.auth, { 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(); next();
}); });
+78 -8
View File
@@ -1,17 +1,87 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import { controller } from '../controllers'; import { controller } from '@controllers/index';
import { authenticateAdmin } from '../middleware'; 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 }); export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.servers.getServerList); router
.route('/')
.get(
validateRequest(validation.servers.list),
controller.servers.getServerList
)
.post(
authenticateAdmin,
validateRequest(validation.servers.create),
controller.servers.createServer
);
router.post('/', authenticateAdmin, 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.get('/:id', controller.servers.getServerDetail); router
.route('/:serverId/refresh')
.get(
authenticateAdmin,
validateRequest(validation.servers.refresh),
controller.servers.refreshServer
);
router.get('/:id/refresh', authenticateAdmin, controller.servers.refreshServer); router
.route('/:serverId/scan')
.post(
validateRequest(validation.servers.scan),
authenticateAdmin,
controller.servers.scanServer
);
router.get('/:id/folder', authenticateAdmin, controller.servers.getFolder); router
.route('/:serverId/url')
.post(
authenticateAdmin,
validateRequest(validation.servers.createUrl),
controller.servers.createServerUrl
);
router.post('/:id/scan', authenticateAdmin, controller.servers.scanServer); 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
);
+7 -2
View File
@@ -1,6 +1,11 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import { controller } from '../controllers'; import { validation, validateRequest } from '@validations/index';
export const router: Router = express.Router({ mergeParams: true }); export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.songs.getSongs); 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));
});
+9 -3
View File
@@ -1,8 +1,14 @@
import express, { Router } from 'express'; import express, { Router } from 'express';
import { controller } from '../controllers'; 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 }); export const router: Router = express.Router({ mergeParams: true });
router.get('/', controller.users.getUsers); router.get('/', authenticateAdmin, controller.users.getUserList);
router.get('/:id', controller.users.getUser); router.get(
':serverId',
validateRequest(validation.users.detail),
controller.users.getUserDetail
);
+2 -2
View File
@@ -4,8 +4,8 @@ import cors from 'cors';
import express from 'express'; import express from 'express';
import passport from 'passport'; import passport from 'passport';
import 'express-async-errors'; import 'express-async-errors';
import { errorHandler } from './middleware'; import { errorHandler } from '@/middleware';
import { routes } from './routes'; import { routes } from '@routes/index';
require('./lib/passport'); require('./lib/passport');
+22 -33
View File
@@ -1,14 +1,11 @@
import { User } from '@prisma/client';
import { Request } from 'express'; import { Request } from 'express';
import { prisma } from '../lib'; import { OffsetPagination } from '@/types/types';
import { OffsetPagination, User } from '../types/types'; import { ApiError } from '@/utils';
import { import { prisma } from '@lib/prisma';
ApiError, import { folderPermissions } from '@utils/folder-permissions';
ApiSuccess,
folderPermissions,
splitNumberString,
} from '../utils';
const findById = async (options: { id: number; user: User }) => { const findById = async (options: { id: string; user: User }) => {
const { id, user } = options; const { id, user } = options;
const albumArtist = await prisma.albumArtist.findUnique({ const albumArtist = await prisma.albumArtist.findUnique({
include: { include: {
@@ -32,7 +29,7 @@ const findById = async (options: { id: number; user: User }) => {
throw ApiError.forbidden(''); throw ApiError.forbidden('');
} }
return ApiSuccess.ok({ data: albumArtist }); return albumArtist;
}; };
const findMany = async ( const findMany = async (
@@ -40,37 +37,29 @@ const findMany = async (
options: { serverFolderIds: string; user: User } & OffsetPagination options: { serverFolderIds: string; user: User } & OffsetPagination
) => { ) => {
const { user, take, serverFolderIds: rServerFolderIds, skip } = options; const { user, take, serverFolderIds: rServerFolderIds, skip } = options;
const serverFolderIds = splitNumberString(rServerFolderIds); const serverFolderIds = rServerFolderIds.split(',');
if (!(await folderPermissions(serverFolderIds!, user))) { if (!(await folderPermissions(serverFolderIds!, user))) {
throw ApiError.forbidden(''); throw ApiError.forbidden('');
} }
const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => { const serverFoldersFilter = serverFolderIds!.map((serverFolderId) => ({
return { serverFolders: { some: { id: { equals: serverFolderId } } },
serverFolders: { some: { id: { equals: Number(serverFolderId) } } }, }));
};
});
const totalEntries = await prisma.albumArtist.count({ const [totalEntries, albumArtists] = await prisma.$transaction([
where: { OR: serverFoldersFilter }, prisma.albumArtist.count({
}); where: { OR: serverFoldersFilter },
const albumArtists = await prisma.albumArtist.findMany({ }),
include: { genres: true }, prisma.albumArtist.findMany({
skip, include: { genres: true },
take,
where: { OR: serverFoldersFilter },
});
return ApiSuccess.ok({
data: albumArtists,
paginationItems: {
skip, skip,
take, take,
totalEntries, where: { OR: serverFoldersFilter },
url: req.originalUrl, }),
}, ]);
});
return { data: albumArtists, totalEntries };
}; };
export const albumArtistsService = { export const albumArtistsService = {
+59 -85
View File
@@ -1,30 +1,15 @@
import { Album } from '@prisma/client'; import { AuthUser } from '@/middleware';
import { Request } from 'express'; import { OffsetPagination, SortOrder } from '@/types/types';
import { albumHelpers, AlbumSort } from '../helpers/albums.helpers'; import { ApiError } from '@/utils';
import { sharedHelpers } from '../helpers/shared.helpers'; import { AlbumSort } from '@helpers/albums.helpers';
import { prisma } from '../lib'; import { helpers } from '@helpers/index';
import { OffsetPagination, SortOrder, User } from '../types/types'; import { prisma } from '@lib/prisma';
import {
ApiError,
ApiSuccess,
folderPermissions,
getFolderPermissions,
splitNumberString,
} from '../utils';
import { toRes } from './response';
const findById = async (options: { const findById = async (user: AuthUser, options: { id: string }) => {
id: number; const { id } = options;
serverUrls?: string;
user: User;
}) => {
const { id, user, serverUrls } = options;
const album = await prisma.album.findUnique({ const album = await prisma.album.findUnique({
include: { include: helpers.albums.include({ songs: true }),
...albumHelpers.include({ serverUrls, songs: true }),
serverFolders: true,
},
where: { id }, where: { id },
}); });
@@ -32,105 +17,94 @@ const findById = async (options: {
throw ApiError.notFound(''); throw ApiError.notFound('');
} }
const serverFolderIds = album.serverFolders.map( const serverFolderId = album.serverFolders.map((s) => s.id);
(serverFolder) => serverFolder.id helpers.shared.checkServerFolderPermissions(user, { serverFolderId });
);
if (!(await folderPermissions(serverFolderIds, user))) { return album;
throw ApiError.forbidden('');
}
return ApiSuccess.ok({ data: toRes.albums([album], user)[0] });
}; };
const findMany = async ( export type AlbumFindManyOptions = {
req: Request, orderBy: SortOrder;
options: { serverFolderId?: string[];
orderBy: SortOrder; serverId: string;
serverFolderIds?: string; sortBy: AlbumSort;
serverUrls?: string; user: AuthUser;
sortBy: AlbumSort; } & OffsetPagination;
user: User;
} & OffsetPagination
) => {
const {
user,
take,
serverFolderIds: rServerFolderIds,
serverUrls,
skip,
sortBy,
orderBy,
} = options;
const serverFolderIds = rServerFolderIds const findMany = async (options: AlbumFindManyOptions) => {
? splitNumberString(rServerFolderIds) const { take, serverFolderId, skip, sortBy, orderBy, user, serverId } =
: await getFolderPermissions(user); options;
if (!(await folderPermissions(serverFolderIds!, user))) { const serverFolderIds =
throw ApiError.forbidden(''); serverFolderId ||
} (await helpers.shared.getAvailableServerFolderIds(user, { serverId }));
const serverFoldersFilter = sharedHelpers.serverFolderFilter(
serverFolderIds!
);
let totalEntries = 0; let totalEntries = 0;
let albums: Album[]; let albums;
if (sortBy === AlbumSort.RATING) { if (sortBy === AlbumSort.RATING) {
const [count, result] = await prisma.$transaction([ const [count, result] = await prisma.$transaction([
prisma.albumRating.count({ prisma.albumRating.count({
where: { where: {
album: { OR: serverFoldersFilter }, album: { OR: helpers.shared.serverFolderFilter(serverFolderIds) },
user: { id: user.id }, user: { id: user.id },
}, },
}), }),
prisma.albumRating.findMany({ prisma.albumRating.findMany({
include: { include: {
album: { album: {
include: { ...albumHelpers.include({ serverUrls, songs: false }) }, include: helpers.albums.include({ songs: false, user }),
}, },
}, },
orderBy: { value: orderBy }, orderBy: { value: orderBy },
skip, skip,
take, take,
where: { where: {
album: { OR: serverFoldersFilter }, album: { OR: helpers.shared.serverFolderFilter(serverFolderIds) },
user: { id: user.id }, user: { id: user.id },
}, },
}), }),
]); ]);
albums = result.map((rating) => rating.album);
albums = result.map((rating) => rating.album) as Album[];
totalEntries = count; totalEntries = count;
} else { } else if (sortBy === AlbumSort.FAVORITE) {
const [count, result] = await prisma.$transaction([ [totalEntries, albums] = await prisma.$transaction([
prisma.album.count({ prisma.album.count({
where: { OR: serverFoldersFilter }, where: {
AND: [
helpers.shared.serverFolderFilter(serverFolderIds),
{ favorites: { some: { userId: user.id } } },
],
},
}), }),
prisma.album.findMany({ prisma.album.findMany({
include: { ...albumHelpers.include({ serverUrls, songs: false }) }, include: helpers.albums.include({ songs: false, user }),
orderBy: [{ ...albumHelpers.sort(sortBy, orderBy) }],
skip, skip,
take, take,
where: { OR: serverFoldersFilter }, 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) },
}), }),
]); ]);
albums = result;
totalEntries = count;
} }
return ApiSuccess.ok({ return { data: albums, totalEntries };
data: toRes.albums(albums, user),
paginationItems: {
skip,
take,
totalEntries,
url: req.originalUrl,
},
});
}; };
export const albumsService = { export const albumsService = {
+23 -43
View File
@@ -1,14 +1,10 @@
import { User } from '@prisma/client';
import { Request } from 'express'; import { Request } from 'express';
import { prisma } from '../lib'; import { prisma } from '../lib';
import { OffsetPagination, User } from '../types/types'; import { OffsetPagination } from '../types/types';
import { import { ApiError, folderPermissions } from '../utils';
ApiError,
ApiSuccess,
folderPermissions,
splitNumberString,
} from '../utils';
const findById = async (options: { id: number; user: User }) => { const findById = async (options: { id: string; user: User }) => {
const { id, user } = options; const { id, user } = options;
const artist = await prisma.artist.findUnique({ const artist = await prisma.artist.findUnique({
@@ -16,19 +12,16 @@ const findById = async (options: { id: number; user: User }) => {
where: { id }, where: { id },
}); });
if (!artist) { if (!artist) throw ApiError.notFound('');
throw ApiError.notFound('');
}
const serverFolderIds = artist.serverFolders.map( const serverFolderIds = artist.serverFolders.map(
(serverFolder) => serverFolder.id (serverFolder) => serverFolder.id
); );
if (!(await folderPermissions(serverFolderIds, user))) { if (!(await folderPermissions(serverFolderIds, user)))
throw ApiError.forbidden(''); throw ApiError.forbidden('');
}
return ApiSuccess.ok({ data: artist }); return artist;
}; };
const findMany = async ( const findMany = async (
@@ -36,41 +29,28 @@ const findMany = async (
options: { serverFolderIds: string; user: User } & OffsetPagination options: { serverFolderIds: string; user: User } & OffsetPagination
) => { ) => {
const { user, skip, take, serverFolderIds: rServerFolderIds } = options; const { user, skip, take, serverFolderIds: rServerFolderIds } = options;
const serverFolderIds = splitNumberString(rServerFolderIds); const serverFolderIds = rServerFolderIds.split(',');
if (!(await folderPermissions(serverFolderIds!, user))) { if (!(await folderPermissions(serverFolderIds!, user)))
throw ApiError.forbidden(''); throw ApiError.forbidden('');
}
const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => { const serverFoldersFilter = serverFolderIds!.map((serverFolderId) => ({
return { serverFolders: { some: { id: { equals: serverFolderId } } },
serverFolders: { }));
some: {
id: { equals: Number(serverFolderId) },
},
},
};
});
const totalEntries = await prisma.artist.count({ const [totalEntries, artists] = await prisma.$transaction([
where: { OR: serverFoldersFilter }, prisma.artist.count({
}); where: { OR: serverFoldersFilter },
const artists = await prisma.artist.findMany({ }),
include: { genres: true }, prisma.artist.findMany({
skip, include: { genres: true },
take,
where: { OR: serverFoldersFilter },
});
return ApiSuccess.ok({
data: artists,
paginationItems: {
skip, skip,
take, take,
totalEntries, where: { OR: serverFoldersFilter },
url: req.originalUrl, }),
}, ]);
});
return { data: artists, totalEntries };
}; };
export const artistsService = { export const artistsService = {
+27 -20
View File
@@ -8,22 +8,32 @@ import { ApiError } from '../utils/api-error';
const login = async (options: { username: string }) => { const login = async (options: { username: string }) => {
const { username } = options; const { username } = options;
const user = await prisma.user.findUnique({ where: { username } }); const user = await prisma.user.findUnique({
include: { serverFolderPermissions: true, serverPermissions: true },
where: { username },
});
if (user) { if (!user) {
const accessToken = generateToken(user.id); throw ApiError.notFound('The user does not exist.');
const refreshToken = generateRefreshToken(user.id);
await prisma.refreshToken.create({
data: { token: refreshToken, userId: user.id },
});
const res = { ...user, accessToken, refreshToken };
return ApiSuccess.ok({ data: res });
} }
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 register = async (options: { password: string; username: string }) => {
@@ -44,7 +54,7 @@ const register = async (options: { password: string; username: string }) => {
}, },
}); });
return ApiSuccess.ok({ data: user }); return user;
}; };
const logout = async (options: { user: User }) => { const logout = async (options: { user: User }) => {
@@ -59,19 +69,16 @@ const logout = async (options: { user: User }) => {
const refresh = async (options: { refreshToken: string }) => { const refresh = async (options: { refreshToken: string }) => {
const { refreshToken } = options; const { refreshToken } = options;
const user = jwt.verify(refreshToken, String(process.env.TOKEN_SECRET)); const user = jwt.verify(refreshToken, String(process.env.TOKEN_SECRET));
const { id } = user as { exp: number; iat: number; id: number }; const { id } = user as { exp: number; iat: number; id: string };
const token = await prisma.refreshToken.findUnique({ const token = await prisma.refreshToken.findUnique({
where: { token: refreshToken }, where: { token: refreshToken },
}); });
if (!token) { if (!token) throw ApiError.unauthorized('Invalid refresh token.');
throw ApiError.unauthorized('Invalid refresh token.');
}
const newToken = generateToken(id); const newToken = generateToken(id);
return { accessToken: newToken };
return ApiSuccess.ok({ data: { accessToken: newToken } });
}; };
export const authService = { export const authService = {
+15 -6
View File
@@ -1,6 +1,15 @@
export * from './auth.service'; import { albumArtistsService } from './album-artists.service';
export * from './servers.service'; import { albumsService } from './albums.service';
export * from './album-artists.service'; import { artistsService } from './artists.service';
export * from './users.service'; import { authService } from './auth.service';
export * from './artists.service'; import { serversService } from './servers.service';
export * from './albums.service'; import { usersService } from './users.service';
export const service = {
albumArtists: albumArtistsService,
albums: albumsService,
artists: artistsService,
auth: authService,
servers: serversService,
users: usersService,
};
-240
View File
@@ -1,240 +0,0 @@
/* eslint-disable no-underscore-dangle */
import meanBy from 'lodash/meanBy';
import { Item, Rating, User } from '../types/types';
import { getImageUrl } from '../utils';
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 = (
serverType: string,
args: {
deviceId: string;
remoteId: string;
token: string;
url: string;
userId?: string;
}
) => {
if (serverType === 'jellyfin') {
return getJellyfinStreamUrl(
args.remoteId,
args.url,
args.token,
args.userId || '',
args.deviceId
);
}
if (serverType === 'subsonic') {
return getSubsonicStreamUrl(
args.remoteId,
args.url,
args.token,
args.deviceId
);
}
return '';
};
const relatedAlbum = (item: any) => {
return {
deleted: item.deleted,
id: item.id,
itemType: Item.ALBUM,
name: item.name,
remoteId: item.remoteId,
};
};
const relatedArtists = (items: any[]) => {
return (
items?.map((item: any) => {
return {
deleted: item.deleted,
id: item.id,
itemType: Item.ARTIST,
name: item.name,
remoteId: item.remoteId,
};
}) || []
);
};
const relatedAlbumArtist = (item: any) => {
return {
deleted: item.deleted,
id: item.id,
itemType: item.ALBUMARTIST,
name: item.name,
remoteId: item.remoteId,
};
};
const relatedGenres = (genres: any[]) => {
return (
genres?.map((genre) => {
return {
id: genre.id,
itemType: Item.GENRE,
name: genre.name,
};
}) || []
);
};
const primaryImage = (
images: any[],
serverType: string,
url: string,
remoteId: string
) => {
const primaryImageId = images.find((i: any) => i.name === 'Primary')?.url;
const image = !primaryImageId ? '' : getImageUrl(serverType, url, remoteId);
return image;
};
const songs = (
items: any[],
options: {
deviceId: string;
imageUrl?: string;
serverFolderId?: number;
serverType?: string;
token: string;
url?: string;
userId: string;
}
) => {
return (
items?.map((item: any) => {
const serverType = options.serverType
? options?.serverType
: item.server.serverType;
const url = options.url ? options.url : item.server.serverUrls[0];
return {
album: item.album && relatedAlbum(item.album),
artistName: item.artistName,
artists: relatedArtists(item.artists),
bitRate: item.bitRate,
container: item.container,
createdAt: item.createdAt,
date: item.date,
deleted: item.deleted,
disc: item.disc,
duration: item.duration,
genres: relatedGenres(item.genres),
id: item.id,
imageUrl:
primaryImage(item.images, serverType, url, item.remoteId) ||
options.imageUrl,
itemType: Item.SONG,
name: item.name,
remoteCreatedAt: item.remoteCreatedAt,
remoteId: item.remoteId,
serverFolderId: item.serverFolderId,
serverId: item.serverId,
streamUrl: streamUrl(serverType, {
deviceId: options.deviceId,
remoteId: item.remoteId,
token: options.token,
url,
userId: options.userId,
}),
track: item.track,
updatedAt: item.updatedAt,
year: item.year,
};
}) || []
);
};
const albums = (items: any[], user: User) => {
return (
items?.map((item: any) => {
const { serverType, token, remoteUserId } = item.server;
const { url } = item.server.serverUrls[0];
const rating = item.ratings.find(
(r: Rating) => r.userId === user.id
)?.value;
const averageRating = meanBy(item.ratings, 'value');
const imageUrl = primaryImage(
item.images,
serverType,
url,
item.remoteId
);
return {
albumArtist: item.albumArtist && relatedAlbumArtist(item.albumArtist),
averageRating,
createdAt: item.createdAt,
date: item.date,
deleted: item.deleted,
genres: relatedGenres(item.genres),
id: item.id,
imageUrl,
itemType: Item.ALBUM,
name: item.name,
rating,
remoteCreatedAt: item.remoteCreatedAt,
remoteId: item.remoteId,
serverFolderId: item.serverFolderId,
serverType,
songCount: item._count.songs,
songs: songs(item.songs, {
deviceId: user.deviceId,
imageUrl,
serverFolderId: item.serverFolderId,
serverType,
token,
url,
userId: remoteUserId,
}),
updatedAt: item.updatedAt,
year: item.year,
};
}) || []
);
};
export const toRes = {
albums,
songs,
};
+399 -138
View File
@@ -1,27 +1,98 @@
import { ServerType, TaskType } from '@prisma/client';
import { SortOrder } from '@/types/types';
import { helpers } from '../helpers';
import { prisma } from '../lib'; import { prisma } from '../lib';
import { import { AuthUser } from '../middleware';
jellyfinApi, import { subsonic } from '../queue';
jellyfinTasks, import { jellyfin } from '../queue/jellyfin';
subsonicApi, import { navidrome } from '../queue/navidrome';
subsonicTasks, import { ApiError } from '../utils';
} from '../queue';
import { User } from '../types/types';
import { ApiError, ApiSuccess, splitNumberString } from '../utils';
const findById = async (user: User, options: { id: number }) => { 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; const { id } = options;
helpers.shared.checkServerPermissions(user, { serverId: id });
const server = await prisma.server.findUnique({ const server = await prisma.server.findUnique({
include: { include: {
serverFolders: user.isAdmin serverFolders: user.isAdmin
? true ? true
: { : {
where: { where: {
OR: [ OR: [{ serverFolderPermissions: { some: { userId: user.id } } }],
{ isPublic: true },
{ serverFolderPermissions: { some: { userId: user.id } } },
],
}, },
}, },
serverPermissions: {
where: { userId: user.id },
},
}, },
where: { id }, where: { id },
}); });
@@ -30,108 +101,162 @@ const findById = async (user: User, options: { id: number }) => {
throw ApiError.notFound(''); throw ApiError.notFound('');
} }
if (!user.isAdmin && server.serverFolders.length === 0) { return server;
throw ApiError.forbidden('');
}
return ApiSuccess.ok({ data: server });
}; };
const findMany = async (user: User) => { const findMany = async (user: AuthUser) => {
let servers;
if (user.isAdmin) { if (user.isAdmin) {
servers = await prisma.server.findMany({ return prisma.server.findMany({
include: { serverFolders: true },
});
} else {
servers = await prisma.server.findMany({
include: { include: {
serverFolders: { serverFolders: {
where: { orderBy: { name: SortOrder.ASC },
OR: [ },
{ isPublic: true }, serverPermissions: {
{ serverFolderPermissions: { some: { userId: user.id } } }, orderBy: { createdAt: SortOrder.ASC },
], where: { userId: user.id },
},
serverUrls: {
include: {
userServerUrls: {
where: { userId: user.id },
},
}, },
}, },
}, },
where: { serverFolders: { some: { isPublic: true } } }, orderBy: { createdAt: SortOrder.ASC },
}); });
} }
return ApiSuccess.ok({ data: servers }); 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: { const create = async (options: {
altToken?: string; // Used for Navidrome only
name: string; name: string;
remoteUserId: string; remoteUserId: string;
serverType: string;
token: string; token: string;
type: ServerType;
url: string; url: string;
username: string; username: string;
}) => { }) => {
const checkDuplicate = await prisma.server.findUnique({ const isDuplicate = await prisma.server.findUnique({
where: { url: options.url }, where: { url: options.url },
}); });
if (checkDuplicate) { if (isDuplicate) {
throw ApiError.conflict('Server already exists.'); throw ApiError.conflict('Server already exists.');
} }
let musicFoldersData: { const serverFolders: {
name: string; name: string;
remoteId: string; remoteId: string;
serverId: number; serverId: string;
}[] = []; }[] = [];
if (options.serverType === 'subsonic') { if (options.type === ServerType.SUBSONIC) {
const musicFoldersRes = await subsonicApi.getMusicFolders({ const serverFoldersRes = await subsonic.api.getMusicFolders({
token: options.token, token: options.token,
url: options.url, url: options.url,
}); });
if (!musicFoldersRes) { if (!serverFoldersRes) {
throw ApiError.badRequest('Server is inaccessible.'); 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({ const server = await prisma.server.create({
data: { data: {
name: options.name, name: options.name,
remoteUserId: options.remoteUserId, remoteUserId: options.remoteUserId,
serverType: options.serverType, serverFolders: { create: serverFoldersCreate },
serverUrls: { create: { url: options.url } },
token: options.token, token: options.token,
type: options.type,
url: options.url, url: options.url,
username: options.username, username: options.username,
}, },
}); });
musicFoldersData = musicFoldersRes.map((musicFolder) => { for (const serverFolder of serverFolders) {
return {
name: musicFolder.name,
remoteId: String(musicFolder.id),
serverId: server.id,
};
});
musicFoldersData.forEach(async (musicFolder) => {
await prisma.serverFolder.upsert({ await prisma.serverFolder.upsert({
create: musicFolder, create: serverFolder,
update: { name: musicFolder.name }, update: { name: serverFolder.name },
where: { where: {
uniqueServerFolderId: { uniqueServerFolderId: {
remoteId: musicFolder.remoteId, remoteId: serverFolder.remoteId,
serverId: musicFolder.serverId, serverId: serverFolder.serverId,
}, },
}, },
}); });
}); }
return ApiSuccess.ok({ data: { ...server } }); return server;
} }
if (options.serverType === 'jellyfin') { if (options.type === ServerType.JELLYFIN) {
const musicFoldersRes = await jellyfinApi.getMusicFolders({ const musicFoldersRes = await jellyfin.api.getMusicFolders({
remoteUserId: options.remoteUserId, remoteUserId: options.remoteUserId,
token: options.token, token: options.token,
url: options.url, url: options.url,
@@ -141,62 +266,72 @@ const create = async (options: {
throw ApiError.badRequest('Server is inaccessible.'); 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({ const server = await prisma.server.create({
data: { data: {
name: options.name, name: options.name,
remoteUserId: options.remoteUserId, remoteUserId: options.remoteUserId,
serverType: options.serverType, serverFolders: { create: serverFoldersCreate },
serverUrls: { create: { url: options.url } }, serverUrls: { create: { url: options.url } },
token: options.token, token: options.token,
type: options.type,
url: options.url, url: options.url,
username: options.username, username: options.username,
}, },
}); });
musicFoldersData = musicFoldersRes.map((musicFolder) => { return server;
return {
name: musicFolder.Name,
remoteId: String(musicFolder.Id),
serverId: server.id,
};
});
musicFoldersData.forEach(async (musicFolder) => {
await prisma.serverFolder.upsert({
create: musicFolder,
update: { name: musicFolder.name },
where: {
uniqueServerFolderId: {
remoteId: musicFolder.remoteId,
serverId: musicFolder.serverId,
},
},
});
});
return ApiSuccess.ok({ data: { ...server } });
} }
return ApiSuccess.ok({ data: {} }); throw ApiError.badRequest('Server type invalid.');
}; };
const refresh = async (options: { id: number }) => { const update = async (
const { id } = options; options: { id: string },
const server = await prisma.server.findUnique({ where: { id } }); 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) { if (!server) {
throw ApiError.notFound(''); throw ApiError.notFound('');
} }
let musicFoldersData: { let serverFolders: {
name: string; name: string;
remoteId: string; remoteId: string;
serverId: number; serverId: string;
}[] = []; }[] = [];
if (server.serverType === 'subsonic') { if (server.type === ServerType.SUBSONIC) {
const musicFoldersRes = await subsonicApi.getMusicFolders(server); const serverFoldersRes = await subsonic.api.getMusicFolders(server);
musicFoldersData = musicFoldersRes.map((musicFolder) => { serverFolders = serverFoldersRes.map((musicFolder) => {
return { return {
name: musicFolder.name, name: musicFolder.name,
remoteId: String(musicFolder.id), remoteId: String(musicFolder.id),
@@ -205,9 +340,9 @@ const refresh = async (options: { id: number }) => {
}); });
} }
if (server.serverType === 'jellyfin') { if (server.type === ServerType.JELLYFIN) {
const musicFoldersRes = await jellyfinApi.getMusicFolders(server); const musicFoldersRes = await jellyfin.api.getMusicFolders(server);
musicFoldersData = musicFoldersRes.map((musicFolder) => { serverFolders = musicFoldersRes.map((musicFolder) => {
return { return {
name: musicFolder.Name, name: musicFolder.Name,
remoteId: String(musicFolder.Id), remoteId: String(musicFolder.Id),
@@ -217,29 +352,24 @@ const refresh = async (options: { id: number }) => {
} }
// mark as deleted if not found // mark as deleted if not found
for (const serverFolder of serverFolders) {
musicFoldersData.forEach(async (musicFolder) => {
await prisma.serverFolder.upsert({ await prisma.serverFolder.upsert({
create: musicFolder, create: serverFolder,
update: { name: musicFolder.name }, update: { name: serverFolder.name },
where: { where: {
uniqueServerFolderId: { uniqueServerFolderId: {
remoteId: musicFolder.remoteId, remoteId: serverFolder.remoteId,
serverId: musicFolder.serverId, serverId: serverFolder.serverId,
}, },
}, },
}); });
}); }
return ApiSuccess.ok({ data: { ...server } }); return server;
}; };
const fullScan = async (options: { const fullScan = async (options: { id: string; serverFolderId?: string[] }) => {
id: number; const { id, serverFolderId } = options;
serverFolderIds?: string;
userId: number;
}) => {
const { id, serverFolderIds } = options;
const server = await prisma.server.findUnique({ const server = await prisma.server.findUnique({
include: { serverFolders: true }, include: { serverFolders: true },
where: { id }, where: { id },
@@ -250,52 +380,183 @@ const fullScan = async (options: {
} }
let serverFolders; let serverFolders;
if (serverFolderIds) { if (serverFolderId) {
const selectedServerFolderIds = splitNumberString(serverFolderIds); serverFolders = server.serverFolders.filter((f) =>
serverFolders = server.serverFolders.filter((folder) => serverFolderId?.includes(f.id)
selectedServerFolderIds?.includes(folder.id)
); );
} else { } else {
serverFolders = server.serverFolders; serverFolders = server.serverFolders;
} }
if (server.serverType === 'jellyfin') { if (serverFolders.length === 0) {
for (const serverFolder of serverFolders) { throw ApiError.notFound('No matching server folders found.');
const task = await prisma.task.create({
data: {
completed: false,
inProgress: true,
name: 'Full scan',
serverFolderId: serverFolder.id,
},
});
await jellyfinTasks.scanAll(server, serverFolder, task);
}
} }
if (server.serverType === 'subsonic') { const task = await prisma.task.create({
for (const serverFolder of serverFolders) { data: {
const task = await prisma.task.create({ completed: false,
data: { name: 'Full scan',
completed: false, server: { connect: { id: server.id } },
inProgress: true, type: TaskType.FULL_SCAN,
name: 'Full scan', },
serverFolderId: serverFolder.id, });
},
});
await subsonicTasks.scanAll(server, serverFolder, task); if (server.type === ServerType.JELLYFIN) {
} await jellyfin.scanner.scanAll(server, serverFolders, task);
} }
return ApiSuccess.ok({ data: {} }); 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 = { export const serversService = {
create, create,
createUrl,
deleteById,
deleteUrlById,
disableUrlById,
enableUrlById,
findById, findById,
findMany, findMany,
findServerUrlById,
findUrlById,
fullScan, fullScan,
refresh, refresh,
remoteServerLogin,
update,
}; };
+30 -36
View File
@@ -1,22 +1,18 @@
import { User } from '@prisma/client';
import { Request } from 'express'; import { Request } from 'express';
import { prisma } from '../lib'; import { prisma } from '../lib';
import { User } from '../types/types'; import { SortOrder } from '../types/types';
import { import { ApiError, ApiSuccess, folderPermissions } from '../utils';
ApiError, // import { toRes } from './response';
ApiSuccess,
folderPermissions,
splitNumberString,
} from '../utils';
import { toRes } from './response';
import { SongRequestParams } from './types'; import { SongRequestParams } from './types';
const findById = async (options: { id: number; user: User }) => { const findById = async (options: { id: string; user: User }) => {
const { id, user } = options; const { id } = options;
const album = await prisma.album.findUnique({ const album = await prisma.album.findUnique({
include: { include: {
_count: true, _count: true,
albumArtist: true, albumArtists: true,
genres: true, genres: true,
songs: { songs: {
include: { include: {
@@ -26,15 +22,16 @@ const findById = async (options: { id: number; user: User }) => {
genres: true, genres: true,
images: true, images: true,
}, },
orderBy: [{ disc: 'asc' }, { track: 'asc' }], orderBy: [
{ discNumber: SortOrder.ASC },
{ trackNumber: SortOrder.ASC },
],
}, },
}, },
where: { id }, where: { id },
}); });
if (!album) { if (!album) throw ApiError.notFound('');
throw ApiError.notFound('');
}
// if (!(await folderPermissions([album?.serverFolderId], user))) { // if (!(await folderPermissions([album?.serverFolderId], user))) {
// throw ApiError.forbidden(''); // throw ApiError.forbidden('');
@@ -49,37 +46,37 @@ const findMany = async (
) => { ) => {
const { const {
albumIds: rawAlbumIds, albumIds: rawAlbumIds,
artistIds: rawArtistIds, // artistIds: rawArtistIds,
serverId,
songIds: rawSongIds, songIds: rawSongIds,
user, user,
skip, skip,
take, take,
serverFolderIds: rServerFolderIds, serverFolderIds: rServerFolderIds,
} = options; } = options;
const serverFolderIds = splitNumberString(rServerFolderIds); const serverFolderIds = rServerFolderIds.split(',');
const albumIds = splitNumberString(rawAlbumIds); const albumIds = rawAlbumIds && rawAlbumIds.split(',');
const artistIds = splitNumberString(rawArtistIds); // const artistIds = rawArtistIds && rawArtistIds.split(',');
const songIds = splitNumberString(rawSongIds); const songIds = rawSongIds && rawSongIds.split(',');
if (serverFolderIds) { if (serverFolderIds) {
if (!(await folderPermissions(serverFolderIds, user))) { if (!(await folderPermissions(serverFolderIds, user)))
throw ApiError.forbidden(''); throw ApiError.forbidden('');
}
} }
// const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => { // const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => {
// return { serverFolders: { id: { equals: serverFolderId } } }; // return { serverFolders: { id: { equals: serverFolderId } } };
// }); // });
const serverFoldersFilter = { // const serverFoldersFilter = {
serverFolders: { some: { id: { in: serverFolderIds } } }, // serverFolders: { some: { id: { in: serverFolderIds } } },
}; // };
const [totalEntries, songs] = await prisma.$transaction([ const [totalEntries, songs] = await prisma.$transaction([
prisma.song.count({ prisma.song.count({
where: { where: {
OR: [ OR: [
serverFoldersFilter, // serverFoldersFilter,
{ {
albumId: { in: albumIds }, albumId: { in: albumIds },
id: { in: songIds }, id: { in: songIds },
@@ -96,19 +93,16 @@ const findMany = async (
}, },
skip, skip,
take, take,
where: { OR: serverFoldersFilter }, where: {
AND: {
// OR: serverFoldersFilter,
serverId,
},
},
}), }),
]); ]);
return ApiSuccess.ok({ return { data: songs, totalEntries };
data: songs,
paginationItems: {
skip,
take,
totalEntries,
url: req.originalUrl,
},
});
}; };
export const songsService = { export const songsService = {
+1
View File
@@ -4,5 +4,6 @@ export interface SongRequestParams extends OffsetPagination {
albumIds?: string; albumIds?: string;
artistIds?: string; artistIds?: string;
serverFolderIds: string; serverFolderIds: string;
serverId: string;
songIds?: string; songIds?: string;
} }
+18 -25
View File
@@ -1,39 +1,32 @@
import { prisma, exclude } from '../lib'; import { prisma } from '../lib';
import { ApiError, ApiSuccess } from '../utils'; import { AuthUser } from '../middleware';
import { ApiError } from '../utils';
const getOne = async (options: { id: number }) => { const findById = async (user: AuthUser, options: { id: string }) => {
const { id } = options; const { id } = options;
const user = await prisma.user.findUnique({
include: { if (!user.isAdmin && user.id !== id) {
serverFolderPermissions: true, throw ApiError.forbidden();
}, }
const uniqueUser = await prisma.user.findUnique({
include: { serverFolderPermissions: true },
where: { id }, where: { id },
}); });
if (!user) { if (!uniqueUser) {
throw ApiError.notFound(''); throw ApiError.notFound('');
} }
return ApiSuccess.ok({ data: exclude(user, 'password') }); return uniqueUser;
}; };
const getMany = async () => { const findMany = async () => {
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({});
select: { return users;
createdAt: true,
enabled: true,
id: true,
isAdmin: true,
serverFolderPermissions: true,
updatedAt: true,
username: true,
},
});
return ApiSuccess.ok({ data: users });
}; };
export const usersService = { export const usersService = {
getMany, findById,
getOne, findMany,
}; };
+1 -9
View File
@@ -1,5 +1,5 @@
import { User } from '@prisma/client';
import { prisma } from '../lib'; import { prisma } from '../lib';
import { User } from '../types/types';
export enum Roles { export enum Roles {
NONE = 0, NONE = 0,
@@ -24,12 +24,8 @@ export const folderPermissions = async (serverFolderIds: any[], user: User) => {
const serverFoldersWithAccess = await prisma.serverFolder.findMany({ const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: { where: {
OR: [ OR: [
{
isPublic: true,
},
{ {
AND: [ AND: [
{ isPublic: false },
{ {
serverFolderPermissions: { serverFolderPermissions: {
some: { userId: { equals: user.id } }, some: { userId: { equals: user.id } },
@@ -66,12 +62,8 @@ export const getFolderPermissions = async (user: User) => {
const serverFoldersWithAccess = await prisma.serverFolder.findMany({ const serverFoldersWithAccess = await prisma.serverFolder.findMany({
where: { where: {
OR: [ OR: [
{
isPublic: true,
},
{ {
AND: [ AND: [
{ isPublic: false },
{ {
serverFolderPermissions: { serverFolderPermissions: {
some: { userId: { equals: user.id } }, some: { userId: { equals: user.id } },
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { paginationValidation, idValidation } from './shared.validation'; import { idValidation, paginationValidation } from './shared.validation';
export const list = { export const list = {
body: z.object({}), body: z.object({}),
+21 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { AlbumSort } from '../helpers/albums.helpers'; import { AlbumSort } from '@helpers/albums.helpers';
import { import {
idValidation, idValidation,
orderByValidation, orderByValidation,
@@ -22,13 +22,32 @@ const list = {
const detail = { const detail = {
body: z.object({}), body: z.object({}),
params: z.object({ ...idValidation('id') }), params: z.object({
...idValidation('albumId'),
...idValidation('serverId'),
}),
query: z.object({ query: z.object({
...serverUrlIdValidation, ...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 = { export const albumsValidation = {
detail, detail,
detailSongList,
list, list,
}; };
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { AlbumSort } from '../helpers/albums.helpers'; import { AlbumSort } from '@helpers/albums.helpers';
import { import {
idValidation, idValidation,
orderByValidation, orderByValidation,
+107 -5
View File
@@ -4,7 +4,32 @@ import { idValidation } from './shared.validation';
const detail = { const detail = {
body: z.object({}), body: z.object({}),
params: z.object({ ...idValidation('id') }), 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({}), query: z.object({}),
}; };
@@ -27,26 +52,103 @@ const create = {
const scan = { const scan = {
body: z.object({ serverFolderId: z.string().array().optional() }), body: z.object({ serverFolderId: z.string().array().optional() }),
params: z.object({ ...idValidation('id') }), params: z.object({ ...idValidation('serverId') }),
query: z.object({}), query: z.object({}),
}; };
const refresh = { const refresh = {
body: z.object({}), body: z.object({}),
params: z.object({ ...idValidation('id') }), params: z.object({ ...idValidation('serverId') }),
query: z.object({}), query: z.object({}),
}; };
const createCredential = { const createCredential = {
body: z.object({ credential: z.string() }), body: z.object({ credential: z.string(), username: z.string() }),
params: z.object({ ...idValidation('id') }), 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({}), query: z.object({}),
}; };
export const serversValidation = { export const serversValidation = {
create, create,
createCredential, createCredential,
createUrl,
deleteCredential,
deleteServer,
deleteUrl,
detail, detail,
disableCredential,
disableUrl,
enableCredential,
enableUrl,
getCredentialDetail,
list,
refresh, refresh,
scan, scan,
update,
}; };
+3 -3
View File
@@ -1,8 +1,8 @@
// Modified from zod-express-middleware: https://github.com/Aquila169/zod-express-middleware
import { Request, RequestHandler } from 'express'; import { Request, RequestHandler } from 'express';
import { z, ZodError, ZodSchema } from 'zod'; import { z, ZodError, ZodSchema } from 'zod';
import { SortOrder } from '../types/types'; import { SortOrder } from '@/types/types';
import { ApiError } from '../utils'; import { ApiError } from '@/utils';
// Modified from zod-express-middleware: https://github.com/Aquila169/zod-express-middleware
export type TypedRequest< export type TypedRequest<
S extends { S extends {