Started to add Lidarr API and such (WIP)

pull/3800/merge^2
Anatole Sot 4 months ago
parent 30390cb424
commit c53251f93f

@ -0,0 +1,216 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface LidarrMusicOptions {
title: string;
qualityProfileId: number;
tags: number[];
profileId: number;
year: number;
rootFolderPath: string;
mbId: number;
monitored?: boolean;
searchNow?: boolean;
}
export interface LidarrMusic {
id: number;
title: string;
isAvailable: boolean;
monitored: boolean;
mbId: number;
imdbId: string;
titleSlug: string;
folderName: string;
path: string;
profileId: number;
qualityProfileId: number;
added: string;
hasFile: boolean;
}
class LidarrAPI extends ServarrBase<{ musicId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' });
}
public getMusics = async (): Promise<LidarrMusic[]> => {
try {
const response = await this.axios.get<LidarrMusic[]>('/music');
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve musics: ${e.message}`);
}
};
public getMusic = async ({ id }: { id: number }): Promise<LidarrMusic> => {
try {
const response = await this.axios.get<LidarrMusic>(`/music/${id}`);
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve music: ${e.message}`);
}
};
public async getMusicBymbId(id: number): Promise<LidarrMusic> {
try {
const response = await this.axios.get<LidarrMusic[]>('/music/lookup', {
params: {
term: `musicbrainz:${id}`,
},
});
if (!response.data[0]) {
throw new Error('Music not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving music by MUSICBRAINZ ID', {
label: 'Lidarr API',
errorMessage: e.message,
mbId: id,
});
throw new Error('Music not found');
}
}
public addMusic = async (
options: LidarrMusicOptions
): Promise<LidarrMusic> => {
try {
const music = await this.getMusicBymbId(options.mbId);
if (music.hasFile) {
logger.info(
'Title already exists and is available. Skipping add and returning success',
{
label: 'Lidarr',
music,
}
);
return music;
}
// music exists in Lidarr but is neither downloaded nor monitored
if (music.id && !music.monitored) {
const response = await this.axios.put<LidarrMusic>(`/music`, {
...music,
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
titleSlug: options.mbId.toString(),
mbId: options.mbId,
year: options.year,
tags: options.tags,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
searchForMusic: options.searchNow,
},
});
if (response.data.monitored) {
logger.info(
'Found existing title in Lidarr and set it to monitored.',
{
label: 'Lidarr',
musicId: response.data.id,
musicTitle: response.data.title,
}
);
logger.debug('Lidarr update details', {
label: 'Lidarr',
music: response.data,
});
if (options.searchNow) {
this.searchMusic(response.data.id);
}
return response.data;
} else {
logger.error('Failed to update existing music in Lidarr.', {
label: 'Lidarr',
options,
});
throw new Error('Failed to update existing music in Lidarr');
}
}
if (music.id) {
logger.info(
'Music is already monitored in Lidarr. Skipping add and returning success',
{ label: 'Lidarr' }
);
return music;
}
const response = await this.axios.post<LidarrMusic>(`/music`, {
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
titleSlug: options.mbId.toString(),
mbId: options.mbId,
year: options.year,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
tags: options.tags,
addOptions: {
searchForMusic: options.searchNow,
},
});
if (response.data.id) {
logger.info('Lidarr accepted request', { label: 'Lidarr' });
logger.debug('Lidarr add details', {
label: 'Lidarr',
music: response.data,
});
} else {
logger.error('Failed to add music to Lidarr', {
label: 'Lidarr',
options,
});
throw new Error('Failed to add music to Lidarr');
}
return response.data;
} catch (e) {
logger.error(
'Failed to add music to Lidarr. This might happen if the music already exists, in which case you can safely ignore this error.',
{
label: 'Lidarr',
errorMessage: e.message,
options,
response: e?.response?.data,
}
);
throw new Error('Failed to add music to Lidarr');
}
};
public async searchMusic(musicId: number): Promise<void> {
logger.info('Executing music search command', {
label: 'Lidarr API',
musicId,
});
try {
await this.runCommand('MusicsSearch', { musicIds: [musicId] });
} catch (e) {
logger.error(
'Something went wrong while executing Lidarr music search.',
{
label: 'Lidarr API',
errorMessage: e.message,
musicId,
}
);
}
}
}
export default LidarrAPI;

