Completed most of the backend and added the lidarr api

pull/3800/merge^2
Anatole Sot 4 months ago
parent 3b3cd27950
commit 5ea66adfbf

@ -1,17 +1,21 @@
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;
}
import BaseNodeBrainz from 'nodebrainz';
import type {
ArtistCredit,
Group,
mbArtist,
mbRecording,
mbRelease,
mbReleaseGroup,
mbWork,
Medium,
Recording,
Relation,
Release,
SearchOptions,
Tag,
Work,
} from './interfaces';
import { mbArtistType, mbReleaseGroupType, mbWorkType } from './interfaces';
interface ArtistSearchOptions {
query: string;
@ -53,123 +57,32 @@ interface WorkSearchOptions {
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
}
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 {
} else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
data.offset = (options.page - 1) * data.limit;
}
return data;
}
function searchOptionstoRecordingSearchOptions(options: SearchOptions): RecordingSearchOptions {
const data : RecordingSearchOptions = {
query: options.query
}
function searchOptionstoRecordingSearchOptions(
options: SearchOptions
): RecordingSearchOptions {
const data: RecordingSearchOptions = {
query: options.query,
};
if (options.tag) {
data.tag = options.tag;
}
@ -181,20 +94,21 @@ function searchOptionstoRecordingSearchOptions(options: SearchOptions): Recordin
}
if (options.limit) {
data.limit = options.limit;
}
else {
} else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
data.offset = (options.page - 1) * data.limit;
}
return data;
}
function searchOptionstoReleaseSearchOptions(options: SearchOptions): ReleaseSearchOptions {
const data : ReleaseSearchOptions = {
query: options.query
}
function searchOptionstoReleaseSearchOptions(
options: SearchOptions
): ReleaseSearchOptions {
const data: ReleaseSearchOptions = {
query: options.query,
};
if (options.artistname) {
data.artistname = options.artistname;
}
@ -203,20 +117,21 @@ function searchOptionstoReleaseSearchOptions(options: SearchOptions): ReleaseSea
}
if (options.limit) {
data.limit = options.limit;
}
else {
} else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
data.offset = (options.page - 1) * data.limit;
}
return data;
}
function searchOptionstoReleaseGroupSearchOptions(options: SearchOptions): ReleaseGroupSearchOptions {
const data : ReleaseGroupSearchOptions = {
query: options.query
}
function searchOptionstoReleaseGroupSearchOptions(
options: SearchOptions
): ReleaseGroupSearchOptions {
const data: ReleaseGroupSearchOptions = {
query: options.query,
};
if (options.artistname) {
data.artistname = options.artistname;
}
@ -225,20 +140,21 @@ function searchOptionstoReleaseGroupSearchOptions(options: SearchOptions): Relea
}
if (options.limit) {
data.limit = options.limit;
}
else {
} else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
data.offset = (options.page - 1) * data.limit;
}
return data;
}
function searchOptionstoWorkSearchOptions(options: SearchOptions): WorkSearchOptions {
const data : WorkSearchOptions = {
query: options.query
}
function searchOptionstoWorkSearchOptions(
options: SearchOptions
): WorkSearchOptions {
const data: WorkSearchOptions = {
query: options.query,
};
if (options.artistname) {
data.artist = options.artistname;
}
@ -247,31 +163,36 @@ function searchOptionstoWorkSearchOptions(options: SearchOptions): WorkSearchOpt
}
if (options.limit) {
data.limit = options.limit;
}
else {
} else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page-1)*data.limit;
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 )'});
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 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 releaseGroupResults = await this.searchReleaseGroups(
releaseGroupSearch
);
const releaseResults = await this.searchReleases(releaseSearch);
const workResults = await this.searchWorks(workSearch);
@ -281,7 +202,7 @@ class MusicBrainz extends BaseNodeBrainz {
recordingResults,
releaseGroupResults,
releaseResults,
workResults
workResults,
};
return combinedResults;
@ -292,7 +213,7 @@ class MusicBrainz extends BaseNodeBrainz {
recordingResults: [],
releaseGroupResults: [],
releaseResults: [],
workResults: []
workResults: [],
};
}
};
@ -342,228 +263,245 @@ class MusicBrainz extends BaseNodeBrainz {
}
};
public getArtist = async (artistId : string): Promise<mbArtist> => {
public getArtist = async (artistId: string): Promise<mbArtist> => {
try {
const rawData = this.artist(artistId, {inc: 'tags+recordings+releases+release-groups+works'});
const artist : mbArtist = {
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"],
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),
recordings: rawData.recordings.map(
(recording: Recording): 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 => {
),
releases: rawData.releases.map((release: Release): 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)
}],
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),
date: new Date(String(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),
releaseGroups: rawData['release-groups'].map(
(releaseGroup: Group): 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 => {
),
works: rawData.works.map((work: Work): 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)
}],
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}`);
throw new Error(
`[MusicBrainz] Failed to fetch artist details: ${e.message}`
);
}
};
public getRecording = async (recordingId : string): Promise<mbRecording> => {
public getRecording = async (recordingId: string): Promise<mbRecording> => {
try {
const rawData = this.recording(recordingId, {inc: 'tags+artists+releases'});
const recording : mbRecording = {
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
artist: rawData['artist-credit'].map(
(artist: ArtistCredit): mbArtist => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}
}),
),
length: rawData.length,
firstReleased: new Date(rawData["first-release-date"]),
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}`);
throw new Error(
`[MusicBrainz] Failed to fetch recording details: ${e.message}`
);
}
};
public async getReleaseGroup(releaseGroupId : string): Promise<mbReleaseGroup> {
public async getReleaseGroup(
releaseGroupId: string
): Promise<mbReleaseGroup> {
try {
const rawData = this.releaseGroup(releaseGroupId, {inc: 'tags+artists+releases'});
const releaseGroup : mbReleaseGroup = {
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
artist: rawData['artist-credit'].map(
(artist: ArtistCredit): mbArtist => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}
}),
type: (rawData["primary-type"] as mbReleaseGroupType) || mbReleaseGroupType.OTHER,
firstReleased: new Date(rawData["first-release-date"]),
),
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}`);
throw new Error(
`[MusicBrainz] Failed to fetch release group details: ${e.message}`
);
}
};
}
public async getRelease(releaseId : string): Promise<mbRelease> {
public async getRelease(releaseId: string): Promise<mbRelease> {
try {
const rawData = this.release(releaseId, {inc: 'tags+artists+recordings'});
const release : mbRelease = {
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
artist: rawData['artist-credit'].map(
(artist: ArtistCredit): mbArtist => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}
),
date:
rawData['release-events'] && rawData['release-events'].length > 0
? new Date(String(rawData['release-events'][0].date))
: undefined,
tracks: rawData.media.flatMap((media: Medium): mbRecording[] => {
return (media.tracks ?? []).map((track): mbRecording => {
return {
id: track.id,
title: track.title,
artist: track.recording['artist-credit'].map((artist) => {
return {
id: artist.artist.id,
name: artist.artist.name,
sortName: artist.artist['sort-name'],
type:
(artist.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: artist.artist.tags.map((tag: Tag) => tag.name),
};
}),
length: track.recording.length,
tags: track.recording.tags.map((tag: Tag) => tag.name),
};
});
}),
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}`);
throw new Error(
`[MusicBrainz] Failed to fetch release details: ${e.message}`
);
}
};
}
public async getWork(workId : string): Promise<mbWork> {
public async getWork(workId: string): Promise<mbWork> {
try {
const rawData = this.work(workId, {inc: 'tags+artist-rels'});
const work : mbWork = {
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}) => {
artist: rawData.relations.map((relation: Relation): mbArtist => {
return {
id: relation.artist.id,
name: relation.artist.name,
sortName: relation.artist["sort-name"],
type: (relation.artist.type as mbArtistType) || mbArtistType.OTHER
}
sortName: relation.artist['sort-name'],
type: (relation.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: relation.artist.tags.map((tag: Tag) => tag.name),
};
}),
tags: rawData.tags.map((tag: Tag) => tag.name),
};
return work;
} catch (e) {
throw new Error(`[MusicBrainz] Failed to fetch work details: ${e.message}`);
throw new Error(
`[MusicBrainz] Failed to fetch work details: ${e.message}`
);
}
};
}
}
export default MusicBrainz;

