diff --git a/src/server/controllers/album-artists-controller.ts b/src/server/controllers/album-artists.controller.ts similarity index 77% rename from src/server/controllers/album-artists-controller.ts rename to src/server/controllers/album-artists.controller.ts index 8382216..3224ae7 100644 --- a/src/server/controllers/album-artists-controller.ts +++ b/src/server/controllers/album-artists.controller.ts @@ -17,7 +17,7 @@ const getAlbumArtists = async (req: Request, res: Response) => { }); const { limit, page, serverFolderIds } = req.query; - const data = await albumArtistsService.getMany(req, { + const data = await albumArtistsService.findMany(req, { limit: Number(limit), page: Number(page), serverFolderIds: String(serverFolderIds), @@ -27,15 +27,15 @@ const getAlbumArtists = async (req: Request, res: Response) => { return res.status(data.statusCode).json(getSuccessResponse(data)); }; -const getAlbumArtist = async (req: Request, res: Response) => { +const getAlbumArtistById = async (req: Request, res: Response) => { validateRequest(req, { params: z.object({ ...idValidation }) }); const { id } = req.params; - const data = await albumArtistsService.getOne({ + const data = await albumArtistsService.findById({ id: Number(id), user: req.auth, }); return res.status(data.statusCode).json(getSuccessResponse(data)); }; -export const albumArtistsController = { getAlbumArtist, getAlbumArtists }; +export const albumArtistsController = { getAlbumArtistById, getAlbumArtists }; diff --git a/src/server/controllers/albums-controller.ts b/src/server/controllers/albums.controller.ts similarity index 51% rename from src/server/controllers/albums-controller.ts rename to src/server/controllers/albums.controller.ts index 928dac4..92d1207 100644 --- a/src/server/controllers/albums-controller.ts +++ b/src/server/controllers/albums.controller.ts @@ -8,14 +8,20 @@ import { validateRequest, } from '../utils'; -const getAlbum = async (req: Request, res: Response) => { - validateRequest(req, { params: z.object({ ...idValidation }) }); +const getAlbumById = async (req: Request, res: Response) => { + validateRequest(req, { + params: z.object({ ...idValidation }), + query: z.object({ serverUrls: z.optional(z.string().min(1)) }), + }); const { id } = req.params; - const data = await albumsService.getOne({ + 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)); }; @@ -23,15 +29,17 @@ const getAlbums = async (req: Request, res: Response) => { validateRequest(req, { query: z.object({ ...paginationValidation, - serverFolderIds: z.string().min(1), + serverFolderIds: z.optional(z.string().min(1)), + serverUrls: z.optional(z.string().min(1)), }), }); - const { limit, page, serverFolderIds } = req.query; - const data = await albumsService.getMany(req, { + const { limit, page, serverFolderIds, serverUrls } = req.query; + const data = await albumsService.findMany(req, { limit: Number(limit), page: Number(page), - serverFolderIds: String(serverFolderIds), + serverFolderIds: serverFolderIds && String(serverFolderIds), + serverUrls: serverUrls && String(serverUrls), user: req.auth, }); @@ -39,6 +47,6 @@ const getAlbums = async (req: Request, res: Response) => { }; export const albumsController = { - getAlbum, + getAlbumById, getAlbums, }; diff --git a/src/server/controllers/artists-controller.ts b/src/server/controllers/artists.controller.ts similarity index 79% rename from src/server/controllers/artists-controller.ts rename to src/server/controllers/artists.controller.ts index 6a21fa1..7ad478a 100644 --- a/src/server/controllers/artists-controller.ts +++ b/src/server/controllers/artists.controller.ts @@ -8,11 +8,11 @@ import { validateRequest, } from '../utils'; -const getArtist = async (req: Request, res: Response) => { +const getArtistById = async (req: Request, res: Response) => { validateRequest(req, { params: z.object({ ...idValidation }) }); const { id } = req.params; - const data = await artistsService.getOne({ + const data = await artistsService.findById({ id: Number(id), user: req.auth, }); @@ -28,7 +28,7 @@ const getArtists = async (req: Request, res: Response) => { }); const { limit, page, serverFolderIds } = req.query; - const data = await artistsService.getMany(req, { + const data = await artistsService.findMany(req, { limit: Number(limit), page: Number(page), serverFolderIds: String(serverFolderIds), @@ -38,4 +38,4 @@ const getArtists = async (req: Request, res: Response) => { return res.status(data.statusCode).json(getSuccessResponse(data)); }; -export const artistsController = { getArtist, getArtists }; +export const artistsController = { getArtistById, getArtists }; diff --git a/src/server/controllers/auth-controller.ts b/src/server/controllers/auth.controller.ts similarity index 100% rename from src/server/controllers/auth-controller.ts rename to src/server/controllers/auth.controller.ts diff --git a/src/server/controllers/index.ts b/src/server/controllers/index.ts index 092cf70..9fa0563 100644 --- a/src/server/controllers/index.ts +++ b/src/server/controllers/index.ts @@ -1,6 +1,6 @@ -export * from './album-artists-controller'; -export * from './auth-controller'; -export * from './servers-controller'; -export * from './users-controller'; -export * from './artists-controller'; -export * from './albums-controller'; +export * from './album-artists.controller'; +export * from './auth.controller'; +export * from './servers.controller'; +export * from './users.controller'; +export * from './artists.controller'; +export * from './albums.controller'; diff --git a/src/server/controllers/servers-controller.ts b/src/server/controllers/servers.controller.ts similarity index 72% rename from src/server/controllers/servers-controller.ts rename to src/server/controllers/servers.controller.ts index 4e0f0d4..8328390 100644 --- a/src/server/controllers/servers-controller.ts +++ b/src/server/controllers/servers.controller.ts @@ -1,13 +1,14 @@ import { Request, Response } from 'express'; import { z } from 'zod'; +import { prisma } from '../lib'; import { serversService } from '../services'; import { getSuccessResponse, idValidation, validateRequest } from '../utils'; -const getServer = async (req: Request, res: Response) => { +const getServerById = async (req: Request, res: Response) => { validateRequest(req, { params: z.object({ ...idValidation }) }); const { id } = req.params; - const data = await serversService.getOne(req.auth, { + const data = await serversService.findById(req.auth, { id: Number(id), }); @@ -15,22 +16,13 @@ const getServer = async (req: Request, res: Response) => { }; const getServers = async (req: Request, res: Response) => { - const data = await serversService.getMany(req.auth); + const data = await serversService.findMany(req.auth); return res.status(data.statusCode).json(getSuccessResponse(data)); }; const createServer = async (req: Request, res: Response) => { - const { name, url, username, remoteUserId, token, serverType } = req.body; - - const data = await serversService.create({ - name, - remoteUserId, - serverType, - token, - url, - username, - }); + const data = await serversService.create(req.body); return res.status(data.statusCode).json(getSuccessResponse(data)); }; @@ -59,9 +51,21 @@ const scanServer = async (req: Request, res: Response) => { return res.status(data.statusCode).json(getSuccessResponse(data)); }; +const getFolder = async (req: Request, res: Response) => { + const data = await prisma.folder.findUnique({ + include: { + children: true, + }, + where: { id: Number(req.params.id) }, + }); + + return res.status(200).json(getSuccessResponse({ data, statusCode: 200 })); +}; + export const serversController = { createServer, - getServer, + getFolder, + getServerById, getServers, refreshServer, scanServer, diff --git a/src/server/controllers/songs.controller.ts b/src/server/controllers/songs.controller.ts new file mode 100644 index 0000000..fd85ad9 --- /dev/null +++ b/src/server/controllers/songs.controller.ts @@ -0,0 +1,35 @@ +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) => { + validateRequest(req, { + query: z.object({ + ...paginationValidation, + albumIds: z.optional(z.string()), + artistIds: z.optional(z.string()), + serverFolderIds: z.optional(z.string().min(1)), + songIds: z.optional(z.string()), + }), + }); + + const { limit, page, serverFolderIds } = req.query; + + const data = await songsService.findMany(req, { + limit: Number(limit), + page: Number(page), + serverFolderIds: String(serverFolderIds), + user: req.auth, + }); + + return res.status(data.statusCode).json(getSuccessResponse(data)); +}; + +export const songsController = { + getSongs, +}; diff --git a/src/server/controllers/users-controller.ts b/src/server/controllers/users.controller.ts similarity index 100% rename from src/server/controllers/users-controller.ts rename to src/server/controllers/users.controller.ts diff --git a/src/server/routes/album-artists-route.ts b/src/server/routes/album-artists.route.ts similarity index 89% rename from src/server/routes/album-artists-route.ts rename to src/server/routes/album-artists.route.ts index 4006dd1..e1bfd5d 100644 --- a/src/server/routes/album-artists-route.ts +++ b/src/server/routes/album-artists.route.ts @@ -13,5 +13,5 @@ albumArtistsRouter.get( albumArtistsRouter.get( '/:id', authenticateLocal, - albumArtistsController.getAlbumArtist + albumArtistsController.getAlbumArtistById ); diff --git a/src/server/routes/albums-route.ts b/src/server/routes/albums.route.ts similarity index 96% rename from src/server/routes/albums-route.ts rename to src/server/routes/albums.route.ts index 064cb7b..76c8164 100644 --- a/src/server/routes/albums-route.ts +++ b/src/server/routes/albums.route.ts @@ -6,4 +6,4 @@ export const albumsRouter: Router = express.Router(); albumsRouter.get('/', authenticateLocal, albumsController.getAlbums); -albumsRouter.get('/:id', authenticateLocal, albumsController.getAlbum); +albumsRouter.get('/:id', authenticateLocal, albumsController.getAlbumById); diff --git a/src/server/routes/artists-route.ts b/src/server/routes/artists.route.ts similarity index 95% rename from src/server/routes/artists-route.ts rename to src/server/routes/artists.route.ts index ddfb16a..9dfa8b3 100644 --- a/src/server/routes/artists-route.ts +++ b/src/server/routes/artists.route.ts @@ -6,4 +6,4 @@ export const artistsRouter: Router = express.Router(); artistsRouter.get('/', authenticateLocal, artistsController.getArtists); -artistsRouter.get('/:id', authenticateLocal, artistsController.getArtist); +artistsRouter.get('/:id', authenticateLocal, artistsController.getArtistById); diff --git a/src/server/routes/auth-route.ts b/src/server/routes/auth.route.ts similarity index 100% rename from src/server/routes/auth-route.ts rename to src/server/routes/auth.route.ts diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index e43d706..5ac05c7 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -1,11 +1,12 @@ import { Router } from 'express'; -import { albumArtistsRouter } from './album-artists-route'; -import { albumsRouter } from './albums-route'; -import { artistsRouter } from './artists-route'; -import { authRouter } from './auth-route'; -import { serversRouter } from './servers-route'; -import { tasksRouter } from './tasks-route'; -import { usersRouter } from './users-route'; +import { albumArtistsRouter } from './album-artists.route'; +import { albumsRouter } from './albums.route'; +import { artistsRouter } from './artists.route'; +import { authRouter } from './auth.route'; +import { serversRouter } from './servers.route'; +import { songsRouter } from './songs.route'; +import { tasksRouter } from './tasks.route'; +import { usersRouter } from './users.route'; export const routes = Router(); @@ -16,3 +17,4 @@ routes.use('/api/users', usersRouter); routes.use('/api/album-artists', albumArtistsRouter); routes.use('/api/artists', artistsRouter); routes.use('/api/albums', albumsRouter); +routes.use('/api/songs', songsRouter); diff --git a/src/server/routes/servers-route.ts b/src/server/routes/servers.route.ts similarity index 85% rename from src/server/routes/servers-route.ts rename to src/server/routes/servers.route.ts index fb9f35a..c1e7f44 100644 --- a/src/server/routes/servers-route.ts +++ b/src/server/routes/servers.route.ts @@ -6,7 +6,7 @@ export const serversRouter: Router = express.Router(); serversRouter.get('/', authenticateLocal, serversController.getServers); -serversRouter.get('/:id', authenticateLocal, serversController.getServer); +serversRouter.get('/:id', authenticateLocal, serversController.getServerById); serversRouter.get( '/:id/refresh', @@ -14,6 +14,12 @@ serversRouter.get( serversController.refreshServer ); +serversRouter.get( + '/:id/folder', + authenticateAdmin, + serversController.getFolder +); + serversRouter.post('/', authenticateAdmin, serversController.createServer); serversRouter.post( diff --git a/src/server/routes/songs.route.ts b/src/server/routes/songs.route.ts new file mode 100644 index 0000000..6a55cdb --- /dev/null +++ b/src/server/routes/songs.route.ts @@ -0,0 +1,7 @@ +import express, { Router } from 'express'; +import { songsController } from '../controllers/songs.controller'; +import { authenticateLocal } from '../middleware'; + +export const songsRouter: Router = express.Router(); + +songsRouter.get('/', authenticateLocal, songsController.getSongs); diff --git a/src/server/routes/tasks-route.ts b/src/server/routes/tasks.route.ts similarity index 100% rename from src/server/routes/tasks-route.ts rename to src/server/routes/tasks.route.ts diff --git a/src/server/routes/users-route.ts b/src/server/routes/users.route.ts similarity index 100% rename from src/server/routes/users-route.ts rename to src/server/routes/users.route.ts diff --git a/src/server/services/album-artists-service.ts b/src/server/services/album-artists.service.ts similarity index 65% rename from src/server/services/album-artists-service.ts rename to src/server/services/album-artists.service.ts index 06f26ee..32f425c 100644 --- a/src/server/services/album-artists-service.ts +++ b/src/server/services/album-artists.service.ts @@ -4,46 +4,51 @@ import { OffsetPagination, User } from '../types/types'; import { ApiError, ApiSuccess, - hasFolderAccess, + folderPermissions, splitNumberString, } from '../utils'; -const getOne = async (options: { id: number; user: User }) => { +const findById = async (options: { id: number; user: User }) => { const { id, user } = options; - const album = await prisma.albumArtist.findUnique({ + const albumArtist = await prisma.albumArtist.findUnique({ include: { albums: { include: { songs: true } }, genres: true, images: true, + serverFolders: true, }, where: { id }, }); - if (!album) { + if (!albumArtist) { throw ApiError.notFound(''); } - if (!(await hasFolderAccess([album?.serverFolderId], user))) { + const serverFolderIds = albumArtist.serverFolders.map( + (serverFolder) => serverFolder.id + ); + + if (!(await folderPermissions(serverFolderIds, user))) { throw ApiError.forbidden(''); } - return ApiSuccess.ok({ data: album }); + return ApiSuccess.ok({ data: albumArtist }); }; -const getMany = async ( +const findMany = async ( req: Request, options: { serverFolderIds: string; user: User } & OffsetPagination ) => { const { user, limit, page, serverFolderIds: rServerFolderIds } = options; const serverFolderIds = splitNumberString(rServerFolderIds); - if (!(await hasFolderAccess(serverFolderIds, user))) { + if (!(await folderPermissions(serverFolderIds!, user))) { throw ApiError.forbidden(''); } - const serverFoldersFilter = serverFolderIds.map((serverFolderId: number) => { + const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => { return { - serverFolder: { id: { equals: Number(serverFolderId) } }, + serverFolders: { some: { id: { equals: Number(serverFolderId) } } }, }; }); @@ -62,6 +67,7 @@ const getMany = async ( data: albumArtists, paginationItems: { limit, + page, startIndex, totalEntries, url: req.originalUrl, @@ -70,6 +76,6 @@ const getMany = async ( }; export const albumArtistsService = { - getMany, - getOne, + findById, + findMany, }; diff --git a/src/server/services/albums-service.ts b/src/server/services/albums-service.ts deleted file mode 100644 index e400013..0000000 --- a/src/server/services/albums-service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Request } from 'express'; -import { prisma } from '../lib'; -import { OffsetPagination, User } from '../types/types'; -import { - ApiError, - ApiSuccess, - hasFolderAccess, - splitNumberString, -} from '../utils'; - -const getOne = async (options: { id: number; user: User }) => { - const { id, user } = options; - - const album = await prisma.album.findUnique({ - include: { - _count: true, - albumArtist: true, - genres: true, - songs: { - include: { - album: true, - artists: true, - externals: true, - genres: true, - images: true, - }, - orderBy: [{ disc: 'asc' }, { track: 'asc' }], - }, - }, - where: { id }, - }); - - if (!album) { - throw ApiError.notFound(''); - } - - if (!(await hasFolderAccess([album?.serverFolderId], user))) { - throw ApiError.forbidden(''); - } - - return ApiSuccess.ok({ data: album }); -}; - -const getMany = async ( - req: Request, - options: { serverFolderIds: string; user: User } & OffsetPagination -) => { - const { user, limit, page, serverFolderIds: rServerFolderIds } = options; - const serverFolderIds = splitNumberString(rServerFolderIds); - - if (!(await hasFolderAccess(serverFolderIds, user))) { - throw ApiError.forbidden(''); - } - - const serverFoldersFilter = serverFolderIds.map((serverFolderId: number) => { - return { - ServerFolder: { - id: { equals: Number(serverFolderId) }, - }, - }; - }); - - const startIndex = limit * page; - const totalEntries = await prisma.album.count({ - where: { OR: serverFoldersFilter }, - }); - const albums = await prisma.album.findMany({ - include: { - _count: { select: { songs: true } }, - albumArtist: true, - genres: true, - }, - skip: startIndex, - take: limit, - where: { OR: serverFoldersFilter }, - }); - - return ApiSuccess.ok({ - data: albums, - paginationItems: { - limit, - startIndex, - totalEntries, - url: req.originalUrl, - }, - }); -}; - -export const albumsService = { - getMany, - getOne, -}; diff --git a/src/server/services/albums.service.ts b/src/server/services/albums.service.ts new file mode 100644 index 0000000..148a48f --- /dev/null +++ b/src/server/services/albums.service.ts @@ -0,0 +1,148 @@ +import { Request } from 'express'; +import { prisma } from '../lib'; +import { OffsetPagination, User } from '../types/types'; +import { + ApiError, + ApiSuccess, + folderPermissions, + getFolderPermissions, + splitNumberString, +} from '../utils'; +import { toRes } from './response'; + +const findById = async (options: { + id: number; + serverUrls?: string; + user: User; +}) => { + const { id, user, serverUrls } = options; + + const album = await prisma.album.findUnique({ + include: { + _count: true, + albumArtist: true, + genres: true, + images: true, + server: { + include: { + serverUrls: serverUrls + ? { where: { id: { in: splitNumberString(serverUrls) } } } + : true, + }, + }, + serverFolders: true, + songs: { + include: { + album: true, + artists: true, + externals: true, + genres: true, + images: true, + }, + orderBy: [{ disc: 'asc' }, { track: 'asc' }], + }, + }, + where: { id }, + }); + + if (!album) { + throw ApiError.notFound(''); + } + + const serverFolderIds = album.serverFolders.map( + (serverFolder) => serverFolder.id + ); + + if (!(await folderPermissions(serverFolderIds, user))) { + throw ApiError.forbidden(''); + } + + return ApiSuccess.ok({ data: toRes.albums([album], user)[0] }); +}; + +const findMany = async ( + req: Request, + options: { + serverFolderIds?: string; + serverUrls?: string; + user: User; + } & OffsetPagination +) => { + const { + user, + limit, + page, + serverFolderIds: rServerFolderIds, + serverUrls, + } = options; + + const serverFolderIds = rServerFolderIds + ? splitNumberString(rServerFolderIds) + : await getFolderPermissions(user); + + if (!(await folderPermissions(serverFolderIds!, user))) { + throw ApiError.forbidden(''); + } + + const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => { + return { + serverFolders: { + some: { + id: { equals: Number(serverFolderId) }, + }, + }, + }; + }); + + const startIndex = limit * page; + const totalEntries = await prisma.album.count({ + where: { + OR: [...serverFoldersFilter], + }, + }); + + const albums = await prisma.album.findMany({ + include: { + _count: { select: { favorites: true, songs: true } }, + albumArtist: true, + genres: true, + images: true, + server: { + include: { + serverUrls: serverUrls + ? { where: { id: { in: splitNumberString(serverUrls) } } } + : true, + }, + }, + songs: { + include: { + album: true, + artists: true, + externals: true, + genres: true, + images: true, + }, + orderBy: [{ disc: 'asc' }, { track: 'asc' }], + }, + }, + skip: startIndex, + take: limit, + where: { OR: serverFoldersFilter }, + }); + + return ApiSuccess.ok({ + data: toRes.albums(albums, user), + paginationItems: { + limit, + page, + startIndex, + totalEntries, + url: req.originalUrl, + }, + }); +}; + +export const albumsService = { + findById, + findMany, +}; diff --git a/src/server/services/artists-service.ts b/src/server/services/artists.service.ts similarity index 61% rename from src/server/services/artists-service.ts rename to src/server/services/artists.service.ts index b9005c7..1d350a2 100644 --- a/src/server/services/artists-service.ts +++ b/src/server/services/artists.service.ts @@ -4,44 +4,50 @@ import { OffsetPagination, User } from '../types/types'; import { ApiError, ApiSuccess, - hasFolderAccess, + folderPermissions, splitNumberString, } from '../utils'; -const getOne = async (options: { id: number; user: User }) => { +const findById = async (options: { id: number; user: User }) => { const { id, user } = options; - const album = await prisma.artist.findUnique({ - include: { genres: true }, + const artist = await prisma.artist.findUnique({ + include: { genres: true, serverFolders: true }, where: { id }, }); - if (!album) { + if (!artist) { throw ApiError.notFound(''); } - if (!(await hasFolderAccess([album?.serverFolderId], user))) { + const serverFolderIds = artist.serverFolders.map( + (serverFolder) => serverFolder.id + ); + + if (!(await folderPermissions(serverFolderIds, user))) { throw ApiError.forbidden(''); } - return ApiSuccess.ok({ data: album }); + return ApiSuccess.ok({ data: artist }); }; -const getMany = async ( +const findMany = async ( req: Request, options: { serverFolderIds: string; user: User } & OffsetPagination ) => { const { user, limit, page, serverFolderIds: rServerFolderIds } = options; const serverFolderIds = splitNumberString(rServerFolderIds); - if (!(await hasFolderAccess(serverFolderIds, user))) { + if (!(await folderPermissions(serverFolderIds!, user))) { throw ApiError.forbidden(''); } - const serverFoldersFilter = serverFolderIds.map((serverFolderId: number) => { + const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => { return { - ServerFolder: { - id: { equals: Number(serverFolderId) }, + serverFolders: { + some: { + id: { equals: Number(serverFolderId) }, + }, }, }; }); @@ -61,6 +67,7 @@ const getMany = async ( data: artists, paginationItems: { limit, + page, startIndex, totalEntries, url: req.originalUrl, @@ -69,6 +76,6 @@ const getMany = async ( }; export const artistsService = { - getMany, - getOne, + findById, + findMany, }; diff --git a/src/server/services/auth-service.ts b/src/server/services/auth.service.ts similarity index 89% rename from src/server/services/auth-service.ts rename to src/server/services/auth.service.ts index 18f8fa9..99092c7 100644 --- a/src/server/services/auth-service.ts +++ b/src/server/services/auth.service.ts @@ -1,6 +1,6 @@ import bcrypt from 'bcryptjs'; import { prisma } from '../lib'; -import { ApiSuccess } from '../utils'; +import { ApiSuccess, randomString } from '../utils'; import { ApiError } from '../utils/api-error'; const login = async (options: { username: string }) => { @@ -21,6 +21,7 @@ const register = async (options: { password: string; username: string }) => { const hashedPassword = await bcrypt.hash(password, 12); const user = await prisma.user.create({ data: { + deviceId: `${username}_${randomString(10)}`, enabled: false, password: hashedPassword, username, diff --git a/src/server/services/index.ts b/src/server/services/index.ts index 733cbb6..3130596 100644 --- a/src/server/services/index.ts +++ b/src/server/services/index.ts @@ -1,6 +1,6 @@ -export * from './auth-service'; -export * from './servers-service'; -export * from './album-artists-service'; -export * from './users-service'; -export * from './artists-service'; -export * from './albums-service'; +export * from './auth.service'; +export * from './servers.service'; +export * from './album-artists.service'; +export * from './users.service'; +export * from './artists.service'; +export * from './albums.service'; diff --git a/src/server/services/response.ts b/src/server/services/response.ts new file mode 100644 index 0000000..363ea40 --- /dev/null +++ b/src/server/services/response.ts @@ -0,0 +1,197 @@ +/* eslint-disable no-underscore-dangle */ +import { 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 relatedArtists = (items: any[]) => { + return ( + items?.map((item: any) => { + return { + deleted: item.deleted, + id: item.id, + name: item.name, + remoteId: item.remoteId, + }; + }) || [] + ); +}; + +const relatedGenres = (genres: any[]) => { + return ( + genres?.map((genre) => { + return { + id: genre.id, + 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; + 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 { + albumId: item.albumId, + 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, + image: primaryImage(item.images, serverType, url, item.remoteId), + name: item.name, + remoteCreatedAt: item.remoteCreatedAt, + remoteId: item.remoteId, + 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]; + + return { + albumArtistId: item.albumArtistId, + createdAt: item.createdAt, + dateCreated: item.date, + deleted: item.deleted, + genres: relatedGenres(item.genres), + id: item.id, + image: primaryImage(item.images, serverType, url, item.remoteId), + name: item.name, + remoteCreatedAt: item.remoteCreatedAt, + remoteId: item.remoteId, + serverFolderId: item.serverFolderId, + serverType, + songCount: item._count.songs, + songs: songs(item.songs, { + deviceId: user.deviceId, + serverType, + token, + url, + userId: remoteUserId, + }), + updatedAt: item.updatedAt, + year: item.year, + }; + }) || [] + ); +}; + +export const toRes = { + albums, + songs, +}; diff --git a/src/server/services/servers-service.ts b/src/server/services/servers.service.ts similarity index 63% rename from src/server/services/servers-service.ts rename to src/server/services/servers.service.ts index 9748f58..742a75c 100644 --- a/src/server/services/servers-service.ts +++ b/src/server/services/servers.service.ts @@ -8,11 +8,11 @@ import { import { User } from '../types/types'; import { ApiError, ApiSuccess, splitNumberString } from '../utils'; -const getOne = async (user: User, options: { id: number }) => { +const findById = async (user: User, options: { id: number }) => { const { id } = options; const server = await prisma.server.findUnique({ include: { - serverFolder: user.isAdmin + serverFolders: user.isAdmin ? true : { where: { @@ -30,24 +30,24 @@ const getOne = async (user: User, options: { id: number }) => { throw ApiError.notFound(''); } - if (!user.isAdmin && server.serverFolder.length === 0) { + if (!user.isAdmin && server.serverFolders.length === 0) { throw ApiError.forbidden(''); } return ApiSuccess.ok({ data: server }); }; -const getMany = async (user: User) => { +const findMany = async (user: User) => { let servers; if (user.isAdmin) { servers = await prisma.server.findMany({ - include: { serverFolder: true }, + include: { serverFolders: true }, }); } else { servers = await prisma.server.findMany({ include: { - serverFolder: { + serverFolders: { where: { OR: [ { isPublic: true }, @@ -56,7 +56,7 @@ const getMany = async (user: User) => { }, }, }, - where: { serverFolder: { some: { isPublic: true } } }, + where: { serverFolders: { some: { isPublic: true } } }, }); } @@ -71,7 +71,13 @@ const create = async (options: { url: string; username: string; }) => { - const server = await prisma.server.create({ data: options }); + const checkDuplicate = await prisma.server.findUnique({ + where: { url: options.url }, + }); + + if (checkDuplicate) { + throw ApiError.conflict('Server already exists.'); + } let musicFoldersData: { name: string; @@ -80,7 +86,26 @@ const create = async (options: { }[] = []; if (options.serverType === 'subsonic') { - const musicFoldersRes = await subsonicApi.getMusicFolders(server); + const musicFoldersRes = await subsonicApi.getMusicFolders({ + token: options.token, + url: options.url, + }); + + if (!musicFoldersRes) { + throw ApiError.badRequest('Server is inaccessible.'); + } + + const server = await prisma.server.create({ + data: { + name: options.name, + remoteUserId: options.remoteUserId, + serverType: options.serverType, + token: options.token, + url: options.url, + username: options.username, + }, + }); + musicFoldersData = musicFoldersRes.map((musicFolder) => { return { name: musicFolder.name, @@ -88,10 +113,46 @@ const create = async (options: { 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 } }); } if (options.serverType === 'jellyfin') { - const musicFoldersRes = await jellyfinApi.getMusicFolders(server); + const musicFoldersRes = await jellyfinApi.getMusicFolders({ + remoteUserId: options.remoteUserId, + token: options.token, + url: options.url, + }); + + if (!musicFoldersRes) { + throw ApiError.badRequest('Server is inaccessible.'); + } + + const server = await prisma.server.create({ + data: { + name: options.name, + remoteUserId: options.remoteUserId, + serverType: options.serverType, + serverUrls: { create: { url: options.url } }, + token: options.token, + url: options.url, + username: options.username, + }, + }); + musicFoldersData = musicFoldersRes.map((musicFolder) => { return { name: musicFolder.Name, @@ -99,22 +160,24 @@ const create = async (options: { 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 } }); } - 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: {} }); }; const refresh = async (options: { id: number }) => { @@ -153,6 +216,8 @@ const refresh = async (options: { id: number }) => { }); } + // mark as deleted if not found + musicFoldersData.forEach(async (musicFolder) => { await prisma.serverFolder.upsert({ create: musicFolder, @@ -176,7 +241,7 @@ const fullScan = async (options: { }) => { const { id, serverFolderIds } = options; const server = await prisma.server.findUnique({ - include: { serverFolder: true }, + include: { serverFolders: true }, where: { id }, }); @@ -187,11 +252,11 @@ const fullScan = async (options: { let serverFolders; if (serverFolderIds) { const selectedServerFolderIds = splitNumberString(serverFolderIds); - serverFolders = server.serverFolder.filter((folder) => + serverFolders = server.serverFolders.filter((folder) => selectedServerFolderIds?.includes(folder.id) ); } else { - serverFolders = server.serverFolder; + serverFolders = server.serverFolders; } if (server.serverType === 'jellyfin') { @@ -229,8 +294,8 @@ const fullScan = async (options: { export const serversService = { create, + findById, + findMany, fullScan, - getMany, - getOne, refresh, }; diff --git a/src/server/services/songs.service.ts b/src/server/services/songs.service.ts new file mode 100644 index 0000000..4c2b014 --- /dev/null +++ b/src/server/services/songs.service.ts @@ -0,0 +1,120 @@ +import { Request } from 'express'; +import { prisma } from '../lib'; +import { OffsetPagination, User } from '../types/types'; +import { + ApiError, + ApiSuccess, + folderPermissions, + splitNumberString, +} from '../utils'; +import { toRes } from './response'; +import { SongRequestParams } from './types'; + +const findById = async (options: { id: number; user: User }) => { + const { id, user } = options; + + const album = await prisma.album.findUnique({ + include: { + _count: true, + albumArtist: true, + genres: true, + songs: { + include: { + album: true, + artists: true, + externals: true, + genres: true, + images: true, + }, + orderBy: [{ disc: 'asc' }, { track: 'asc' }], + }, + }, + where: { id }, + }); + + if (!album) { + throw ApiError.notFound(''); + } + + // if (!(await folderPermissions([album?.serverFolderId], user))) { + // throw ApiError.forbidden(''); + // } + + return ApiSuccess.ok({ data: album }); +}; + +const findMany = async ( + req: Request, + options: SongRequestParams & { user: User } +) => { + const { + albumIds: rawAlbumIds, + artistIds: rawArtistIds, + songIds: rawSongIds, + user, + limit, + page, + serverFolderIds: rServerFolderIds, + } = options; + const serverFolderIds = splitNumberString(rServerFolderIds); + const albumIds = splitNumberString(rawAlbumIds); + const artistIds = splitNumberString(rawArtistIds); + const songIds = splitNumberString(rawSongIds); + + if (serverFolderIds) { + if (!(await folderPermissions(serverFolderIds, user))) { + throw ApiError.forbidden(''); + } + } + + // const serverFoldersFilter = serverFolderIds!.map((serverFolderId: number) => { + // return { serverFolders: { id: { equals: serverFolderId } } }; + // }); + + const serverFoldersFilter = { + serverFolders: { some: { id: { in: serverFolderIds } } }, + }; + + const startIndex = limit * page; + + const [totalEntries, songs] = await prisma.$transaction([ + prisma.song.count({ + where: { + OR: [ + serverFoldersFilter, + { + albumId: { in: albumIds }, + id: { in: songIds }, + }, + ], + }, + }), + prisma.song.findMany({ + include: { + _count: { select: { favorites: true } }, + genres: true, + images: true, + serverFolders: { include: { server: true } }, + }, + skip: startIndex, + take: limit, + where: { OR: serverFoldersFilter }, + }), + ]); + + return ApiSuccess.ok({ + data: songs, + paginationItems: { + limit, + page, + startIndex, + totalEntries, + url: req.originalUrl, + }, + }); +}; + +export const songsService = { + findById, + findMany, +}; diff --git a/src/server/services/types.ts b/src/server/services/types.ts new file mode 100644 index 0000000..d2f350a --- /dev/null +++ b/src/server/services/types.ts @@ -0,0 +1,8 @@ +import { OffsetPagination } from '../types/types'; + +export interface SongRequestParams extends OffsetPagination { + albumIds?: string; + artistIds?: string; + serverFolderIds: string; + songIds?: string; +} diff --git a/src/server/services/users-service.ts b/src/server/services/users.service.ts similarity index 100% rename from src/server/services/users-service.ts rename to src/server/services/users.service.ts diff --git a/src/server/utils/folder-permissions.ts b/src/server/utils/folder-permissions.ts new file mode 100644 index 0000000..d689d5b --- /dev/null +++ b/src/server/utils/folder-permissions.ts @@ -0,0 +1,91 @@ +import { prisma } from '../lib'; +import { User } from '../types/types'; + +export enum Roles { + NONE = 0, + GUEST = 1, + USER = 2, + ADMIN = 4, + SUPERADMIN = 8, +} + +export enum FolderRoles { + NONE = 0, + READ = 1, + WRITE = 2, + ADMIN = 4, +} + +export const folderPermissions = async (serverFolderIds: any[], user: User) => { + if (user.isAdmin) { + return true; + } + + const serverFoldersWithAccess = await prisma.serverFolder.findMany({ + where: { + OR: [ + { + isPublic: true, + }, + { + AND: [ + { isPublic: false }, + { + serverFolderPermissions: { + some: { userId: { equals: user.id } }, + }, + }, + ], + }, + ], + }, + }); + + const serverFoldersWithAccessIds = serverFoldersWithAccess.map( + (serverFolder) => serverFolder.id + ); + + const hasAccess = serverFolderIds.every((id) => + serverFoldersWithAccessIds.includes(id) + ); + + return hasAccess; +}; + +export const getFolderPermissions = async (user: User) => { + if (user.isAdmin) { + const serverFoldersWithAccess = await prisma.serverFolder.findMany(); + + const serverFoldersWithAccessIds = serverFoldersWithAccess.map( + (serverFolder) => serverFolder.id + ); + + return serverFoldersWithAccessIds; + } + + const serverFoldersWithAccess = await prisma.serverFolder.findMany({ + where: { + OR: [ + { + isPublic: true, + }, + { + AND: [ + { isPublic: false }, + { + serverFolderPermissions: { + some: { userId: { equals: user.id } }, + }, + }, + ], + }, + ], + }, + }); + + const serverFoldersWithAccessIds = serverFoldersWithAccess.map( + (serverFolder) => serverFolder.id + ); + + return serverFoldersWithAccessIds; +}; diff --git a/src/server/utils/has-folder-access.ts b/src/server/utils/has-folder-access.ts deleted file mode 100644 index f4ce664..0000000 --- a/src/server/utils/has-folder-access.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { prisma } from '../lib'; -import { User } from '../types/types'; - -export const hasFolderAccess = async (serverFolderIds: any[], user: User) => { - if (user.isAdmin) { - return true; - } - - const serverFoldersWithAccess = await prisma.serverFolder.findMany({ - where: { - OR: [ - { - isPublic: true, - }, - { - AND: [ - { isPublic: false }, - { - serverFolderPermissions: { - some: { userId: { equals: user.id } }, - }, - }, - ], - }, - ], - }, - }); - - const serverFoldersWithAccessIds = serverFoldersWithAccess.map( - (serverFolder) => serverFolder.id - ); - - const hasAccess = serverFolderIds.every((id) => - serverFoldersWithAccessIds.includes(id) - ); - - return hasAccess; -}; diff --git a/src/server/utils/index.ts b/src/server/utils/index.ts index 4015dae..bf6af1d 100644 --- a/src/server/utils/index.ts +++ b/src/server/utils/index.ts @@ -4,7 +4,7 @@ export * from './get-success-response'; export * from './group-by-property'; export * from './split-number-string'; export * from './split-text-string'; -export * from './has-folder-access'; +export * from './folder-permissions'; export * from './is-array-equal'; export * from './is-json-string'; export * from './validate-request'; diff --git a/src/server/utils/random-string.ts b/src/server/utils/random-string.ts index 67a9699..ccb3fb7 100644 --- a/src/server/utils/random-string.ts +++ b/src/server/utils/random-string.ts @@ -1,8 +1,8 @@ -export const randomString = () => { +export const randomString = (length: number) => { const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let string = ''; - for (let i = 0; i < 12; i += 1) { + for (let i = 0; i < length; i += 1) { const randomPoz = Math.floor(Math.random() * charSet.length); string += charSet.substring(randomPoz, randomPoz + 1); } diff --git a/src/server/utils/split-number-string.ts b/src/server/utils/split-number-string.ts index 6fd1c90..1b64704 100644 --- a/src/server/utils/split-number-string.ts +++ b/src/server/utils/split-number-string.ts @@ -1,4 +1,8 @@ -export const splitNumberString = (string: string, delimiter = ',') => { +export const splitNumberString = (string?: string, delimiter = ',') => { + if (!string) { + return undefined; + } + return string.split(delimiter).map((s: string) => { return Number(s); }); diff --git a/src/server/utils/zod-validation.ts b/src/server/utils/zod-validation.ts index 3ebc800..1950b58 100644 --- a/src/server/utils/zod-validation.ts +++ b/src/server/utils/zod-validation.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const paginationValidation = { limit: z.preprocess( (a) => parseInt(z.string().parse(a), 10), - z.number().max(1000) + z.number().min(0).max(1000) ), page: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number()), };