@ -20,6 +20,7 @@ import {
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
import LidarrAPI from '@server/api/servarr/lidarr';
@Entity()
class Media {
@ -72,14 +73,22 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column()
@Column({ nullable: true })
@Index()
public tmdbId: number;
@Column({ unique: true, nullable: true })
@Column({ nullable: true })
@Index()
public mbId: number;
@Column({ nullable: true })
@Index()
public tvdbId?: number;
@Column({ nullable: true })
@Index()
public musicdbId?: number;
@Column({ nullable: true })
@Index()
public imdbId?: string;
@ -253,6 +262,21 @@ class Media {
}
}
}
if (this.mediaType === MediaType.MUSIC) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.lidarr.find(
(lidarr) => lidarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
: LidarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`);
}
}
}
}
@AfterLoad()
@ -308,6 +332,20 @@ class Media {
);
}
}
if (this.mediaType === MediaType.MUSIC) {
if (
this.externalServiceId !== undefined &&
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getMusicProgress(
this.serviceId,
this.externalServiceId
);
}
}
}
}

@ -5,7 +5,10 @@ import type {
SonarrSeries,
} from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { LidarrMusicOptions } from '@server/api/servarr/lidarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb';
import MusicBrainz from '@server/api/musicbrainz';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import {
MediaRequestStatus,
@ -53,6 +56,7 @@ export class MediaRequest {
options: MediaRequestOptions = {}
): Promise<MediaRequest> {
const tmdb = new TheMovieDb();
const musicbrainz = new MusicBrainz();
const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User);
@ -1160,6 +1164,234 @@ export class MediaRequest {
}
}
public async sendToLidarr(): Promise<void> {
if (
this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.MUSIC
) {
try {
const mediaRepository = getRepository(Media);
const settings = getSettings();
if (settings.lidarr.length === 0 && !settings.lidarr[0]) {
logger.info(
'No Lidarr server configured, skipping request processing',
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
let lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
if (
this.serverId !== null &&
this.serverId >= 0 &&
lidarrSettings?.id !== this.serverId
) {
lidarrSettings = settings.lidarr.find(
(lidarr) => lidarr.id === this.serverId
);
logger.info(
`Request has an override server: ${lidarrSettings?.name}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (!lidarrSettings) {
logger.warn(
`There is no default Lidarr server configured. Did you set any of your Lidarr servers as default?`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
let rootFolder = lidarrSettings.activeDirectory;
let qualityProfile = lidarrSettings.activeProfileId;
let tags = lidarrSettings.tags ? [...lidarrSettings.tags] : [];
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== lidarrSettings.activeDirectory
) {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
if (
this.profileId &&
this.profileId !== lidarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(
`Request has an override quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (this.tags && !isEqual(this.tags, lidarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
const musicbrainz = new MusicBrainz();
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v3'),
});
const music = await musicbrainz.getMusic({ mbId: this.media.mbId });
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('Media data not found', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
return;
}
if (lidarrSettings.tagRequests) {
let userTag = (await lidarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await lidarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
lidarrServer: lidarrSettings.hostname + ':' + lidarrSettings.port,
});
}
}
if (
media['status'] === MediaStatus.AVAILABLE
) {
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}
const lidarrMusicOptions: LidarrMusicOptions = {
profileId: qualityProfile,
qualityProfileId: qualityProfile,
rootFolderPath: rootFolder,
title: music.title,
mbId: music.id,
year: Number(music.release_date.slice(0, 4)),
monitored: true,
tags,
searchNow: !lidarrSettings.preventSearch,
};
// Run this asynchronously so we don't wait for it on the UI side
lidarr
.addMusic(lidarrMusicOptions)
.then(async (lidarrMusic) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
throw new Error('Media data not found');
}
media['externalServiceId'] =
lidarrMusic.id;
media['externalServiceSlug'] =
lidarrMusic.titleSlug;
media['serviceId'] = lidarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
logger.warn(
'Something went wrong sending music request to Lidarr, marking status as FAILED',
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
lidarrMusicOptions,
}
);
this.sendNotification(media, Notification.MEDIA_FAILED);
});
logger.info('Sent request to Lidarr', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
} catch (e) {
logger.error('Something went wrong sending request to Lidarr', {
label: 'Media Request',
errorMessage: e.message,
requestId: this.id,
mediaId: this.media.id,
});
throw new Error(e.message);
}
}
}
private async sendNotification(media: Media, type: Notification) {
const tmdb = new TheMovieDb();
@ -1244,6 +1476,25 @@ export class MediaRequest {
},
],
});
} else if (this.type === MediaType.MUSIC) {
const music = await musicbrainz.getMusic({ mbId: media.tmdbId });
notificationManager.sendNotification(type, {
media,
request: this,
notifyAdmin,
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy,
event,
subject: `${music.name}${
music.first_realease_date ? ` (${music.first_realease_date.slice(0, 4)})` : ''
}`,
message: truncate(music.overview, {
length: 500,
separator: /\s/,
omission: '…',
}),
image: `http://coverartarchive.org/${music.type}/${music.mbid}/front-250`, //TODO: Add coverartarchive
});
}
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {

@ -1,5 +1,6 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import { MediaType } from '@server/constants/media';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@ -26,6 +27,7 @@ export interface DownloadingItem {
class DownloadTracker {
private radarrServers: Record<number, DownloadingItem[]> = {};
private sonarrServers: Record<number, DownloadingItem[]> = {};
private lidarrServers: Record<number, DownloadingItem[]> = {};
public getMovieProgress(
serverId: number,
@ -53,6 +55,19 @@ class DownloadTracker {
);
}
public getMusicProgress(
serverId: number,
externalServiceId: number
): DownloadingItem[] {
if (!this.lidarrServers[serverId]) {
return [];
}
return this.lidarrServers[serverId].filter(
(item) => item.externalId === externalServiceId
);
}
public async resetDownloadTracker() {
this.radarrServers = {};
}
@ -60,6 +75,7 @@ class DownloadTracker {
public updateDownloads() {
this.updateRadarrDownloads();
this.updateSonarrDownloads();
this.updateLidarrDownloads();
}
private async updateRadarrDownloads() {
@ -214,6 +230,83 @@ class DownloadTracker {
})
);
}
private async updateLidarrDownloads() {
const settings = getSettings();
// Remove duplicate servers
const filteredServers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
return (
lidarrA.hostname === lidarrB.hostname &&
lidarrA.port === lidarrB.port &&
lidarrA.baseUrl === lidarrB.baseUrl
);
});
// Load downloads from Lidarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const lidarr = new LidarrAPI({
apiKey: server.apiKey,
url: LidarrAPI.buildUrl(server, '/api/v3'),
});
try {
const queueItems = await lidarr.getQueue();
this.lidarrServers[server.id] = queueItems.map((item) => ({
externalId: item.seriesId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.TV,
size: item.size,
sizeLeft: item.sizeleft,
status: item.status,
timeLeft: item.timeleft,
title: item.title,
episode: item.episode,
}));
if (queueItems.length > 0) {
logger.debug(
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
} catch {
logger.error(
`Unable to get queue from Sonarr server: ${server.name}`,
{
label: 'Download Tracker',
}
);
}
// Duplicate this data to matching servers
const matchingServers = settings.lidarr.filter(
(ss) =>
ss.hostname === server.hostname &&
ss.port === server.port &&
ss.baseUrl === server.baseUrl &&
ss.id !== server.id
);
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Sonarr server(s)`,
{ label: 'Download Tracker' }
);
}
matchingServers.forEach((ms) => {
if (ms.syncEnabled) {
this.lidarrServers[ms.id] = this.lidarrServers[server.id];
}
});
}
})
);
}
}
const downloadTracker = new DownloadTracker();

@ -424,6 +424,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'lidarr-scan': {
schedule: '0 0 5 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},

Loading…
Cancel
Save