mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
Move server directory outside of frontend src
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './subsonic';
|
||||
export * from './jellyfin';
|
||||
@@ -0,0 +1,7 @@
|
||||
import { jellyfinApi } from './jellyfin.api';
|
||||
import { jellyfinScanner } from './jellyfin.scanner';
|
||||
|
||||
export const jellyfin = {
|
||||
api: jellyfinApi,
|
||||
scanner: jellyfinScanner,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Server } from '@prisma/client';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
JFAlbumArtistsResponse,
|
||||
JFAlbumsResponse,
|
||||
JFArtistsResponse,
|
||||
JFAuthenticate,
|
||||
JFCollectionType,
|
||||
JFGenreResponse,
|
||||
JFItemType,
|
||||
JFMusicFoldersResponse,
|
||||
JFRequestParams,
|
||||
JFSongsResponse,
|
||||
} from './jellyfin.types';
|
||||
|
||||
export const api = axios.create({});
|
||||
|
||||
export const authenticate = async (options: {
|
||||
password: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
const { password, url, username } = options;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const { data } = await api.post<JFAuthenticate>(
|
||||
`${cleanServerUrl}/users/authenticatebyname`,
|
||||
{ pw: password, username },
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="1.0.0-alpha1"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getMusicFolders = async (server: Partial<Server>) => {
|
||||
const { data } = await api.get<JFMusicFoldersResponse>(
|
||||
`${server.url}/users/${server.remoteUserId}/items`,
|
||||
{ headers: { 'X-MediaBrowser-Token': server.token! } }
|
||||
);
|
||||
|
||||
const musicFolders = data.Items.filter(
|
||||
(folder) => folder.CollectionType === JFCollectionType.MUSIC
|
||||
);
|
||||
|
||||
return musicFolders;
|
||||
};
|
||||
|
||||
export const getGenres = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFGenreResponse>(`${server.url}/genres`, {
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAlbumArtists = async (
|
||||
server: Server,
|
||||
params: JFRequestParams
|
||||
) => {
|
||||
const { data } = await api.get<JFAlbumArtistsResponse>(
|
||||
`${server.url}/artists/albumArtists`,
|
||||
{
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getArtists = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFArtistsResponse>(`${server.url}/artists`, {
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAlbums = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFAlbumsResponse>(
|
||||
`${server.url}/users/${server.remoteUserId}/items`,
|
||||
{
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params: { includeItemTypes: JFItemType.MUSICALBUM, ...params },
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getSongs = async (server: Server, params: JFRequestParams) => {
|
||||
const { data } = await api.get<JFSongsResponse>(
|
||||
`${server.url}/users/${server.remoteUserId}/items`,
|
||||
{
|
||||
headers: { 'X-MediaBrowser-Token': server.token },
|
||||
params: { includeItemTypes: JFItemType.AUDIO, ...params },
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const jellyfinApi = {
|
||||
authenticate,
|
||||
getAlbumArtists,
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getMusicFolders,
|
||||
getSongs,
|
||||
};
|
||||
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
ExternalSource,
|
||||
Folder,
|
||||
ImageType,
|
||||
Server,
|
||||
ServerFolder,
|
||||
Task,
|
||||
} from '@prisma/client';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { prisma } from '../../lib';
|
||||
import { groupByProperty } from '../../utils';
|
||||
import { queue } from '../queues';
|
||||
import { jellyfinApi } from './jellyfin.api';
|
||||
import { JFExternalType, JFImageType, JFItemType } from './jellyfin.types';
|
||||
import { jellyfinUtils } from './jellyfin.utils';
|
||||
|
||||
const scanGenres = async (options: {
|
||||
server: Server;
|
||||
serverFolder: ServerFolder;
|
||||
task: Task;
|
||||
}) => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning genres' },
|
||||
where: { id: options.task.id },
|
||||
});
|
||||
|
||||
const genres = await jellyfinApi.getGenres(options.server, {
|
||||
parentId: options.serverFolder.remoteId,
|
||||
});
|
||||
|
||||
const genresCreate = genres.Items.map((genre) => {
|
||||
return { name: genre.Name };
|
||||
});
|
||||
|
||||
await prisma.genre.createMany({
|
||||
data: genresCreate,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const scanAlbumArtists = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning album artists' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
// TODO: Possibly need to scan without the parentId to get all artists, since Jellyfin may link an album to an artist of a different folder
|
||||
const albumArtists = await jellyfinApi.getAlbumArtists(server, {
|
||||
fields: 'Genres,DateCreated,ExternalUrls,Overview',
|
||||
parentId: serverFolder.remoteId,
|
||||
});
|
||||
|
||||
await jellyfinUtils.insertGenres(albumArtists.Items);
|
||||
await jellyfinUtils.insertImages(albumArtists.Items);
|
||||
await jellyfinUtils.insertExternals(albumArtists.Items);
|
||||
|
||||
for (const albumArtist of albumArtists.Items) {
|
||||
const genresConnect = albumArtist.Genres.map((genre) => ({ name: genre }));
|
||||
|
||||
const imagesConnectOrCreate = [];
|
||||
for (const backdrop of albumArtist.BackdropImageTags) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: backdrop, type: ImageType.BACKDROP },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: backdrop, type: ImageType.BACKDROP },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(albumArtist.ImageTags)) {
|
||||
if (key === JFImageType.PRIMARY) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (key === JFImageType.LOGO) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.LOGO },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const externalsConnect = albumArtist.ExternalUrls.map((external) => ({
|
||||
uniqueExternalId: {
|
||||
source:
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB,
|
||||
value: external.Url.split('/').pop() || '',
|
||||
},
|
||||
}));
|
||||
|
||||
await prisma.albumArtist.upsert({
|
||||
create: {
|
||||
biography: albumArtist.Overview,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: {
|
||||
connectOrCreate: imagesConnectOrCreate,
|
||||
},
|
||||
name: albumArtist.Name,
|
||||
remoteCreatedAt: albumArtist.DateCreated,
|
||||
remoteId: albumArtist.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: albumArtist.Name,
|
||||
},
|
||||
update: {
|
||||
biography: albumArtist.Overview,
|
||||
deleted: false,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: {
|
||||
connectOrCreate: imagesConnectOrCreate,
|
||||
},
|
||||
name: albumArtist.Name,
|
||||
remoteCreatedAt: albumArtist.DateCreated,
|
||||
remoteId: albumArtist.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: albumArtist.Name,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: albumArtist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scanAlbums = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
const check = await jellyfinApi.getAlbums(server, {
|
||||
enableUserData: false,
|
||||
includeItemTypes: JFItemType.MUSICALBUM,
|
||||
limit: 1,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const albumCount = check.TotalRecordCount;
|
||||
const chunkSize = 5000;
|
||||
const albumChunkCount = Math.ceil(albumCount / chunkSize);
|
||||
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning albums' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (let i = 0; i < albumChunkCount; i += 1) {
|
||||
const albums = await jellyfinApi.getAlbums(server, {
|
||||
enableImageTypes: 'Primary,Logo,Backdrop',
|
||||
enableUserData: false,
|
||||
fields: 'Genres,DateCreated,ExternalUrls,Overview',
|
||||
imageTypeLimit: 1,
|
||||
limit: chunkSize,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
startIndex: i * chunkSize,
|
||||
});
|
||||
|
||||
await jellyfinUtils.insertGenres(albums.Items);
|
||||
await jellyfinUtils.insertImages(albums.Items);
|
||||
await jellyfinUtils.insertExternals(albums.Items);
|
||||
|
||||
for (const album of albums.Items) {
|
||||
const genresConnect = album.Genres.map((genre) => ({ name: genre }));
|
||||
|
||||
const imagesConnectOrCreate = [];
|
||||
for (const [key, value] of Object.entries(album.ImageTags)) {
|
||||
if (key === JFImageType.PRIMARY) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (key === JFImageType.LOGO) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: { remoteUrl: value, type: ImageType.LOGO },
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const externalsConnect = album.ExternalUrls.map((external) => ({
|
||||
uniqueExternalId: {
|
||||
source:
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB,
|
||||
value: external.Url.split('/').pop() || '',
|
||||
},
|
||||
}));
|
||||
|
||||
const remoteAlbumArtists = album.AlbumArtists;
|
||||
|
||||
const albumArtists = await prisma.albumArtist.findMany({
|
||||
where: {
|
||||
remoteId: { in: remoteAlbumArtists.map((artist) => artist.Id) },
|
||||
},
|
||||
});
|
||||
|
||||
const albumArtistsConnect = [];
|
||||
for (const albumArtist of remoteAlbumArtists) {
|
||||
const invalid = !albumArtists.find(
|
||||
(artist) => artist.remoteId === albumArtist.Id
|
||||
);
|
||||
|
||||
if (invalid) {
|
||||
// If Jellyfin returns an invalid album artist, we'll just use the first matching one
|
||||
const foundAlternate = await prisma.albumArtist.findFirst({
|
||||
where: {
|
||||
name: albumArtist.Name,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (foundAlternate) {
|
||||
albumArtistsConnect.push({
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: foundAlternate.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
albumArtistsConnect.push({
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: albumArtist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.album.upsert({
|
||||
create: {
|
||||
albumArtists: { connect: albumArtistsConnect },
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: album.Name,
|
||||
releaseDate: album.PremiereDate,
|
||||
releaseYear: album.ProductionYear,
|
||||
remoteCreatedAt: album.DateCreated,
|
||||
remoteId: album.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.Name,
|
||||
},
|
||||
update: {
|
||||
albumArtists: { connect: albumArtistsConnect },
|
||||
deleted: false,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: album.Name,
|
||||
releaseDate: album.PremiereDate,
|
||||
releaseYear: album.ProductionYear,
|
||||
remoteCreatedAt: album.DateCreated,
|
||||
remoteId: album.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.Name,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: album.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scanSongs = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
const check = await jellyfinApi.getSongs(server, {
|
||||
enableUserData: false,
|
||||
limit: 0,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const songCount = check.TotalRecordCount;
|
||||
const chunkSize = 5000;
|
||||
const songChunkCount = Math.ceil(songCount / chunkSize);
|
||||
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning songs' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (let i = 0; i < songChunkCount; i += 1) {
|
||||
const songs = await jellyfinApi.getSongs(server, {
|
||||
enableImageTypes: 'Primary,Logo,Backdrop',
|
||||
enableUserData: false,
|
||||
fields: 'Genres,DateCreated,ExternalUrls,MediaSources,SortName',
|
||||
imageTypeLimit: 1,
|
||||
limit: chunkSize,
|
||||
parentId: serverFolder.remoteId,
|
||||
recursive: true,
|
||||
sortBy: 'DateCreated,Album',
|
||||
sortOrder: 'Descending',
|
||||
startIndex: i * chunkSize,
|
||||
});
|
||||
|
||||
const folderGroups = songs.Items.map((song) => {
|
||||
const songPaths = song.MediaSources[0].Path.split('/');
|
||||
const paths = [];
|
||||
for (let b = 0; b < songPaths.length - 1; b += 1) {
|
||||
paths.push({
|
||||
name: songPaths[b],
|
||||
path: songPaths.slice(0, b + 1).join('/'),
|
||||
});
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const uniqueFolders = uniqBy(
|
||||
folderGroups.flatMap((folder) => folder).filter((f) => f.path !== ''),
|
||||
'path'
|
||||
);
|
||||
|
||||
const createdFolders: Folder[] = [];
|
||||
for (const folder of uniqueFolders) {
|
||||
const createdFolder = await prisma.folder.upsert({
|
||||
create: {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
serverFolders: {
|
||||
connect: {
|
||||
uniqueServerFolderId: {
|
||||
remoteId: serverFolder.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
serverId: server.id,
|
||||
},
|
||||
update: {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
serverFolders: {
|
||||
connect: {
|
||||
uniqueServerFolderId: {
|
||||
remoteId: serverFolder.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
uniqueFolderId: {
|
||||
path: folder.path,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createdFolders.push(createdFolder);
|
||||
}
|
||||
|
||||
for (const folder of createdFolders) {
|
||||
if (folder.parentId) break;
|
||||
|
||||
const pathSplit = folder.path.split('/');
|
||||
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
|
||||
|
||||
const parentPathData = createdFolders.find(
|
||||
(save) => save.path === parentPath
|
||||
);
|
||||
|
||||
if (parentPathData) {
|
||||
await prisma.folder.update({
|
||||
data: {
|
||||
parentId: parentPathData.id,
|
||||
},
|
||||
where: { id: folder.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await jellyfinUtils.insertArtists(server, serverFolder, songs.Items);
|
||||
await jellyfinUtils.insertImages(songs.Items);
|
||||
await jellyfinUtils.insertExternals(songs.Items);
|
||||
|
||||
const albumSongGroups = groupByProperty(songs.Items, 'AlbumId');
|
||||
const keys = Object.keys(albumSongGroups);
|
||||
|
||||
for (const key of keys) {
|
||||
const songGroup = albumSongGroups[key];
|
||||
await jellyfinUtils.insertSongGroup(server, serverFolder, songGroup, key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkDeleted = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
task: Task
|
||||
) => {
|
||||
await prisma.$transaction([
|
||||
prisma.albumArtist.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
prisma.artist.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
prisma.album.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
prisma.song.updateMany({
|
||||
data: { deleted: true },
|
||||
where: {
|
||||
serverFolders: { some: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
updatedAt: { lte: task.createdAt },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
const scanAll = async (
|
||||
server: Server,
|
||||
serverFolders: ServerFolder[],
|
||||
task: Task
|
||||
) => {
|
||||
queue.scanner.push({
|
||||
fn: async () => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Beginning scan...' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (const serverFolder of serverFolders) {
|
||||
await scanGenres({ server, serverFolder, task });
|
||||
await scanAlbumArtists(server, serverFolder, task);
|
||||
await scanAlbums(server, serverFolder, task);
|
||||
await scanSongs(server, serverFolder, task);
|
||||
await checkDeleted(server, serverFolder, task);
|
||||
}
|
||||
|
||||
return { task };
|
||||
},
|
||||
id: task.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const jellyfinScanner = {
|
||||
scanAlbumArtists,
|
||||
scanAlbums,
|
||||
scanAll,
|
||||
scanGenres,
|
||||
scanSongs,
|
||||
};
|
||||
@@ -0,0 +1,404 @@
|
||||
export interface JFBaseResponse {
|
||||
StartIndex: number;
|
||||
TotalRecordCount: number;
|
||||
}
|
||||
|
||||
export interface JFMusicFoldersResponse extends JFBaseResponse {
|
||||
Items: JFMusicFolder[];
|
||||
}
|
||||
|
||||
export interface JFGenreResponse extends JFBaseResponse {
|
||||
Items: JFGenre[];
|
||||
}
|
||||
|
||||
export interface JFAlbumArtistsResponse extends JFBaseResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export interface JFArtistsResponse extends JFBaseResponse {
|
||||
Items: JFAlbumArtist[];
|
||||
}
|
||||
|
||||
export interface JFAlbumsResponse extends JFBaseResponse {
|
||||
Items: JFAlbum[];
|
||||
}
|
||||
|
||||
export interface JFSongsResponse extends JFBaseResponse {
|
||||
Items: JFSong[];
|
||||
}
|
||||
|
||||
export interface JFRequestParams {
|
||||
albumArtistIds?: string;
|
||||
artistIds?: string;
|
||||
enableImageTypes?: string;
|
||||
enableTotalRecordCount?: boolean;
|
||||
enableUserData?: boolean;
|
||||
excludeItemTypes?: string;
|
||||
fields?: string;
|
||||
imageTypeLimit?: number;
|
||||
includeItemTypes?: string;
|
||||
isFavorite?: boolean;
|
||||
limit?: number;
|
||||
parentId?: string;
|
||||
recursive?: boolean;
|
||||
searchTerm?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'Ascending' | 'Descending';
|
||||
startIndex?: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface JFMusicFolder {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
CollectionType: string;
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
UserData: UserData;
|
||||
}
|
||||
|
||||
export interface JFGenre {
|
||||
BackdropImageTags: any[];
|
||||
ChannelId: null;
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: ImageTags;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
export interface JFAlbumArtist {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: ImageTags;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
export interface JFArtist {
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: GenreItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: any;
|
||||
ImageTags: string[];
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
Overview?: string;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
export interface JFAlbum {
|
||||
AlbumArtist: string;
|
||||
AlbumArtists: JFGenericItem[];
|
||||
ArtistItems: JFGenericItem[];
|
||||
Artists: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: JFGenericItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
Name: string;
|
||||
ParentLogoImageTag: string;
|
||||
ParentLogoItemId: string;
|
||||
PremiereDate?: string;
|
||||
ProductionYear: number;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
export interface JFSong {
|
||||
Album: string;
|
||||
AlbumArtist: string;
|
||||
AlbumArtists: JFGenericItem[];
|
||||
AlbumId: string;
|
||||
AlbumPrimaryImageTag: string;
|
||||
ArtistItems: JFGenericItem[];
|
||||
Artists: string[];
|
||||
BackdropImageTags: string[];
|
||||
ChannelId: null;
|
||||
DateCreated: string;
|
||||
ExternalUrls: ExternalURL[];
|
||||
GenreItems: JFGenericItem[];
|
||||
Genres: string[];
|
||||
Id: string;
|
||||
ImageBlurHashes: ImageBlurHashes;
|
||||
ImageTags: ImageTags;
|
||||
IndexNumber: number;
|
||||
IsFolder: boolean;
|
||||
LocationType: string;
|
||||
MediaSources: MediaSources[];
|
||||
MediaType: string;
|
||||
Name: string;
|
||||
ParentIndexNumber: number;
|
||||
PremiereDate?: string;
|
||||
ProductionYear: number;
|
||||
RunTimeTicks: number;
|
||||
ServerId: string;
|
||||
SortName: string;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
interface ImageBlurHashes {
|
||||
Backdrop?: any;
|
||||
Logo?: any;
|
||||
Primary?: any;
|
||||
}
|
||||
|
||||
interface ImageTags {
|
||||
Logo?: string;
|
||||
Primary?: string;
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
IsFavorite: boolean;
|
||||
Key: string;
|
||||
PlayCount: number;
|
||||
PlaybackPositionTicks: number;
|
||||
Played: boolean;
|
||||
}
|
||||
|
||||
interface ExternalURL {
|
||||
Name: string;
|
||||
Url: string;
|
||||
}
|
||||
|
||||
interface GenreItem {
|
||||
Id: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
export interface JFGenericItem {
|
||||
Id: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
interface MediaSources {
|
||||
Bitrate: number;
|
||||
Container: string;
|
||||
DefaultAudioStreamIndex: number;
|
||||
ETag: string;
|
||||
Formats: any[];
|
||||
GenPtsInput: boolean;
|
||||
Id: string;
|
||||
IgnoreDts: boolean;
|
||||
IgnoreIndex: boolean;
|
||||
IsInfiniteStream: boolean;
|
||||
IsRemote: boolean;
|
||||
MediaAttachments: any[];
|
||||
MediaStreams: MediaStream[];
|
||||
Name: string;
|
||||
Path: string;
|
||||
Protocol: string;
|
||||
ReadAtNativeFramerate: boolean;
|
||||
RequiredHttpHeaders: any;
|
||||
RequiresClosing: boolean;
|
||||
RequiresLooping: boolean;
|
||||
RequiresOpening: boolean;
|
||||
RunTimeTicks: number;
|
||||
Size: number;
|
||||
SupportsDirectPlay: boolean;
|
||||
SupportsDirectStream: boolean;
|
||||
SupportsProbing: boolean;
|
||||
SupportsTranscoding: boolean;
|
||||
Type: string;
|
||||
}
|
||||
|
||||
interface MediaStream {
|
||||
AspectRatio?: string;
|
||||
BitDepth?: number;
|
||||
BitRate?: number;
|
||||
ChannelLayout?: string;
|
||||
Channels?: number;
|
||||
Codec: string;
|
||||
CodecTimeBase: string;
|
||||
ColorSpace?: string;
|
||||
Comment?: string;
|
||||
DisplayTitle?: string;
|
||||
Height?: number;
|
||||
Index: number;
|
||||
IsDefault: boolean;
|
||||
IsExternal: boolean;
|
||||
IsForced: boolean;
|
||||
IsInterlaced: boolean;
|
||||
IsTextSubtitleStream: boolean;
|
||||
Level: number;
|
||||
PixelFormat?: string;
|
||||
Profile?: string;
|
||||
RealFrameRate?: number;
|
||||
RefFrames?: number;
|
||||
SampleRate?: number;
|
||||
SupportsExternalStream: boolean;
|
||||
TimeBase: string;
|
||||
Type: string;
|
||||
Width?: number;
|
||||
}
|
||||
|
||||
export enum JFExternalType {
|
||||
MUSICBRAINZ = 'MusicBrainz',
|
||||
THEAUDIODB = 'TheAudioDb',
|
||||
}
|
||||
|
||||
export enum JFImageType {
|
||||
LOGO = 'Logo',
|
||||
PRIMARY = 'Primary',
|
||||
}
|
||||
|
||||
export enum JFItemType {
|
||||
AUDIO = 'Audio',
|
||||
MUSICALBUM = 'MusicAlbum',
|
||||
}
|
||||
|
||||
export enum JFCollectionType {
|
||||
MUSIC = 'music',
|
||||
PLAYLISTS = 'playlists',
|
||||
}
|
||||
|
||||
export interface JFAuthenticate {
|
||||
AccessToken: string;
|
||||
ServerId: string;
|
||||
SessionInfo: SessionInfo;
|
||||
User: User;
|
||||
}
|
||||
|
||||
interface SessionInfo {
|
||||
AdditionalUsers: any[];
|
||||
ApplicationVersion: string;
|
||||
Capabilities: Capabilities;
|
||||
Client: string;
|
||||
DeviceId: string;
|
||||
DeviceName: string;
|
||||
HasCustomDeviceName: boolean;
|
||||
Id: string;
|
||||
IsActive: boolean;
|
||||
LastActivityDate: string;
|
||||
LastPlaybackCheckIn: string;
|
||||
NowPlayingQueue: any[];
|
||||
NowPlayingQueueFullItems: any[];
|
||||
PlayState: PlayState;
|
||||
PlayableMediaTypes: any[];
|
||||
RemoteEndPoint: string;
|
||||
ServerId: string;
|
||||
SupportedCommands: any[];
|
||||
SupportsMediaControl: boolean;
|
||||
SupportsRemoteControl: boolean;
|
||||
UserId: string;
|
||||
UserName: string;
|
||||
}
|
||||
|
||||
interface Capabilities {
|
||||
PlayableMediaTypes: any[];
|
||||
SupportedCommands: any[];
|
||||
SupportsContentUploading: boolean;
|
||||
SupportsMediaControl: boolean;
|
||||
SupportsPersistentIdentifier: boolean;
|
||||
SupportsSync: boolean;
|
||||
}
|
||||
|
||||
interface PlayState {
|
||||
CanSeek: boolean;
|
||||
IsMuted: boolean;
|
||||
IsPaused: boolean;
|
||||
RepeatMode: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
Configuration: Configuration;
|
||||
EnableAutoLogin: boolean;
|
||||
HasConfiguredEasyPassword: boolean;
|
||||
HasConfiguredPassword: boolean;
|
||||
HasPassword: boolean;
|
||||
Id: string;
|
||||
LastActivityDate: string;
|
||||
LastLoginDate: string;
|
||||
Name: string;
|
||||
Policy: Policy;
|
||||
ServerId: string;
|
||||
}
|
||||
|
||||
interface Configuration {
|
||||
DisplayCollectionsView: boolean;
|
||||
DisplayMissingEpisodes: boolean;
|
||||
EnableLocalPassword: boolean;
|
||||
EnableNextEpisodeAutoPlay: boolean;
|
||||
GroupedFolders: any[];
|
||||
HidePlayedInLatest: boolean;
|
||||
LatestItemsExcludes: any[];
|
||||
MyMediaExcludes: any[];
|
||||
OrderedViews: any[];
|
||||
PlayDefaultAudioTrack: boolean;
|
||||
RememberAudioSelections: boolean;
|
||||
RememberSubtitleSelections: boolean;
|
||||
SubtitleLanguagePreference: string;
|
||||
SubtitleMode: string;
|
||||
}
|
||||
|
||||
interface Policy {
|
||||
AccessSchedules: any[];
|
||||
AuthenticationProviderId: string;
|
||||
BlockUnratedItems: any[];
|
||||
BlockedChannels: any[];
|
||||
BlockedMediaFolders: any[];
|
||||
BlockedTags: any[];
|
||||
EnableAllChannels: boolean;
|
||||
EnableAllDevices: boolean;
|
||||
EnableAllFolders: boolean;
|
||||
EnableAudioPlaybackTranscoding: boolean;
|
||||
EnableContentDeletion: boolean;
|
||||
EnableContentDeletionFromFolders: any[];
|
||||
EnableContentDownloading: boolean;
|
||||
EnableLiveTvAccess: boolean;
|
||||
EnableLiveTvManagement: boolean;
|
||||
EnableMediaConversion: boolean;
|
||||
EnableMediaPlayback: boolean;
|
||||
EnablePlaybackRemuxing: boolean;
|
||||
EnablePublicSharing: boolean;
|
||||
EnableRemoteAccess: boolean;
|
||||
EnableRemoteControlOfOtherUsers: boolean;
|
||||
EnableSharedDeviceControl: boolean;
|
||||
EnableSyncTranscoding: boolean;
|
||||
EnableUserPreferenceAccess: boolean;
|
||||
EnableVideoPlaybackTranscoding: boolean;
|
||||
EnabledChannels: any[];
|
||||
EnabledDevices: any[];
|
||||
EnabledFolders: any[];
|
||||
ForceRemoteSourceTranscoding: boolean;
|
||||
InvalidLoginAttemptCount: number;
|
||||
IsAdministrator: boolean;
|
||||
IsDisabled: boolean;
|
||||
IsHidden: boolean;
|
||||
LoginAttemptsBeforeLockout: number;
|
||||
MaxActiveSessions: number;
|
||||
PasswordResetProviderId: string;
|
||||
RemoteClientBitrateLimit: number;
|
||||
SyncPlayAccess: string;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { prisma } from '@lib/prisma';
|
||||
import {
|
||||
ExternalSource,
|
||||
ExternalType,
|
||||
ImageType,
|
||||
Prisma,
|
||||
Server,
|
||||
ServerFolder,
|
||||
} from '@prisma/client';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { uniqueArray } from '../../utils/unique-array';
|
||||
import {
|
||||
JFAlbum,
|
||||
JFAlbumArtist,
|
||||
JFExternalType,
|
||||
JFImageType,
|
||||
JFSong,
|
||||
} from './jellyfin.types';
|
||||
|
||||
const insertGenres = async (items: JFSong[] | JFAlbum[] | JFAlbumArtist[]) => {
|
||||
const genresCreateMany = items
|
||||
.flatMap((item) => item.GenreItems)
|
||||
.map((genre) => ({ name: genre.Name }));
|
||||
|
||||
await prisma.genre.createMany({
|
||||
data: genresCreateMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const insertArtists = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
items: JFSong[] | JFAlbum[]
|
||||
) => {
|
||||
const artistItems = uniqBy(
|
||||
items.flatMap((item) => item.ArtistItems),
|
||||
'Id'
|
||||
);
|
||||
|
||||
const createMany = artistItems.map((artist) => ({
|
||||
name: artist.Name,
|
||||
remoteId: artist.Id,
|
||||
serverId: server.id,
|
||||
sortName: artist.Name,
|
||||
}));
|
||||
|
||||
await prisma.artist.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
for (const artist of artistItems) {
|
||||
await prisma.artist.update({
|
||||
data: { serverFolders: { connect: { id: serverFolder.id } } },
|
||||
where: {
|
||||
uniqueArtistId: {
|
||||
remoteId: artist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const insertImages = async (items: JFSong[] | JFAlbum[] | JFAlbumArtist[]) => {
|
||||
const imageItems = uniqBy(
|
||||
items.flatMap((item) => item.ImageTags),
|
||||
'Id'
|
||||
);
|
||||
|
||||
const createMany: Prisma.ImageCreateManyInput[] = [];
|
||||
|
||||
for (const image of imageItems) {
|
||||
if (image.Logo) {
|
||||
createMany.push({
|
||||
remoteUrl: image.Logo,
|
||||
type: ImageType.LOGO,
|
||||
});
|
||||
}
|
||||
if (image.Primary) {
|
||||
createMany.push({
|
||||
remoteUrl: image.Primary,
|
||||
type: ImageType.PRIMARY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.image.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const insertExternals = async (
|
||||
items: JFSong[] | JFAlbum[] | JFAlbumArtist[]
|
||||
) => {
|
||||
const externalItems = uniqBy(
|
||||
items.flatMap((item) => item.ExternalUrls),
|
||||
'Url'
|
||||
);
|
||||
const createMany: Prisma.ExternalCreateManyInput[] = [];
|
||||
|
||||
for (const external of externalItems) {
|
||||
if (
|
||||
external.Name === JFExternalType.MUSICBRAINZ ||
|
||||
external.Name === JFExternalType.THEAUDIODB
|
||||
) {
|
||||
const source =
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB;
|
||||
|
||||
const value = external.Url.split('/').pop() || '';
|
||||
|
||||
createMany.push({ source, type: ExternalType.ID, value });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.external.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const insertSongGroup = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
songs: JFSong[],
|
||||
remoteAlbumId: string
|
||||
) => {
|
||||
const remoteAlbumArtist =
|
||||
songs[0].AlbumArtists.length > 0 ? songs[0].AlbumArtists[0] : undefined;
|
||||
|
||||
let albumArtist = remoteAlbumArtist?.Id
|
||||
? await prisma.albumArtist.findUnique({
|
||||
where: {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: remoteAlbumArtist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// If Jellyfin returns an invalid album artist, we'll just use the first matching one
|
||||
if (remoteAlbumArtist && !albumArtist) {
|
||||
albumArtist = await prisma.albumArtist.findFirst({
|
||||
where: {
|
||||
name: remoteAlbumArtist?.Name,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const albumArtistId = albumArtist ? albumArtist.id : undefined;
|
||||
|
||||
const songsUpsert: Prisma.SongUpsertWithWhereUniqueWithoutAlbumInput[] =
|
||||
songs.map((song) => {
|
||||
const genresConnect = song.Genres.map((genre) => ({ name: genre }));
|
||||
|
||||
const artistsConnect = song.ArtistItems.map((artist) => ({
|
||||
uniqueArtistId: {
|
||||
remoteId: artist.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
}));
|
||||
|
||||
const externalsConnect = song.ExternalUrls.map((external) => ({
|
||||
uniqueExternalId: {
|
||||
source:
|
||||
external.Name === JFExternalType.MUSICBRAINZ
|
||||
? ExternalSource.MUSICBRAINZ
|
||||
: ExternalSource.THEAUDIODB,
|
||||
value: external.Url.split('/').pop() || '',
|
||||
},
|
||||
}));
|
||||
|
||||
const imagesConnectOrCreate = [];
|
||||
for (const [key, value] of Object.entries(song.ImageTags)) {
|
||||
if (key === JFImageType.PRIMARY) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: {
|
||||
remoteUrl: value,
|
||||
type: ImageType.PRIMARY,
|
||||
},
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.PRIMARY },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (key === JFImageType.LOGO) {
|
||||
imagesConnectOrCreate.push({
|
||||
create: {
|
||||
remoteUrl: value,
|
||||
type: ImageType.LOGO,
|
||||
},
|
||||
where: {
|
||||
uniqueImageId: { remoteUrl: value, type: ImageType.LOGO },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pathSplit = song.MediaSources[0].Path.split('/');
|
||||
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
|
||||
|
||||
return {
|
||||
create: {
|
||||
albumArtistId,
|
||||
artists: { connect: artistsConnect },
|
||||
bitRate: Math.floor(song.MediaSources[0].Bitrate / 1e3),
|
||||
container: song.MediaSources[0].Container,
|
||||
deleted: false,
|
||||
discNumber: song.ParentIndexNumber,
|
||||
duration: Math.floor(song.MediaSources[0].RunTimeTicks / 1e7),
|
||||
externals: { connect: externalsConnect },
|
||||
folders: {
|
||||
connect: {
|
||||
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||
},
|
||||
},
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: song.Name,
|
||||
releaseDate: song.PremiereDate,
|
||||
releaseYear: song.ProductionYear,
|
||||
remoteCreatedAt: song.DateCreated,
|
||||
remoteId: song.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.MediaSources[0].Size,
|
||||
sortName: song.Name,
|
||||
trackNumber: song.IndexNumber,
|
||||
},
|
||||
update: {
|
||||
albumArtistId,
|
||||
artists: { connect: artistsConnect },
|
||||
bitRate: Math.floor(song.MediaSources[0].Bitrate / 1e3),
|
||||
container: song.MediaSources[0].Container,
|
||||
deleted: false,
|
||||
discNumber: song.ParentIndexNumber,
|
||||
duration: Math.floor(song.MediaSources[0].RunTimeTicks / 1e7),
|
||||
externals: { connect: externalsConnect },
|
||||
folders: {
|
||||
connect: {
|
||||
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||
},
|
||||
},
|
||||
genres: { connect: genresConnect },
|
||||
images: { connectOrCreate: imagesConnectOrCreate },
|
||||
name: song.Name,
|
||||
releaseDate: song.PremiereDate,
|
||||
releaseYear: song.ProductionYear,
|
||||
remoteCreatedAt: song.DateCreated,
|
||||
remoteId: song.Id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.MediaSources[0].Size,
|
||||
sortName: song.Name,
|
||||
trackNumber: song.IndexNumber,
|
||||
},
|
||||
where: {
|
||||
uniqueSongId: {
|
||||
remoteId: song.Id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueArtistIds = songs
|
||||
.flatMap((song) => song.ArtistItems.flatMap((artist) => artist.Id))
|
||||
.filter(uniqueArray);
|
||||
|
||||
const artistsConnect = uniqueArtistIds.map((artistId) => ({
|
||||
uniqueArtistId: {
|
||||
remoteId: artistId,
|
||||
serverId: server.id,
|
||||
},
|
||||
}));
|
||||
|
||||
await prisma.album.update({
|
||||
data: {
|
||||
artists: { connect: artistsConnect },
|
||||
deleted: false,
|
||||
songs: { upsert: songsUpsert },
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: remoteAlbumId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const jellyfinUtils = {
|
||||
insertArtists,
|
||||
insertExternals,
|
||||
insertGenres,
|
||||
insertImages,
|
||||
insertSongGroup,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { navidromeApi } from './navidrome.api';
|
||||
import { navidromeScanner } from './navidrome.scanner';
|
||||
|
||||
export const navidrome = {
|
||||
api: navidromeApi,
|
||||
scanner: navidromeScanner,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Server } from '@prisma/client';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
NDAlbumListResponse,
|
||||
NDGenreListResponse,
|
||||
NDAlbumListParams,
|
||||
NDGenreListParams,
|
||||
NDSongListParams,
|
||||
NDSongListResponse,
|
||||
NDArtistListResponse,
|
||||
NDAuthenticate,
|
||||
} from './navidrome.types';
|
||||
|
||||
const api = axios.create();
|
||||
|
||||
const authenticate = async (options: {
|
||||
password: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
const { password, url, username } = options;
|
||||
const cleanServerUrl = url.replace(/\/$/, '');
|
||||
|
||||
const { data } = await api.post<NDAuthenticate>(
|
||||
`${cleanServerUrl}/auth/login`,
|
||||
{ password, username }
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getGenres = async (server: Server, params?: NDGenreListParams) => {
|
||||
const { data } = await api.get<NDGenreListResponse>(
|
||||
`${server.url}/api/genre`,
|
||||
{
|
||||
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getArtists = async (server: Server, params?: NDGenreListParams) => {
|
||||
const { data } = await api.get<NDArtistListResponse>(
|
||||
`${server.url}/api/artist`,
|
||||
{
|
||||
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getAlbums = async (server: Server, params?: NDAlbumListParams) => {
|
||||
const { data } = await api.get<NDAlbumListResponse>(
|
||||
`${server.url}/api/album`,
|
||||
{
|
||||
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
|
||||
params,
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const getSongs = async (server: Server, params?: NDSongListParams) => {
|
||||
const { data } = await api.get<NDSongListResponse>(`${server.url}/api/song`, {
|
||||
headers: { 'x-nd-authorization': `Bearer ${server.token}` },
|
||||
params,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const navidromeApi = {
|
||||
authenticate,
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getSongs,
|
||||
};
|
||||
@@ -0,0 +1,376 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import {
|
||||
ExternalSource,
|
||||
ExternalType,
|
||||
Folder,
|
||||
ImageType,
|
||||
Server,
|
||||
ServerFolder,
|
||||
Task,
|
||||
} from '@prisma/client';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
import { prisma } from '@lib/prisma';
|
||||
import { groupByProperty } from '@utils/group-by-property';
|
||||
import { queue } from '../queues/index';
|
||||
import { navidromeApi } from './navidrome.api';
|
||||
import { navidromeUtils } from './navidrome.utils';
|
||||
|
||||
const CHUNK_SIZE = 5000;
|
||||
|
||||
export const scanGenres = async (server: Server, task: Task) => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning genres' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
const res = await navidromeApi.getGenres(server);
|
||||
|
||||
const genres = res.map((genre) => {
|
||||
return { name: genre.name };
|
||||
});
|
||||
|
||||
await prisma.genre.createMany({
|
||||
data: genres,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const scanAlbumArtists = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder
|
||||
) => {
|
||||
const artists = await navidromeApi.getArtists(server);
|
||||
|
||||
const externalsCreateMany = artists
|
||||
.filter((artist) => artist.mbzArtistId)
|
||||
.map((artist) => ({
|
||||
source: ExternalSource.MUSICBRAINZ,
|
||||
type: ExternalType.ID,
|
||||
value: artist.mbzArtistId,
|
||||
}));
|
||||
|
||||
await prisma.external.createMany({
|
||||
data: externalsCreateMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
for (const artist of artists) {
|
||||
const genresConnect = artist.genres
|
||||
? artist.genres.map((genre) => ({ name: genre.name }))
|
||||
: undefined;
|
||||
|
||||
const externalsConnect = artist.mbzArtistId
|
||||
? {
|
||||
uniqueExternalId: {
|
||||
source: ExternalSource.MUSICBRAINZ,
|
||||
value: artist.mbzArtistId,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await prisma.albumArtist.upsert({
|
||||
create: {
|
||||
deleted: false,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
name: artist.name,
|
||||
remoteId: artist.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: artist.name,
|
||||
},
|
||||
update: {
|
||||
deleted: false,
|
||||
externals: { connect: externalsConnect },
|
||||
genres: { connect: genresConnect },
|
||||
name: artist.name,
|
||||
remoteId: artist.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: artist.name,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: artist.id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const scanAlbums = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder
|
||||
) => {
|
||||
let start = 0;
|
||||
let count = 5000;
|
||||
do {
|
||||
const albums = await navidromeApi.getAlbums(server, {
|
||||
_end: start + CHUNK_SIZE,
|
||||
_start: start,
|
||||
});
|
||||
|
||||
const imagesCreateMany = albums
|
||||
.filter((album) => album.coverArtId)
|
||||
.map((album) => ({
|
||||
remoteUrl: album.coverArtId,
|
||||
type: ImageType.PRIMARY,
|
||||
}));
|
||||
|
||||
await prisma.image.createMany({
|
||||
data: imagesCreateMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
const artistIds = (
|
||||
await prisma.artist.findMany({
|
||||
select: { remoteId: true },
|
||||
where: { serverId: server.id },
|
||||
})
|
||||
).map((artist) => artist.remoteId);
|
||||
|
||||
for (const album of albums) {
|
||||
const imagesConnect = album.coverArtId
|
||||
? {
|
||||
uniqueImageId: {
|
||||
remoteUrl: album.coverArtId,
|
||||
type: ImageType.PRIMARY,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const genresConnect = album.genres
|
||||
? album.genres.map((genre) => ({ name: genre.name }))
|
||||
: undefined;
|
||||
|
||||
const validArtistIds = [];
|
||||
const ndArtistIds = album.allArtistIds.split(' ');
|
||||
|
||||
for (const artistId of ndArtistIds) {
|
||||
if (artistIds.includes(artistId)) {
|
||||
validArtistIds.push(artistId);
|
||||
}
|
||||
}
|
||||
|
||||
const artistsConnect = validArtistIds.map((id) => ({
|
||||
uniqueArtistId: {
|
||||
remoteId: id,
|
||||
serverId: server.id,
|
||||
},
|
||||
}));
|
||||
|
||||
const albumArtistConnect = album.artistId
|
||||
? {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: album.artistId,
|
||||
serverId: server.id,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await prisma.album.upsert({
|
||||
create: {
|
||||
albumArtists: { connect: albumArtistConnect },
|
||||
artists: { connect: artistsConnect },
|
||||
deleted: false,
|
||||
genres: { connect: genresConnect },
|
||||
images: { connect: imagesConnect },
|
||||
name: album.name,
|
||||
releaseDate: album?.minYear
|
||||
? new Date(album.minYear, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: album.minYear,
|
||||
remoteCreatedAt: album.createdAt,
|
||||
remoteId: album.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.name,
|
||||
},
|
||||
update: {
|
||||
albumArtists: { connect: albumArtistConnect },
|
||||
artists: { connect: artistsConnect },
|
||||
deleted: false,
|
||||
genres: { connect: genresConnect },
|
||||
images: { connect: imagesConnect },
|
||||
name: album.name,
|
||||
releaseDate: album?.minYear
|
||||
? new Date(album.minYear, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: album.minYear,
|
||||
remoteCreatedAt: album.createdAt,
|
||||
remoteId: album.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.name,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: album.id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
start += CHUNK_SIZE;
|
||||
count = albums.length;
|
||||
} while (count === CHUNK_SIZE);
|
||||
};
|
||||
|
||||
const scanSongs = async (server: Server, serverFolder: ServerFolder) => {
|
||||
let start = 0;
|
||||
let count = 5000;
|
||||
do {
|
||||
const songs = await navidromeApi.getSongs(server, {
|
||||
_end: start + CHUNK_SIZE,
|
||||
_start: start,
|
||||
});
|
||||
|
||||
const externalsCreateMany = [];
|
||||
const genresCreateMany = [];
|
||||
for (const song of songs) {
|
||||
if (song.mbzTrackId) {
|
||||
externalsCreateMany.push({
|
||||
source: ExternalSource.MUSICBRAINZ,
|
||||
type: ExternalType.ID,
|
||||
value: song.mbzTrackId,
|
||||
});
|
||||
}
|
||||
|
||||
if (song.genres?.length > 0) {
|
||||
genresCreateMany.push(
|
||||
...song.genres.map((genre) => ({ name: genre.name }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.external.createMany({
|
||||
data: externalsCreateMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
await prisma.genre.createMany({
|
||||
data: genresCreateMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
const folderGroups = songs.map((song) => {
|
||||
const songPaths = song.path.split('/');
|
||||
const paths = [];
|
||||
for (let b = 0; b < songPaths.length - 1; b += 1) {
|
||||
paths.push({
|
||||
name: songPaths[b],
|
||||
path: songPaths.slice(0, b + 1).join('/'),
|
||||
});
|
||||
}
|
||||
|
||||
return paths;
|
||||
});
|
||||
|
||||
const uniqueFolders = uniqBy(
|
||||
folderGroups.flatMap((folder) => folder).filter((f) => f.path !== ''),
|
||||
'path'
|
||||
);
|
||||
|
||||
const createdFolders: Folder[] = [];
|
||||
for (const folder of uniqueFolders) {
|
||||
const createdFolder = await prisma.folder.upsert({
|
||||
create: {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
serverFolders: {
|
||||
connect: {
|
||||
uniqueServerFolderId: {
|
||||
remoteId: serverFolder.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
serverId: server.id,
|
||||
},
|
||||
update: {
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
serverFolders: {
|
||||
connect: {
|
||||
uniqueServerFolderId: {
|
||||
remoteId: serverFolder.remoteId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
uniqueFolderId: {
|
||||
path: folder.path,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createdFolders.push(createdFolder);
|
||||
}
|
||||
|
||||
for (const folder of createdFolders) {
|
||||
if (folder.parentId) break;
|
||||
|
||||
const pathSplit = folder.path.split('/');
|
||||
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
|
||||
|
||||
const parentPathData = createdFolders.find(
|
||||
(save) => save.path === parentPath
|
||||
);
|
||||
|
||||
if (parentPathData) {
|
||||
await prisma.folder.update({
|
||||
data: {
|
||||
parentId: parentPathData.id,
|
||||
},
|
||||
where: { id: folder.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const albumSongGroups = groupByProperty(songs, 'albumId');
|
||||
const albumIds = Object.keys(albumSongGroups);
|
||||
|
||||
for (const id of albumIds) {
|
||||
const songGroup = albumSongGroups[id];
|
||||
await navidromeUtils.insertSongGroup(server, serverFolder, songGroup, id);
|
||||
}
|
||||
|
||||
start += CHUNK_SIZE;
|
||||
count = songs.length;
|
||||
} while (count === CHUNK_SIZE);
|
||||
};
|
||||
|
||||
const scanAll = async (
|
||||
server: Server,
|
||||
serverFolders: ServerFolder[],
|
||||
task: Task
|
||||
) => {
|
||||
queue.scanner.push({
|
||||
fn: async () => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Beginning scan...' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (const serverFolder of serverFolders) {
|
||||
await scanGenres(server, task);
|
||||
await scanAlbumArtists(server, serverFolder);
|
||||
await scanAlbums(server, serverFolder);
|
||||
await scanSongs(server, serverFolder);
|
||||
}
|
||||
|
||||
return { task };
|
||||
},
|
||||
id: task.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const navidromeScanner = {
|
||||
scanAll,
|
||||
scanGenres,
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
export type NDAuthenticate = {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
name: string;
|
||||
subsonicSalt: string;
|
||||
subsonicToken: string;
|
||||
token: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type NDGenre = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type NDAlbum = {
|
||||
albumArtist: string;
|
||||
albumArtistId: string;
|
||||
allArtistIds: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
compilation: boolean;
|
||||
coverArtId: string;
|
||||
coverArtPath: string;
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
genre: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
maxYear: number;
|
||||
mbzAlbumArtistId: string;
|
||||
mbzAlbumId: string;
|
||||
minYear: number;
|
||||
name: string;
|
||||
orderAlbumArtistName: string;
|
||||
orderAlbumName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
songCount: number;
|
||||
sortAlbumArtistName: string;
|
||||
sortArtistName: string;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type NDSong = {
|
||||
album: string;
|
||||
albumArtist: string;
|
||||
albumArtistId: string;
|
||||
albumId: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
bitRate: number;
|
||||
bookmarkPosition: number;
|
||||
channels: number;
|
||||
compilation: boolean;
|
||||
createdAt: string;
|
||||
discNumber: number;
|
||||
duration: number;
|
||||
fullText: string;
|
||||
genre: string;
|
||||
genres: NDGenre[];
|
||||
hasCoverArt: boolean;
|
||||
id: string;
|
||||
mbzAlbumArtistId: string;
|
||||
mbzAlbumId: string;
|
||||
mbzArtistId: string;
|
||||
mbzTrackId: string;
|
||||
orderAlbumArtistName: string;
|
||||
orderAlbumName: string;
|
||||
orderArtistName: string;
|
||||
orderTitle: string;
|
||||
path: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
sortAlbumArtistName: string;
|
||||
sortArtistName: string;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
suffix: string;
|
||||
title: string;
|
||||
trackNumber: number;
|
||||
updatedAt: string;
|
||||
year: number;
|
||||
};
|
||||
|
||||
export type NDArtist = {
|
||||
albumCount: number;
|
||||
biography: string;
|
||||
externalInfoUpdatedAt: string;
|
||||
externalUrl: string;
|
||||
fullText: string;
|
||||
genres: NDGenre[];
|
||||
id: string;
|
||||
largeImageUrl: string;
|
||||
mbzArtistId: string;
|
||||
mediumImageUrl: string;
|
||||
name: string;
|
||||
orderArtistName: string;
|
||||
playCount: number;
|
||||
playDate: string;
|
||||
rating: number;
|
||||
size: number;
|
||||
smallImageUrl: string;
|
||||
songCount: number;
|
||||
starred: boolean;
|
||||
starredAt: string;
|
||||
};
|
||||
|
||||
export type NDGenreListResponse = NDGenre[];
|
||||
|
||||
export type NDAlbumListResponse = NDAlbum[];
|
||||
|
||||
export type NDSongListResponse = NDSong[];
|
||||
|
||||
export type NDArtistListResponse = NDArtist[];
|
||||
|
||||
export type NDPagination = {
|
||||
_end?: number;
|
||||
_start?: number;
|
||||
};
|
||||
|
||||
export type NDOrder = {
|
||||
_order?: 'ASC' | 'DESC';
|
||||
};
|
||||
|
||||
export enum NDGenreSort {
|
||||
NAME = 'name',
|
||||
}
|
||||
|
||||
export type NDGenreListParams = {
|
||||
_sort?: NDGenreSort;
|
||||
id?: string;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export enum NDAlbumSort {
|
||||
ARTIST = 'artist',
|
||||
MAX_YEAR = 'max_year',
|
||||
NAME = 'name',
|
||||
RANDOM = 'random',
|
||||
RECENTLY_ADDED = 'recently_added',
|
||||
}
|
||||
|
||||
export type NDAlbumListParams = {
|
||||
_sort?: NDAlbumSort;
|
||||
artist_id?: string;
|
||||
compilation?: boolean;
|
||||
genre_id?: string;
|
||||
has_rating?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
recently_played?: boolean;
|
||||
starred?: boolean;
|
||||
year?: number;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
|
||||
export type NDSongListParams = {
|
||||
genre_id?: string;
|
||||
starred?: boolean;
|
||||
} & NDPagination &
|
||||
NDOrder;
|
||||
@@ -0,0 +1,125 @@
|
||||
import { ExternalSource, Server, ServerFolder } from '@prisma/client';
|
||||
import { prisma } from '@lib/prisma';
|
||||
import { NDSong } from './navidrome.types';
|
||||
|
||||
const insertSongGroup = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder,
|
||||
songs: NDSong[],
|
||||
remoteAlbumId: string
|
||||
) => {
|
||||
const songsWithArtistIds = songs.filter((song) => song.artistId);
|
||||
const artistId =
|
||||
songsWithArtistIds.length > 0 ? songsWithArtistIds[0].artistId : undefined;
|
||||
|
||||
const albumArtist = artistId
|
||||
? await prisma.albumArtist.findUnique({
|
||||
where: {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: artistId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const songsUpsert = songs.map((song) => {
|
||||
const genresConnect = song.genres
|
||||
? song.genres.map((genre) => ({ name: genre.name }))
|
||||
: undefined;
|
||||
|
||||
const externalsConnect = song.mbzTrackId
|
||||
? {
|
||||
uniqueExternalId: {
|
||||
source: ExternalSource.MUSICBRAINZ,
|
||||
value: song.mbzTrackId,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const pathSplit = song.path.split('/');
|
||||
const parentPath = pathSplit.slice(0, pathSplit.length - 1).join('/');
|
||||
|
||||
return {
|
||||
create: {
|
||||
albumArtistId: albumArtist?.id,
|
||||
artistName: !song.artistId ? song.artist : undefined,
|
||||
bitRate: song.bitRate,
|
||||
container: song.suffix,
|
||||
deleted: false,
|
||||
discNumber: song.discNumber,
|
||||
duration: song.duration,
|
||||
externals: { connect: externalsConnect },
|
||||
folders: {
|
||||
connect: {
|
||||
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||
},
|
||||
},
|
||||
genres: { connect: genresConnect },
|
||||
name: song.title,
|
||||
releaseDate: song?.year
|
||||
? new Date(song.year, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: song?.year,
|
||||
remoteCreatedAt: song.createdAt,
|
||||
remoteId: song.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.size,
|
||||
sortName: song.title,
|
||||
trackNumber: song.trackNumber,
|
||||
},
|
||||
update: {
|
||||
albumArtistId: albumArtist?.id,
|
||||
artistName: !song.artistId ? song.artist : undefined,
|
||||
bitRate: song.bitRate,
|
||||
container: song.suffix,
|
||||
deleted: false,
|
||||
discNumber: song.discNumber,
|
||||
duration: song.duration,
|
||||
externals: { connect: externalsConnect },
|
||||
folders: {
|
||||
connect: {
|
||||
uniqueFolderId: { path: parentPath, serverId: server.id },
|
||||
},
|
||||
},
|
||||
genres: { connect: genresConnect },
|
||||
name: song.title,
|
||||
releaseDate: song?.year
|
||||
? new Date(song.year, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: song?.year,
|
||||
remoteCreatedAt: song.createdAt,
|
||||
remoteId: song.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.size,
|
||||
sortName: song.title,
|
||||
trackNumber: song.trackNumber,
|
||||
},
|
||||
where: {
|
||||
uniqueSongId: {
|
||||
remoteId: song.id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.album.update({
|
||||
data: {
|
||||
deleted: false,
|
||||
songs: { upsert: songsUpsert },
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: remoteAlbumId,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const navidromeUtils = {
|
||||
insertSongGroup,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { scannerQueue } from './scanner.queue';
|
||||
|
||||
export const queue = {
|
||||
scanner: scannerQueue,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Task } from '@prisma/client';
|
||||
import Queue from 'better-queue';
|
||||
import { prisma } from '../../lib';
|
||||
|
||||
interface QueueTask {
|
||||
fn: any;
|
||||
id: string;
|
||||
task: Task;
|
||||
}
|
||||
|
||||
export const scannerQueue: Queue = new Queue(
|
||||
async (task: QueueTask, cb: any) => {
|
||||
const result = await task.fn();
|
||||
return cb(null, result);
|
||||
},
|
||||
{
|
||||
afterProcessDelay: 1000,
|
||||
cancelIfRunning: true,
|
||||
concurrent: 1,
|
||||
filo: false,
|
||||
maxRetries: 5,
|
||||
maxTimeout: 600000,
|
||||
retryDelay: 2000,
|
||||
}
|
||||
);
|
||||
|
||||
scannerQueue.on('task_finish', async (taskId) => {
|
||||
await prisma.task.update({
|
||||
data: {
|
||||
completed: true,
|
||||
isError: false,
|
||||
progress: null,
|
||||
},
|
||||
where: { id: taskId },
|
||||
});
|
||||
});
|
||||
|
||||
scannerQueue.on('task_failed', async (taskId, errorMessage) => {
|
||||
const dbTaskId = taskId.split('(')[1].split(')')[0];
|
||||
|
||||
console.log('errorMessage', errorMessage);
|
||||
await prisma.task.update({
|
||||
data: {
|
||||
completed: true,
|
||||
isError: true,
|
||||
message: errorMessage,
|
||||
},
|
||||
where: { id: dbTaskId },
|
||||
});
|
||||
});
|
||||
|
||||
scannerQueue.on('drain', async () => {
|
||||
await prisma.task.updateMany({
|
||||
data: { completed: true, progress: null },
|
||||
where: { completed: false },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { subsonicApi } from './subsonic.api';
|
||||
import { subsonicScanner } from './subsonic.scanner';
|
||||
|
||||
export const subsonic = {
|
||||
api: subsonicApi,
|
||||
scanner: subsonicScanner,
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Server } from '@prisma/client';
|
||||
import { randomString } from '@utils/index';
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import {
|
||||
SSAlbumListEntry,
|
||||
SSAlbumListResponse,
|
||||
SSAlbumResponse,
|
||||
SSAlbumsParams,
|
||||
SSArtistIndex,
|
||||
SSArtistInfoResponse,
|
||||
SSArtistsResponse,
|
||||
SSGenresResponse,
|
||||
SSMusicFoldersResponse,
|
||||
} from './subsonic.types';
|
||||
|
||||
const api = axios.create({
|
||||
validateStatus: (status) => status >= 200,
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res: any) => {
|
||||
res.data = res.data['subsonic-response'];
|
||||
return res;
|
||||
},
|
||||
(err: any) => {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
const authenticate = async (options: {
|
||||
legacy?: boolean;
|
||||
password: string;
|
||||
url: string;
|
||||
username: string;
|
||||
}) => {
|
||||
let token;
|
||||
|
||||
const cleanServerUrl = options.url.replace(/\/$/, '');
|
||||
|
||||
if (options.legacy) {
|
||||
token = `u=${options.username}&p=${options.password}`;
|
||||
} else {
|
||||
const salt = randomString(12);
|
||||
const hash = md5(options.password + salt);
|
||||
token = `u=${options.username}&s=${salt}&t=${hash}`;
|
||||
}
|
||||
|
||||
const { data } = await api.get(
|
||||
`${cleanServerUrl}/rest/ping.view?v=1.13.0&c=Feishin&f=json&${token}`
|
||||
);
|
||||
|
||||
return { token, ...data };
|
||||
};
|
||||
|
||||
const getMusicFolders = async (server: Partial<Server>) => {
|
||||
const { data } = await api.get<SSMusicFoldersResponse>(
|
||||
`${server.url}/rest/getMusicFolders.view?v=1.13.0&c=Feishin&f=json&${server.token}`
|
||||
);
|
||||
|
||||
return data.musicFolders.musicFolder;
|
||||
};
|
||||
|
||||
const getArtists = async (server: Server, musicFolderId: string) => {
|
||||
const { data } = await api.get<SSArtistsResponse>(
|
||||
`${server.url}/rest/getArtists.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||
{ params: { musicFolderId } }
|
||||
);
|
||||
|
||||
const artists = (data.artists?.index || []).flatMap(
|
||||
(index: SSArtistIndex) => index.artist
|
||||
);
|
||||
|
||||
return artists;
|
||||
};
|
||||
|
||||
const getGenres = async (server: Server) => {
|
||||
const { data: genres } = await api.get<SSGenresResponse>(
|
||||
`${server.url}/rest/getGenres.view?v=1.13.0&c=Feishin&f=json&${server.token}`
|
||||
);
|
||||
|
||||
return genres;
|
||||
};
|
||||
|
||||
const getAlbum = async (server: Server, id: string) => {
|
||||
const { data: album } = await api.get<SSAlbumResponse>(
|
||||
`${server.url}/rest/getAlbum.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||
{ params: { id } }
|
||||
);
|
||||
|
||||
return album;
|
||||
};
|
||||
|
||||
const getAlbums = async (
|
||||
server: Server,
|
||||
params: SSAlbumsParams,
|
||||
recursiveData: any[] = []
|
||||
) => {
|
||||
const albums: any = api
|
||||
.get<SSAlbumListResponse>(
|
||||
`${server.url}/rest/getAlbumList2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||
{ params }
|
||||
)
|
||||
.then((res) => {
|
||||
if (
|
||||
!res.data.albumList2.album ||
|
||||
res.data.albumList2.album.length === 0
|
||||
) {
|
||||
// Flatten and return once there are no more albums left
|
||||
return recursiveData.flatMap((album) => album);
|
||||
}
|
||||
|
||||
// On every iteration, push the existing combined album array and increase the offset
|
||||
recursiveData.push(res.data.albumList2.album);
|
||||
return getAlbums(
|
||||
server,
|
||||
{
|
||||
musicFolderId: params.musicFolderId,
|
||||
offset: (params.offset || 0) + (params.size || 0),
|
||||
size: params.size,
|
||||
type: 'newest',
|
||||
},
|
||||
|
||||
recursiveData
|
||||
);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
|
||||
return albums as SSAlbumListEntry[];
|
||||
};
|
||||
|
||||
const getArtistInfo = async (server: Server, id: string) => {
|
||||
const { data: artistInfo } = await api.get<SSArtistInfoResponse>(
|
||||
`${server.url}/rest/getArtistInfo2.view?v=1.13.0&c=Feishin&f=json&${server.token}`,
|
||||
{ params: { id } }
|
||||
);
|
||||
|
||||
return {
|
||||
...artistInfo,
|
||||
artistInfo2: {
|
||||
...artistInfo.artistInfo2,
|
||||
biography: artistInfo.artistInfo2.biography
|
||||
.replaceAll(/<a target.*<\/a>/gm, '')
|
||||
.replace('Biography not available', ''),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const subsonicApi = {
|
||||
authenticate,
|
||||
getAlbum,
|
||||
getAlbums,
|
||||
getArtistInfo,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getMusicFolders,
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { ImageType, Server, ServerFolder, Task } from '@prisma/client';
|
||||
import { prisma, throttle } from '@lib/index';
|
||||
import { uniqueArray } from '@utils/index';
|
||||
import { queue } from '../queues';
|
||||
import { subsonicApi } from './subsonic.api';
|
||||
import { subsonicUtils } from './subsonic.utils';
|
||||
|
||||
export const scanGenres = async (server: Server, task: Task) => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Scanning genres' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
const res = await subsonicApi.getGenres(server);
|
||||
|
||||
const genres = res.genres.genre.map((genre) => {
|
||||
return { name: genre.value };
|
||||
});
|
||||
|
||||
await prisma.genre.createMany({
|
||||
data: genres,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const scanAlbumArtists = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder
|
||||
) => {
|
||||
const artists = await subsonicApi.getArtists(server, serverFolder.remoteId);
|
||||
|
||||
for (const artist of artists) {
|
||||
await prisma.albumArtist.upsert({
|
||||
create: {
|
||||
name: artist.name,
|
||||
remoteId: artist.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: artist.name,
|
||||
},
|
||||
update: {
|
||||
name: artist.name,
|
||||
remoteId: artist.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: artist.name,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: artist.id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const scanAlbums = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder
|
||||
) => {
|
||||
const albums = await subsonicApi.getAlbums(server, {
|
||||
musicFolderId: serverFolder.id,
|
||||
offset: 0,
|
||||
size: 500,
|
||||
type: 'newest',
|
||||
});
|
||||
|
||||
await subsonicUtils.insertImages(albums);
|
||||
|
||||
for (const album of albums) {
|
||||
const imagesConnect = album.coverArt
|
||||
? {
|
||||
uniqueImageId: {
|
||||
remoteUrl: album.coverArt,
|
||||
type: ImageType.PRIMARY,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const albumArtistConnect = album.artistId
|
||||
? {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: album.artistId,
|
||||
serverId: server.id,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await prisma.album.upsert({
|
||||
create: {
|
||||
albumArtists: { connect: albumArtistConnect },
|
||||
genres: { connect: album.genre ? { name: album.genre } : undefined },
|
||||
images: { connect: imagesConnect },
|
||||
name: album.title,
|
||||
releaseDate: album?.year
|
||||
? new Date(album.year, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: album.year,
|
||||
remoteCreatedAt: album.created,
|
||||
remoteId: album.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.title,
|
||||
},
|
||||
update: {
|
||||
albumArtists: { connect: albumArtistConnect },
|
||||
genres: { connect: album.genre ? { name: album.genre } : undefined },
|
||||
images: { connect: imagesConnect },
|
||||
name: album.title,
|
||||
releaseDate: album?.year
|
||||
? new Date(album.year, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: album.year,
|
||||
remoteCreatedAt: album.created,
|
||||
remoteId: album.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
sortName: album.title,
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: album.id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const throttledAlbumFetch = throttle(
|
||||
async (server: Server, serverFolder: ServerFolder, album: any) => {
|
||||
const albumRes = await subsonicApi.getAlbum(server, album.remoteId);
|
||||
|
||||
if (albumRes) {
|
||||
await subsonicUtils.insertSongImages(albumRes);
|
||||
const songsUpsert = albumRes.album.song.map((song) => {
|
||||
const genresConnect = song.genre ? { name: song.genre } : undefined;
|
||||
|
||||
const imagesConnect = song.coverArt
|
||||
? {
|
||||
uniqueImageId: {
|
||||
remoteUrl: song.coverArt,
|
||||
type: ImageType.PRIMARY,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const albumArtistsConnect = song.artistId
|
||||
? {
|
||||
uniqueAlbumArtistId: {
|
||||
remoteId: song.artistId,
|
||||
serverId: server.id,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
create: {
|
||||
albumArtists: { connect: albumArtistsConnect },
|
||||
artistName: !song.artistId ? song.artist : undefined,
|
||||
bitRate: song.bitRate,
|
||||
container: song.suffix,
|
||||
discNumber: song.discNumber,
|
||||
duration: song.duration,
|
||||
genres: { connect: genresConnect },
|
||||
images: { connect: imagesConnect },
|
||||
name: song.title,
|
||||
releaseDate: song?.year
|
||||
? new Date(song.year, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: song.year,
|
||||
remoteCreatedAt: song.created,
|
||||
remoteId: song.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.size,
|
||||
sortName: song.title,
|
||||
trackNumber: song.track,
|
||||
},
|
||||
update: {
|
||||
albumArtists: { connect: albumArtistsConnect },
|
||||
artistName: !song.artistId ? song.artist : undefined,
|
||||
bitRate: song.bitRate,
|
||||
container: song.suffix,
|
||||
discNumber: song.discNumber,
|
||||
duration: song.duration,
|
||||
genres: { connect: genresConnect },
|
||||
images: { connect: imagesConnect },
|
||||
name: song.title,
|
||||
releaseDate: song?.year
|
||||
? new Date(song.year, 0).toISOString()
|
||||
: undefined,
|
||||
releaseYear: song.year,
|
||||
remoteCreatedAt: song.created,
|
||||
remoteId: song.id,
|
||||
serverFolders: { connect: { id: serverFolder.id } },
|
||||
serverId: server.id,
|
||||
size: song.size,
|
||||
sortName: song.title,
|
||||
trackNumber: song.track,
|
||||
},
|
||||
where: {
|
||||
uniqueSongId: {
|
||||
remoteId: song.id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueArtistIds = albumRes.album.song
|
||||
.map((song) => song.artistId)
|
||||
.filter(uniqueArray);
|
||||
|
||||
const artistsConnect = uniqueArtistIds.map((artistId) => {
|
||||
return {
|
||||
uniqueArtistId: {
|
||||
remoteId: artistId!,
|
||||
serverId: server.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.album.update({
|
||||
data: {
|
||||
artists: { connect: artistsConnect },
|
||||
songs: { upsert: songsUpsert },
|
||||
},
|
||||
where: {
|
||||
uniqueAlbumId: {
|
||||
remoteId: albumRes.album.id,
|
||||
serverId: server.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const scanAlbumDetail = async (
|
||||
server: Server,
|
||||
serverFolder: ServerFolder
|
||||
) => {
|
||||
const promises = [];
|
||||
const dbAlbums = await prisma.album.findMany({
|
||||
where: {
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i < dbAlbums.length; i += 1) {
|
||||
promises.push(throttledAlbumFetch(server, serverFolder, dbAlbums[i]));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
const scanAll = async (
|
||||
server: Server,
|
||||
serverFolders: ServerFolder[],
|
||||
task: Task
|
||||
) => {
|
||||
queue.scanner.push({
|
||||
fn: async () => {
|
||||
await prisma.task.update({
|
||||
data: { message: 'Beginning scan...' },
|
||||
where: { id: task.id },
|
||||
});
|
||||
|
||||
for (const serverFolder of serverFolders) {
|
||||
await scanGenres(server, task);
|
||||
await scanAlbumArtists(server, serverFolder);
|
||||
await scanAlbums(server, serverFolder);
|
||||
await scanAlbumDetail(server, serverFolder);
|
||||
}
|
||||
|
||||
return { task };
|
||||
},
|
||||
id: task.id,
|
||||
});
|
||||
};
|
||||
|
||||
export const subsonicScanner = {
|
||||
scanAll,
|
||||
scanGenres,
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
export interface SSBaseResponse {
|
||||
serverVersion?: 'string';
|
||||
status: 'string';
|
||||
type?: 'string';
|
||||
version: 'string';
|
||||
}
|
||||
|
||||
export interface SSMusicFoldersResponse extends SSBaseResponse {
|
||||
musicFolders: {
|
||||
musicFolder: SSMusicFolder[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSGenresResponse extends SSBaseResponse {
|
||||
genres: {
|
||||
genre: SSGenre[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSArtistsResponse extends SSBaseResponse {
|
||||
artists: {
|
||||
ignoredArticles: string;
|
||||
index: SSArtistIndex[];
|
||||
lastModified: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSAlbumListResponse extends SSBaseResponse {
|
||||
albumList2: {
|
||||
album: SSAlbumListEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSAlbumResponse extends SSBaseResponse {
|
||||
album: SSAlbum;
|
||||
}
|
||||
|
||||
export interface SSArtistInfoResponse extends SSBaseResponse {
|
||||
artistInfo2: SSArtistInfo;
|
||||
}
|
||||
|
||||
export interface SSArtistInfo {
|
||||
biography: string;
|
||||
largeImageUrl?: string;
|
||||
lastFmUrl?: string;
|
||||
mediumImageUrl?: string;
|
||||
musicBrainzId?: string;
|
||||
smallImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface SSMusicFolder {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SSGenre {
|
||||
albumCount?: number;
|
||||
songCount?: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SSArtistIndex {
|
||||
artist: SSArtistListEntry[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SSArtistListEntry {
|
||||
albumCount: string;
|
||||
artistImageUrl?: string;
|
||||
coverArt?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SSAlbumListEntry {
|
||||
album: string;
|
||||
artist: string;
|
||||
artistId: string;
|
||||
coverArt: string;
|
||||
created: string;
|
||||
duration: number;
|
||||
genre?: string;
|
||||
id: string;
|
||||
isDir: boolean;
|
||||
isVideo: boolean;
|
||||
name: string;
|
||||
parent: string;
|
||||
songCount: number;
|
||||
starred?: boolean;
|
||||
title: string;
|
||||
userRating?: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface SSAlbum extends SSAlbumListEntry {
|
||||
song: SSSong[];
|
||||
}
|
||||
|
||||
export interface SSSong {
|
||||
album: string;
|
||||
albumId: string;
|
||||
artist: string;
|
||||
artistId?: string;
|
||||
bitRate: number;
|
||||
contentType: string;
|
||||
coverArt: string;
|
||||
created: string;
|
||||
discNumber?: number;
|
||||
duration: number;
|
||||
genre: string;
|
||||
id: string;
|
||||
isDir: boolean;
|
||||
isVideo: boolean;
|
||||
parent: string;
|
||||
path: string;
|
||||
playCount: number;
|
||||
size: number;
|
||||
starred?: boolean;
|
||||
suffix: string;
|
||||
title: string;
|
||||
track: number;
|
||||
type: string;
|
||||
userRating?: number;
|
||||
year: number;
|
||||
}
|
||||
|
||||
export interface SSAlbumsParams {
|
||||
fromYear?: number;
|
||||
genre?: string;
|
||||
musicFolderId?: string;
|
||||
offset?: number;
|
||||
size?: number;
|
||||
toYear?: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SSArtistsParams {
|
||||
musicFolderId?: number;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ImageType } from '@prisma/client';
|
||||
import { prisma } from '../../lib';
|
||||
import { SSAlbumListEntry, SSAlbumResponse } from './subsonic.types';
|
||||
|
||||
const insertImages = async (items: SSAlbumListEntry[]) => {
|
||||
const createMany = items
|
||||
.filter((item) => item.coverArt)
|
||||
.map((item) => ({
|
||||
remoteUrl: item.coverArt,
|
||||
type: ImageType.PRIMARY,
|
||||
}));
|
||||
|
||||
await prisma.image.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
const insertSongImages = async (item: SSAlbumResponse) => {
|
||||
const createMany = item.album.song
|
||||
.filter((song) => song.coverArt)
|
||||
.map((song) => ({
|
||||
remoteUrl: song.coverArt,
|
||||
type: ImageType.PRIMARY,
|
||||
}));
|
||||
|
||||
await prisma.image.createMany({
|
||||
data: createMany,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const subsonicUtils = {
|
||||
insertImages,
|
||||
insertSongImages,
|
||||
};
|
||||
Reference in New Issue
Block a user