@ -7,7 +7,7 @@ export enum mbArtistType {
CHOIR = 'Choir',
CHARACTER = 'Character',
OTHER = 'Other',
};
}
export interface mbArtist {
id: string;
@ -23,7 +23,7 @@ export interface mbArtist {
beginDate?: string;
endDate?: string;
tags: string[];
};
}
export interface mbRecording {
id: string;
@ -32,7 +32,7 @@ export interface mbRecording {
length: number;
firstReleased?: Date;
tags: string[];
};
}
export interface mbRelease {
id: string;
@ -41,8 +41,7 @@ export interface mbRelease {
date?: Date;
tracks?: mbRecording[];
tags: string[];
};
}
export enum mbReleaseGroupType {
ALBUM = 'Album',
@ -50,7 +49,7 @@ export enum mbReleaseGroupType {
EP = 'EP',
BROADCAST = 'Broadcast',
OTHER = 'Other',
};
}
export interface mbReleaseGroup {
id: string;
@ -60,7 +59,7 @@ export interface mbReleaseGroup {
firstReleased?: Date;
releases?: mbRelease[];
tags: string[];
};
}
export enum mbWorkType {
ARIA = 'Aria',
@ -93,8 +92,7 @@ export enum mbWorkType {
MUSICAL = 'Musical',
INCIDENTAL_MUSIC = 'Incidental music',
OTHER = 'Other',
};
}
export interface mbWork {
id: string;
@ -102,4 +100,185 @@ export interface mbWork {
type: mbWorkType;
artist: mbArtist[];
tags: string[];
};
}
export interface Artist {
'end-area': Area;
tags: Tag[];
name: string;
country: string;
ipis: string[];
gender: string;
area: Area;
begin_area: Area;
id: string;
releases: Release[];
'type-id': string;
'begin-area': Area;
isnis: string[];
recordings: Recording[];
'sort-name': string;
'release-groups': Group[];
works: Work[];
type: string;
'gender-id': string;
disambiguation: string;
end_area: Area;
'life-span': LifeSpan;
}
export interface Tag {
count: number;
name: string;
}
export interface Area {
type: string;
disambiguation: string;
'iso-3166-1-codes'?: string[];
'type-id': string;
id: string;
'sort-name': string;
name: string;
}
export interface Release {
'packaging-id'?: string;
title: string;
'release-events'?: Event[];
tags: Tag[];
country?: string;
status: string;
'release-group': Group;
quality: string;
media: Medium[];
date?: string;
packaging?: string;
disambiguation: string;
barcode?: string;
'status-id': string;
'text-representation': TextRepresentation;
id: string;
'cover-art-archive': CoverArtArchive;
'artist-credit': ArtistCredit[];
}
export interface CoverArtArchive {
artwork: boolean;
back: boolean;
count: number;
darkened: boolean;
front: boolean;
}
export interface ArtistCredit {
name: string;
joinphrase: string;
artist: Artist;
}
export interface Event {
area?: Area;
date: string;
}
export interface Medium {
position: number;
'format-id': string;
format: string;
title: string;
'track-count': number;
'track-offset'?: number;
tracks?: Track[];
}
export interface Track {
title: string;
position: number;
number: string;
recording: Recording;
length: number;
id: string;
}
export interface TextRepresentation {
language: string;
script: string;
}
export interface Recording {
title: string;
tags: Tag[];
disambiguation: string;
id: string;
releases: Release[];
'first-release-date': string;
length: number;
'artist-credit': ArtistCredit[];
video: boolean;
}
export interface Group {
id: string;
releases: Release[];
'first-release-date': string;
'primary-type': string;
tags: Tag[];
'secondary-types': string[];
disambiguation: string;
'secondary-type-ids': string[];
'primary-type-id': string;
title: string;
'artist-credit': ArtistCredit[];
}
export interface Work {
attributes: Attribute[];
language: string;
type: string;
disambiguation: string;
id: string;
'type-id': string;
iswcs: string[];
title: string;
tags: Tag[];
languages: string[];
relations: Relation[];
}
export interface Relation {
type: string;
attributes: Attribute[];
begin: string;
'target-credit': string;
end: string;
'type-id': string;
direction: string;
ended: boolean;
'target-type': string;
'source-credit': string;
artist: Artist;
}
export interface Attribute {
'type-id': string;
type: string;
value: string;
}
export interface LifeSpan {
ended: boolean;
end: string;
begin: string;
}
export interface SearchOptions {
query: string;
page?: number;
limit?: number;
keywords?: string;
artistname?: string;
albumname?: string;
recordingname?: string;
tag?: string;
}

