feat(API): Added MusicBrainz API support

pull/3800/head
Anatole Sot 3 months ago committed by Anatole Sot
parent 666cf3949c
commit ad3707e065

@ -64,6 +64,7 @@
"node-cache": "5.1.2",
"node-gyp": "^8.0.0",
"node-schedule": "2.1.1",
"nodebrainz": "^2.1.1",
"nodemailer": "6.9.1",
"openpgp": "5.7.0",
"plex-api": "5.3.2",

File diff suppressed because one or more lines are too long

@ -0,0 +1,569 @@
import { BaseNodeBrainz } from 'nodebrainz';
import type { mbArtist, mbRecording, mbReleaseGroup, mbRelease, mbWork} from './interfaces';
import {mbArtistType, mbReleaseGroupType, mbWorkType} from './interfaces';
interface SearchOptions {
query: string;
page?: number;
limit?: number;
keywords?: string;
artistname?: string;
albumname?: string;
recordingname?: string;
tag?: string;
}
interface ArtistSearchOptions {
query: string;
tag?: string; // (part of) a tag attached to the artist
limit?: number;
offset?: number;
}
interface RecordingSearchOptions {
query: string;
tag?: string; // (part of) a tag attached to the recording
artistname?: string; // (part of) the name of any of the recording artists
release?: string; // the name of a release that the recording appears on
offset?: number;
limit?: number;
}
interface ReleaseSearchOptions {
query: string;
artistname?: string; // (part of) the name of any of the release artists
tag?: string; // (part of) a tag attached to the release
limit?: number;
offset?: number;
}
interface ReleaseGroupSearchOptions {
query: string;
artistname?: string; // (part of) the name of any of the release group artists
tag?: string; // (part of) a tag attached to the release group
limit?: number;
offset?: number;
}
interface WorkSearchOptions {
query: string;
artist?: string; // (part of) the name of an artist related to the work (e.g. a composer or lyricist)
tag?: string; // (part of) a tag attached to the work
limit?: number;
offset?: number;
}
interface Tag {
name: string;
count: number;
}
interface Area {
"sort-name": string
"type-id": string
"iso-3166-1-codes": string[]
type: string
disambiguation: string
name: string
id: string
}
interface Media {
position: number
"track-count": number
format: string
"format-id": string
title: string
}
interface ReleaseEvent {
area: Area
date: string
}
interface RawArtist {
"sort-name": string
disambiguation: string
id: string
name: string
"type-id": string
type: string
}
interface RawRecording {
length: number
video: boolean
title: string
id: string
disambiguation: string
tags: Tag[]
}
interface RawReleaseGroup {
tags: Tag[],
"primary-type": string
"secondary-types": string[]
disambiguation: string
"first-release-date": string
"secondary-type-ids": string[]
releases: any[]
"primary-type-id": string
id: string
title: string
}
interface RawRelease {
barcode: string
tags: Tag[]
disambiguation: string
packaging: string
"packaging-id": string
"release-events": ReleaseEvent[]
title: string
status: string
"text-representation": {
language: string
script: string
}
"status-id": string
"release-group": any
country: string
quality: string
date: string
id: string
media: Media[]
}
interface RawWork {
disambiguation: string
attributes: any[]
id: string
"type-id": string
languages: string[]
type: string
tags: Tag[]
iswcs: string[]
title: string
language: string
}
function searchOptionstoArtistSearchOptions(options: SearchOptions): ArtistSearchOptions {
const data : ArtistSearchOptions = {
query: options.query
}
if (options.tag) {
data.tag = options.tag;
}
if (options.limit) {
data.limit = options.limit;
}
else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
}
return data;
}
function searchOptionstoRecordingSearchOptions(options: SearchOptions): RecordingSearchOptions {
const data : RecordingSearchOptions = {
query: options.query
}
if (options.tag) {
data.tag = options.tag;
}
if (options.artistname) {
data.artistname = options.artistname;
}
if (options.albumname) {
data.release = options.albumname;
}
if (options.limit) {
data.limit = options.limit;
}
else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
}
return data;
}
function searchOptionstoReleaseSearchOptions(options: SearchOptions): ReleaseSearchOptions {
const data : ReleaseSearchOptions = {
query: options.query
}
if (options.artistname) {
data.artistname = options.artistname;
}
if (options.tag) {
data.tag = options.tag;
}
if (options.limit) {
data.limit = options.limit;
}
else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
}
return data;
}
function searchOptionstoReleaseGroupSearchOptions(options: SearchOptions): ReleaseGroupSearchOptions {
const data : ReleaseGroupSearchOptions = {
query: options.query
}
if (options.artistname) {
data.artistname = options.artistname;
}
if (options.tag) {
data.tag = options.tag;
}
if (options.limit) {
data.limit = options.limit;
}
else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
}
return data;
}
function searchOptionstoWorkSearchOptions(options: SearchOptions): WorkSearchOptions {
const data : WorkSearchOptions = {
query: options.query
}
if (options.artistname) {
data.artist = options.artistname;
}
if (options.tag) {
data.tag = options.tag;
}
if (options.limit) {
data.limit = options.limit;
}
else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
}
return data;
}
class MusicBrainz extends BaseNodeBrainz {
constructor() {
super({userAgent:'Overseer-with-lidar-support/0.0.1 ( https://github.com/ano0002/overseerr )'});
}
public searchMulti = async (search: SearchOptions) => {
try {
const artistSearch = searchOptionstoArtistSearchOptions(search);
const recordingSearch = searchOptionstoRecordingSearchOptions(search);
const releaseGroupSearch = searchOptionstoReleaseGroupSearchOptions(search);
const releaseSearch = searchOptionstoReleaseSearchOptions(search);
const workSearch = searchOptionstoWorkSearchOptions(search);
const artistResults = await this.searchArtists(artistSearch);
const recordingResults = await this.searchRecordings(recordingSearch);
const releaseGroupResults = await this.searchReleaseGroups(releaseGroupSearch);
const releaseResults = await this.searchReleases(releaseSearch);
const workResults = await this.searchWorks(workSearch);
const combinedResults = {
status: 'ok',
artistResults,
recordingResults,
releaseGroupResults,
releaseResults,
workResults
};
return combinedResults;
} catch (e) {
return {
status: 'error',
artistResults: [],
recordingResults: [],
releaseGroupResults: [],
releaseResults: [],
workResults: []
};
}
};
public searchArtists = async (search: ArtistSearchOptions) => {
try {
const results = await this.search('artist', search);
return results;
} catch (e) {
return [];
}
};
public searchRecordings = async (search: RecordingSearchOptions) => {
try {
const results = await this.search('recording', search);
return results;
} catch (e) {
return [];
}
};
public searchReleaseGroups = async (search: ReleaseGroupSearchOptions) => {
try {
const results = await this.search('release-group', search);
return results;
} catch (e) {
return [];
}
};
public searchReleases = async (search: ReleaseSearchOptions) => {
try {
const results = await this.search('release', search);
return results;
} catch (e) {
return [];
}
};
public searchWorks = async (search: WorkSearchOptions) => {
try {
const results = await this.search('work', search);
return results;
} catch (e) {
return [];
}
};
public getArtist = async (artistId : string): Promise<mbArtist> => {
try {
const rawData = this.artist(artistId, {inc: 'tags+recordings+releases+release-groups+works'});
const artist : mbArtist = {
id: rawData.id,
name: rawData.name,
sortName: rawData["sort-name"],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
recordings: rawData.recordings.map((recording: RawRecording): mbRecording => {
return {
id: recording.id,
artist: [{
id: rawData.id,
name: rawData.name,
sortName: rawData["sort-name"],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name)
}],
title: recording.title,
length: recording.length,
tags: recording.tags.map((tag: Tag) => tag.name),
}
}),
releases: rawData.releases.map((release: RawRelease): mbRelease => {
return {
id: release.id,
artist: [{
id: rawData.id,
name: rawData.name,
sortName: rawData["sort-name"],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name)
}],
title: release.title,
date: new Date(release.date),
tags: release.tags.map((tag: Tag) => tag.name),
}
}),
releaseGroups: rawData["release-groups"].map((releaseGroup: RawReleaseGroup): mbReleaseGroup => {
return {
id: releaseGroup.id,
artist: [{
id: rawData.id,
name: rawData.name,
sortName: rawData["sort-name"],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name)
}],
title: releaseGroup.title,
type: (releaseGroup["primary-type"] as mbReleaseGroupType) || mbReleaseGroupType.OTHER,
firstReleased: new Date(releaseGroup["first-release-date"]),
tags: releaseGroup.tags.map((tag: Tag) => tag.name),
}
}),
works: rawData.works.map((work: RawWork): mbWork => {
return {
id: work.id,
title: work.title,
type: (work.type as mbWorkType) || mbWorkType.OTHER,
artist: [{
id: rawData.id,
name: rawData.name,
sortName: rawData["sort-name"],
type: (rawData.type as mbArtistType) || mbArtistType.OTHER,
tags: rawData.tags.map((tag: Tag) => tag.name)
}],
tags: work.tags.map((tag: Tag) => tag.name),
}
}),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
return artist;
} catch (e) {
throw new Error(`[MusicBrainz] Failed to fetch artist details: ${e.message}`);
}
};
public getRecording = async (recordingId : string): Promise<mbRecording> => {
try {
const rawData = this.recording(recordingId, {inc: 'tags+artists+releases'});
const recording : mbRecording = {
id: rawData.id,
title: rawData.title,
artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist["sort-name"],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER
}
}),
length: rawData.length,
firstReleased: new Date(rawData["first-release-date"]),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
return recording;
} catch (e) {
throw new Error(`[MusicBrainz] Failed to fetch recording details: ${e.message}`);
}
};
public async getReleaseGroup(releaseGroupId : string): Promise<mbReleaseGroup> {
try {
const rawData = this.releaseGroup(releaseGroupId, {inc: 'tags+artists+releases'});
const releaseGroup : mbReleaseGroup = {
id: rawData.id,
title: rawData.title,
artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist["sort-name"],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER
}
}),
type: (rawData["primary-type"] as mbReleaseGroupType) || mbReleaseGroupType.OTHER,
firstReleased: new Date(rawData["first-release-date"]),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
return releaseGroup;
} catch (e) {
throw new Error(`[MusicBrainz] Failed to fetch release group details: ${e.message}`);
}
};
public async getRelease(releaseId : string): Promise<mbRelease> {
try {
const rawData = this.release(releaseId, {inc: 'tags+artists+recordings'});
const release : mbRelease = {
id: rawData.id,
title: rawData.title,
artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist["sort-name"],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER
}
}),
date: new Date(rawData["release-events"][0].date),
tracks: rawData.media.map((media: {
"track-count": number
title: string
format: string
position: number
"track-offset": number
tracks: {
title: string
position: number
id: string
length: number
recording: {
disambiguation: string
"first-release-date": string
title: string
id: string
length: number
tags: Tag[]
video: boolean
}
number: string
}[];
"format-id": string
}) => {
return media.tracks.map((track: {
title: string
position: number
id: string
length: number
recording: {
disambiguation: string
"first-release-date": string
title: string
id: string
length: number
tags: Tag[]
video: boolean
}
number: string
}) => {
return {
id: track.id,
title: track.title,
length: track.recording.length,
tags: track.recording.tags.map((tag: Tag) => tag.name),
}
})
}).flat(),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
return release;
} catch (e) {
throw new Error(`[MusicBrainz] Failed to fetch release details: ${e.message}`);
}
};
public async getWork(workId : string): Promise<mbWork> {
try {
const rawData = this.work(workId, {inc: 'tags+artist-rels'});
const work : mbWork = {
id: rawData.id,
title: rawData.title,
type: (rawData.type as mbWorkType) || mbWorkType.OTHER,
artist: rawData.relations.map((relation: {artist: RawArtist}) => {
return {
id: relation.artist.id,
name: relation.artist.name,
sortName: relation.artist["sort-name"],
type: (relation.artist.type as mbArtistType) || mbArtistType.OTHER
}
}),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
return work;
} catch (e) {
throw new Error(`[MusicBrainz] Failed to fetch work details: ${e.message}`);
}
};
}
export default MusicBrainz;

