Move server directory outside of frontend src

This commit is contained in:
jeffvli
2022-10-25 16:52:45 -07:00
parent 863dce88b7
commit 0438f2d5f2
105 changed files with 16946 additions and 6901 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from './subsonic';
export * from './jellyfin';
+7
View File
@@ -0,0 +1,7 @@
import { jellyfinApi } from './jellyfin.api';
import { jellyfinScanner } from './jellyfin.scanner';
export const jellyfin = {
api: jellyfinApi,
scanner: jellyfinScanner,
};
+117
View File
@@ -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,
};
+495
View File
@@ -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,
};
+404
View File
@@ -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;
}
+304
View File
@@ -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,
};
+7
View File
@@ -0,0 +1,7 @@
import { navidromeApi } from './navidrome.api';
import { navidromeScanner } from './navidrome.scanner';
export const navidrome = {
api: navidromeApi,
scanner: navidromeScanner,
};
+83
View File
@@ -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,
};
+376
View File
@@ -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,
};
+169
View File
@@ -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;
+125
View File
@@ -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,
};
+5
View File
@@ -0,0 +1,5 @@
import { scannerQueue } from './scanner.queue';
export const queue = {
scanner: scannerQueue,
};
+57
View File
@@ -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 },
});
});
+7
View File
@@ -0,0 +1,7 @@
import { subsonicApi } from './subsonic.api';
import { subsonicScanner } from './subsonic.scanner';
export const subsonic = {
api: subsonicApi,
scanner: subsonicScanner,
};
+157
View File
@@ -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,
};
+288
View File
@@ -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,
};
+139
View File
@@ -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;
}
+36
View File
@@ -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,
};