@ -123,7 +123,7 @@ export interface PlexWatchlistItem {
ratingKey: string;
tmdbId: number;
tvdbId?: number;
type: 'movie' | 'show';
type: 'movie' | 'show' | 'music';
title: string;
}

@ -1,16 +1,15 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface LidarrMusicOptions {
title: string;
qualityProfileId: number;
tags: number[];
export interface LidarrAlbumOptions {
profileId: number;
year: number;
qualityProfileId: number;
rootFolderPath: string;
mbId: number;
monitored?: boolean;
searchNow?: boolean;
title: string;
mbId: string;
monitored: boolean;
tags: string[];
searchNow: boolean;
}
export interface LidarrMusic {
@ -19,7 +18,6 @@ export interface LidarrMusic {
isAvailable: boolean;
monitored: boolean;
mbId: number;
imdbId: string;
titleSlug: string;
folderName: string;
path: string;
@ -29,15 +27,123 @@ export interface LidarrMusic {
hasFile: boolean;
}
export interface LidarrAlbum {
title: string;
disambiguation: string;
overview: string;
artistId: number;
foreignAlbumId: string;
monitored: boolean;
anyReleaseOk: boolean;
profileId: number;
duration: number;
albumType: string;
secondaryTypes: string[];
mediumCount: number;
ratings: Ratings;
releaseDate: string;
releases: LidarrRelease[];
genres: string[];
media: Medium[];
artist: LidarrArtist;
images: Image[];
links: Link[];
statistics: Statistics;
grabbed: boolean;
id: number;
}
export interface LidarrArtist {
artistMetadataId: number;
status: string;
ended: boolean;
artistName: string;
foreignArtistId: string;
tadbId: number;
discogsId: number;
overview: string;
artistType: string;
disambiguation: string;
links: Link[];
images: Image[];
path: string;
qualityProfileId: number;
metadataProfileId: number;
monitored: boolean;
monitorNewItems: string;
rootFolderPath?: string;
genres: string[];
cleanName: string;
sortName: string;
tags: Tag[];
added: string;
ratings: Ratings;
statistics: Statistics;
id: number;
}
export interface LidarrRelease {
id: number;
albumId: number;
foreignReleaseId: string;
title: string;
status: string;
duration: number;
trackCount: number;
media: Medium[];
mediumCount: number;
disambiguation: string;
country: string[];
label: string[];
format: string;
monitored: boolean;
}
export interface Link {
url: string;
name: string;
}
export interface Ratings {
votes: number;
value: number;
}
export interface Statistics {
albumCount?: number;
trackFileCount: number;
trackCount: number;
totalTrackCount: number;
sizeOnDisk: number;
percentOfTracks: number;
}
export interface Image {
url: string;
coverType: string;
extension: string;
remoteUrl: string;
}
export interface Tag {
name: string;
count: number;
}
export interface Medium {
mediumNumber: number;
mediumName: string;
mediumFormat: string;
}
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[]> => {
public getArtists = async (): Promise<LidarrArtist[]> => {
try {
const response = await this.axios.get<LidarrMusic[]>('/music');
const response = await this.axios.get<LidarrArtist[]>('/artist');
return response.data;
} catch (e) {
@ -45,172 +151,52 @@ class LidarrAPI extends ServarrBase<{ musicId: number }> {
}
};
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> {
public async getAlbum({ mbId }: { mbId: string }): Promise<LidarrAlbum[]> {
try {
const response = await this.axios.get<LidarrMusic[]>('/music/lookup', {
const response = await this.axios.get<LidarrAlbum[]>('/album', {
params: {
term: `musicbrainz:${id}`,
foreignAlbumId: mbId,
},
});
if (!response.data[0]) {
throw new Error('Music not found');
if (response.data.length > 0) {
return response.data;
}
return response.data[0];
throw new Error('Album not found');
} catch (e) {
logger.error('Error retrieving music by MUSICBRAINZ ID', {
logger.error('Error retrieving album by MUSICBRAINZ ID', {
label: 'Lidarr API',
errorMessage: e.message,
mbId: id,
mbId: mbId,
});
throw new Error('Music not found');
throw new Error('Album not found');
}
}
public addMusic = async (
options: LidarrMusicOptions
): Promise<LidarrMusic> => {
public addAlbum = async (
options: LidarrAlbumOptions
): Promise<LidarrAlbum> => {
try {
const music = await this.getMusicBymbId(options.mbId);
if (music.hasFile) {
const albums = await this.getAlbum({ mbId: options.mbId.toString() });
if (albums.length > 0) {
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',
'Album is already monitored in Lidarr. Skipping add and returning success',
{ label: 'Lidarr' }
);
return music;
return albums[0];
}
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,
},
const response = await this.axios.put<LidarrAlbum>('/album', {
params: { id: options.mbId },
});
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');
logger.error('Error adding album by MUSICBRAINZ ID', {
label: 'Lidarr API',
errorMessage: e.message,
mbId: options.mbId,
});
throw new Error(`[Lidarr] Failed to add album: ${options.mbId}`);
}
};
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;

@ -1,8 +1,9 @@
export enum IssueType {
VIDEO = 1,
AUDIO = 2,
SUBTITLES = 3,
OTHER = 4,
MUSIC = 3,
SUBTITLES = 4,
OTHER = 5,
}
export enum IssueStatus {
@ -13,6 +14,7 @@ export enum IssueStatus {
export const IssueTypeName = {
[IssueType.AUDIO]: 'Audio',
[IssueType.VIDEO]: 'Video',
[IssueType.MUSIC]: 'Music',
[IssueType.SUBTITLES]: 'Subtitle',
[IssueType.OTHER]: 'Other',
};

@ -73,11 +73,11 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column()
@Column({ nullable: true })
@Index()
public tmdbId?: number;
@Column()
@Column({ nullable: true })
@Index()
public mbId?: string;

@ -1,5 +1,5 @@
import MusicBrainz from '@server/api/musicbrainz';
import type { LidarrMusicOptions } from '@server/api/servarr/lidarr';
import type { LidarrAlbumOptions } from '@server/api/servarr/lidarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
@ -1384,7 +1384,7 @@ export class MediaRequest {
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v3'),
});
const music = await musicbrainz.getMusic({ mbId: this.media.mbId });
const release = await musicbrainz.getRelease(String(this.media.mbId));
const media = await mediaRepository.findOne({
where: { id: this.media.id },
@ -1444,22 +1444,21 @@ export class MediaRequest {
return;
}
const lidarrMusicOptions: LidarrMusicOptions = {
const lidarrAlbumOptions: LidarrAlbumOptions = {
profileId: qualityProfile,
qualityProfileId: qualityProfile,
rootFolderPath: rootFolder,
title: music.title,
mbId: music.id,
year: Number(music.release_date.slice(0, 4)),
title: release.title,
mbId: release.id,
monitored: true,
tags,
tags: tags.map((tag) => String(tag)),
searchNow: !lidarrSettings.preventSearch,
};
// Run this asynchronously so we don't wait for it on the UI side
lidarr
.addMusic(lidarrMusicOptions)
.then(async (lidarrMusic) => {
.addAlbum(lidarrAlbumOptions)
.then(async (lidarrAlbum) => {
// 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 },
@ -1469,8 +1468,8 @@ export class MediaRequest {
throw new Error('Media data not found');
}
media['externalServiceId'] = lidarrMusic.id;
media['externalServiceSlug'] = lidarrMusic.titleSlug;
media['externalServiceId'] = lidarrAlbum.id;
media['externalServiceSlug'] = lidarrAlbum.disambiguation;
media['serviceId'] = lidarrSettings?.id;
await mediaRepository.save(media);
})
@ -1486,7 +1485,7 @@ export class MediaRequest {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
lidarrMusicOptions,
lidarrAlbumOptions,
}
);

@ -20,13 +20,13 @@ interface MediaRequestBody {
export interface VideoRequestBody extends MediaRequestBody {
mediaType: MediaType.MOVIE | MediaType.TV;
mediaId: number;
seasons?: number[] | 'all';
is4k?: boolean;
tvdbId?: number;
}
export interface TvRequestBody extends VideoRequestBody {
mediaType: MediaType.TV;
tvdbId?: number;
seasons?: number[] | 'all';
}
export interface MusicRequestBody extends MediaRequestBody {

@ -1,6 +1,6 @@
import LidarrAPI from '@server/api/servarr/lidarr';
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';
@ -249,33 +249,32 @@ class DownloadTracker {
if (server.syncEnabled) {
const lidarr = new LidarrAPI({
apiKey: server.apiKey,
url: LidarrAPI.buildUrl(server, '/api/v3'),
url: LidarrAPI.buildUrl(server, '/api/v1'),
});
try {
const queueItems = await lidarr.getQueue();
this.lidarrServers[server.id] = queueItems.map((item) => ({
externalId: item.seriesId,
externalId: item.musicId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.TV,
mediaType: MediaType.MUSIC,
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}`,
`Found ${queueItems.length} item(s) in progress on Lidarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
} catch {
logger.error(
`Unable to get queue from Sonarr server: ${server.name}`,
`Unable to get queue from Lidarr server: ${server.name}`,
{
label: 'Download Tracker',
}
@ -293,7 +292,7 @@ class DownloadTracker {
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Sonarr server(s)`,
`Matching download data to ${matchingServers.length} other Lidarr server(s)`,
{ label: 'Download Tracker' }
);
}

@ -55,16 +55,17 @@ export interface ArrSettings {
activeProfileId: number;
activeProfileName: string;
activeDirectory: string;
tags: number[];
isDefault: boolean;
externalUrl?: string;
syncEnabled: boolean;
preventSearch: boolean;
tagRequests: boolean;
tags: string[] | number[];
}
export interface DVRSettings extends ArrSettings {
is4k: boolean;
tags: number[];
}
export interface RadarrSettings extends DVRSettings {
@ -83,7 +84,6 @@ export interface SonarrSettings extends DVRSettings {
enableSeasonFolders: boolean;
}
interface Quota {
quotaLimit?: number;
quotaDays?: number;

@ -45,6 +45,7 @@ class WatchlistSync {
Permission.AUTO_REQUEST,
Permission.AUTO_REQUEST_MOVIE,
Permission.AUTO_APPROVE_TV,
Permission.AUTO_REQUEST_MUSIC,
],
{ type: 'or' }
)
@ -74,7 +75,8 @@ class WatchlistSync {
!mediaItems.find(
(m) =>
m.tmdbId === i.tmdbId &&
((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') ||
((m.status !== MediaStatus.UNKNOWN &&
(m.mediaType === 'movie' || m.mediaType === 'music')) ||
(m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE))
)
);

@ -15,8 +15,10 @@ import {
import SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User';
import type {
MediaRequestBody,
MusicRequestBody,
RequestResultsResponse,
TvRequestBody,
VideoRequestBody,
} from '@server/interfaces/api/requestInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
@ -158,38 +160,39 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
}
);
requestRoutes.post<never, MediaRequest, MediaRequestBody>(
'/',
async (req, res, next) => {
try {
if (!req.user) {
return next({
status: 401,
message: 'You must be logged in to request media.',
});
}
const request = await MediaRequest.request(req.body, req.user);
requestRoutes.post<
never,
MediaRequest,
MusicRequestBody | VideoRequestBody | TvRequestBody
>('/', async (req, res, next) => {
try {
if (!req.user) {
return next({
status: 401,
message: 'You must be logged in to request media.',
});
}
const request = await MediaRequest.request(req.body, req.user);
return res.status(201).json(request);
} catch (error) {
if (!(error instanceof Error)) {
return;
}
return res.status(201).json(request);
} catch (error) {
if (!(error instanceof Error)) {
return;
}
switch (error.constructor) {
case RequestPermissionError:
case QuotaRestrictedError:
return next({ status: 403, message: error.message });
case DuplicateMediaRequestError:
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
default:
return next({ status: 500, message: error.message });
}
switch (error.constructor) {
case RequestPermissionError:
case QuotaRestrictedError:
return next({ status: 403, message: error.message });
case DuplicateMediaRequestError:
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
default:
return next({ status: 500, message: error.message });
}
}
);
});
requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest);

@ -42,14 +42,14 @@ export class IssueCommentSubscriber
});
if (media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
const movie = await tmdb.getMovie({ movieId: Number(media.tmdbId) });
title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvshow = await tmdb.getTvShow({ tvId: Number(media.tmdbId) });
title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''

@ -26,14 +26,18 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
try {
if (entity.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
const movie = await tmdb.getMovie({
movieId: Number(entity.media.tmdbId),
});
title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
const tvshow = await tmdb.getTvShow({
tvId: Number(entity.media.tmdbId),
});
title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''

@ -41,7 +41,9 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
const tmdb = new TheMovieDb();
try {
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
const movie = await tmdb.getMovie({
movieId: Number(entity.tmdbId),
});
relatedRequests.forEach((request) => {
notificationManager.sendNotification(
@ -136,7 +138,7 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
);
try {
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
const tv = await tmdb.getTvShow({ tvId: Number(entity.tmdbId) });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${

@ -10,5 +10,5 @@
"@server/*": ["*"]
}
},
"include": ["**/*.ts"]
"include": ["**/*.ts", "type/**/*.d.ts"]
}

@ -1 +1,22 @@
declare module 'nodebrainz';
declare module 'nodebrainz' {
import type {
Artist,
Group,
Recording,
Release,
SearchOptions,
Work,
} from 'server/api/musicbrainz/interfaces';
export default class BaseNodeBrainz {
constructor(options: { userAgent: string });
artist(artistId: string, { inc }: { inc: string }): Artist;
recording(recordingId: string, { inc }: { inc: string }): Recording;
release(releaseId: string, { inc }: { inc: string }): Release;
releaseGroup(releaseGroupId: string, { inc }: { inc: string }): Group;
work(workId: string, { inc }: { inc: string }): Work;
search(
type: string,
search: SearchOptions
): Artist[] | Recording[] | Release[] | Group[] | Work[] | null;
}
}

@ -24,6 +24,6 @@
"@app/*": ["*"]
}
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx"],
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["node_modules"]
}

Loading…
Cancel
Save