@ -0,0 +1,105 @@
// Purpose: Interfaces for MusicBrainz data.
export enum mbArtistType {
PERSON = 'Person',
GROUP = 'Group',
ORCHESTRA = 'Orchestra',
CHOIR = 'Choir',
CHARACTER = 'Character',
OTHER = 'Other',
};
export interface mbArtist {
id: string;
name: string;
sortName: string;
type: mbArtistType;
recordings?: mbRecording[];
releases?: mbRelease[];
releaseGroups?: mbReleaseGroup[];
works?: mbWork[];
gender?: string;
area?: string;
beginDate?: string;
endDate?: string;
tags: string[];
};
export interface mbRecording {
id: string;
title: string;
artist: mbArtist[];
length: number;
firstReleased?: Date;
tags: string[];
};
export interface mbRelease {
id: string;
title: string;
artist: mbArtist[];
date?: Date;
tracks?: mbRecording[];
tags: string[];
};
export enum mbReleaseGroupType {
ALBUM = 'Album',
SINGLE = 'Single',
EP = 'EP',
BROADCAST = 'Broadcast',
OTHER = 'Other',
};
export interface mbReleaseGroup {
id: string;
title: string;
artist: mbArtist[];
type: mbReleaseGroupType;
firstReleased?: Date;
releases?: mbRelease[];
tags: string[];
};
export enum mbWorkType {
ARIA = 'Aria',
BALLET = 'Ballet',
CANTATA = 'Cantata',
CONCERTO = 'Concerto',
SONATA = 'Sonata',
SUITE = 'Suite',
MADRIGAL = 'Madrigal',
MASS = 'Mass',
MOTET = 'Motet',
OPERA = 'Opera',
ORATORIO = 'Oratorio',
OVERTURE = 'Overture',
PARTITA = 'Partita',
QUARTET = 'Quartet',
SONG_CYCLE = 'Song-cycle',
SYMPHONY = 'Symphony',
SONG = 'Song',
SYMPHONIC_POEM = 'Symphonic poem',
ZARZUELA = 'Zarzuela',
ETUDE = 'Étude',
POEM = 'Poem',
SOUNDTRACK = 'Soundtrack',
PROSE = 'Prose',
OPERETTA = 'Operetta',
AUDIO_DRAMA = 'Audio drama',
BEIJING_OPERA = 'Beijing opera',
PLAY = 'Play',
MUSICAL = 'Musical',
INCIDENTAL_MUSIC = 'Incidental music',
OTHER = 'Other',
};
export interface mbWork {
id: string;
title: string;
type: mbWorkType;
artist: mbArtist[];
tags: string[];
};

@ -1,7 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { AvailableCacheIds } from '@server/lib/cache';
import cacheManager from '@server/lib/cache';
import type { DVRSettings } from '@server/lib/settings';
import type { ArrSettings } from '@server/lib/settings';
export interface SystemStatus {
version: string;
@ -79,7 +79,7 @@ interface QueueResponse<QueueItemAppendT> {
}
class ServarrBase<QueueItemAppendT> extends ExternalAPI {
static buildUrl(settings: DVRSettings, path?: string): string {
static buildUrl(settings: ArrSettings, path?: string): string {
return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port
}${settings.baseUrl ?? ''}${path}`;

@ -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;

@ -8,6 +8,7 @@ export enum MediaRequestStatus {
export enum MediaType {
MOVIE = 'movie',
TV = 'tv',
MUSIC = 'music',
}
export enum MediaStatus {

@ -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)', {

@ -2,13 +2,15 @@ import NodeCache from 'node-cache';
export type AvailableCacheIds =
| 'tmdb'
| 'musicbrainz'
| 'radarr'
| 'sonarr'
| 'rt'
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv';
| 'plextv'
| 'lidarr';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@ -46,8 +48,13 @@ class CacheManager {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
musicbrainz: new Cache('musicbrainz', 'MusicBrainz API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
radarr: new Cache('radarr', 'Radarr API'),
sonarr: new Cache('sonarr', 'Sonarr API'),
lidarr: new Cache('lidarr', 'Lidarr API'),
rt: new Cache('rt', 'Rotten Tomatoes API', {
stdTtl: 43200,
checkPeriod: 60 * 30,

@ -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();

@ -44,7 +44,7 @@ export interface TautulliSettings {
externalUrl?: string;
}
export interface DVRSettings {
export interface ArrSettings {
id: number;
name: string;
hostname: string;
@ -56,7 +56,6 @@ export interface DVRSettings {
activeProfileName: string;
activeDirectory: string;
tags: number[];
is4k: boolean;
isDefault: boolean;
externalUrl?: string;
syncEnabled: boolean;
@ -64,6 +63,10 @@ export interface DVRSettings {
tagRequests: boolean;
}
export interface DVRSettings extends ArrSettings {
is4k: boolean;
}
export interface RadarrSettings extends DVRSettings {
minimumAvailability: string;
}
@ -80,6 +83,7 @@ export interface SonarrSettings extends DVRSettings {
enableSeasonFolders: boolean;
}
interface Quota {
quotaLimit?: number;
quotaDays?: number;
@ -95,6 +99,7 @@ export interface MainSettings {
defaultQuotas: {
movie: Quota;
tv: Quota;
music: Quota;
};
hideAvailable: boolean;
localLogin: boolean;
@ -250,6 +255,7 @@ export type JobId =
| 'plex-watchlist-sync'
| 'radarr-scan'
| 'sonarr-scan'
| 'lidarr-scan'
| 'download-sync'
| 'download-sync-reset'
| 'image-cache-cleanup'
@ -264,6 +270,7 @@ interface AllSettings {
tautulli: TautulliSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
lidarr: ArrSettings[];
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
@ -291,6 +298,7 @@ class Settings {
defaultQuotas: {
movie: {},
tv: {},
music: {},
},
hideAvailable: false,
localLogin: true,
@ -311,6 +319,7 @@ class Settings {
tautulli: {},
radarr: [],
sonarr: [],
lidarr: [],
public: {
initialized: false,
},
@ -415,6 +424,9 @@ class Settings {
'sonarr-scan': {
schedule: '0 30 4 * * *',
},
'lidarr-scan': {
schedule: '0 0 5 * * *',
},
'availability-sync': {
schedule: '0 0 5 * * *',
},
@ -478,6 +490,14 @@ class Settings {
this.data.sonarr = data;
}
get lidarr(): ArrSettings[] {
return this.data.lidarr;
}
set lidarr(data: ArrSettings[]) {
this.data.lidarr = data;
}
get public(): PublicSettings {
return this.data.public;
}

@ -0,0 +1 @@
declare module 'nodebrainz';
Loading…
Cancel
Save