feat: added support for Lidarr

pull/3800/head
Anatole Sot 3 months ago
parent e749547ca0
commit f303c21402

3
.gitignore vendored

@ -68,6 +68,3 @@ tsconfig.tsbuildinfo
# Config Cache Directory
config/cache
# Yarn
yarn.lock
.yarn/*

Binary file not shown.

@ -26,6 +26,8 @@ tags:
description: Endpoints related to retrieving movies and their details.
- name: tv
description: Endpoints related to retrieving TV series and their details.
- name: music
description: Endpoints related to retrieving music and details about artists,...
- name: other
description: Endpoints related to other TMDB data
- name: person
@ -35,7 +37,7 @@ tags:
- name: collection
description: Endpoints related to retrieving collection details.
- name: service
description: Endpoints related to getting service (Radarr/Sonarr) details.
description: Endpoints related to getting service (Radarr/Sonarr/Lidarr) details.
servers:
- url: '{server}/api/v1'
variables:
@ -466,6 +468,61 @@ components:
- is4k
- enableSeasonFolders
- isDefault
LidarrSettings:
type: object
properties:
id:
type: number
example: 0
readOnly: true
name:
type: string
example: 'Lidarr Main'
hostname:
type: string
example: '127.0.0.1'
port:
type: number
example: 8989
apiKey:
type: string
example: 'exampleapikey'
useSsl:
type: boolean
example: false
baseUrl:
type: string
activeProfileId:
type: number
example: 1
activeProfileName:
type: string
example: 128kps
activeDirectory:
type: string
example: '/music/'
isDefault:
type: boolean
example: false
externalUrl:
type: string
example: http://lidarr.example.com
syncEnabled:
type: boolean
example: false
preventSearch:
type: boolean
example: false
required:
- name
- hostname
- port
- apiKey
- useSsl
- activeProfileId
- activeProfileName
- activeDirectory
- isDefault
ServarrTag:
type: object
properties:
@ -593,6 +650,149 @@ components:
oneOf:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
MusicResult:
type: object
properties:
id:
type: string
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
mediaType:
type: string
posterPath:
type: string
title:
type: string
example: Album Name
releaseDate:
type: string
example: 19923-12-03
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
ArtistResult:
type: object
properties:
id:
type: string
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
mediaType:
type: string
example: artist
posterPath:
type: string
title:
type: string
example: Album Name
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
name:
type: string
type:
type: string
enum:
- mbArtistType
releases:
type: array
items:
$ref: '#/components/schemas/ReleaseResult'
gender:
type: string
area:
type: string
beginDate:
type: string
endDate:
type: string
tags:
type: array
items:
type: string
RecordingResult:
type: object
properties:
id:
type: string
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
mediaType:
type: string
example: recording
title:
type: string
artist:
type: array
items:
$ref: '#/components/schemas/ArtistResult'
length:
type: number
firstReleased:
type: string
format: date-time
tags:
type: array
items:
type: string
ReleaseGroupResult:
type: object
properties:
id:
type: string
mediaType:
type: string
enum: ['release-group']
type:
type: string
enum: ['Album', 'Single', 'EP', 'Broadcast', 'Other']
posterPath:
type: string
nullable: true
title:
type: string
releases:
type: array
items:
$ref: '#/components/schemas/ReleaseResult'
artist:
type: array
items:
$ref: '#/components/schemas/ArtistResult'
tags:
type: array
items:
type: string
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
ReleaseResult:
type: object
properties:
id:
type: string
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
mediaType:
type: string
example: release
title:
type: string
artist:
type: array
items:
$ref: '#/components/schemas/ArtistResult'
posterPath:
type: string
date:
type: string
format: date
tracks:
type: array
items:
$ref: '#/components/schemas/RecordingResult'
tags:
type: array
items:
type: string
mediaInfo:
$ref: '#/components/schemas/MediaInfo'
releaseGroup:
$ref: '#/components/schemas/ReleaseGroupResult'
Genre:
type: object
properties:
@ -1067,6 +1267,8 @@ components:
type: string
example: '2020-09-12T10:00:27.000Z'
readOnly: true
secondaryType:
type: string
Cast:
type: object
properties:
@ -2408,6 +2610,150 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
/settings/lidarr:
get:
summary: Get Lidarr settings
description: Returns all Lidarr settings in a JSON array.
tags:
- settings
responses:
'200':
description: 'Values were returned'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/LidarrSettings'
post:
summary: Create Lidarr instance
description: Creates a new Lidarr instance from the request body.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
responses:
'201':
description: 'New Lidarr instance created'
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
/settings/lidarr/test:
post:
summary: Test Lidarr configuration
description: Tests if the Lidarr configuration is valid. Returns profiles and root folders on success.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
hostname:
type: string
example: '127.0.0.1'
port:
type: number
example: 7878
apiKey:
type: string
example: yourapikey
useSsl:
type: boolean
example: false
baseUrl:
type: string
required:
- hostname
- port
- apiKey
- useSsl
responses:
'200':
description: Succesfully connected to Lidarr instance
content:
application/json:
schema:
type: object
properties:
profiles:
type: array
items:
$ref: '#/components/schemas/ServiceProfile'
/settings/lidarr/{lidarrId}:
put:
summary: Update Lidarr instance
description: Updates an existing Lidarr instance with the provided values.
tags:
- settings
parameters:
- in: path
name: lidarrId
required: true
schema:
type: integer
description: Lidarr instance ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
responses:
'200':
description: 'Lidarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
delete:
summary: Delete Lidarr instance
description: Deletes an existing Lidarr instance based on the lidarrId parameter.
tags:
- settings
parameters:
- in: path
name: lidarrId
required: true
schema:
type: integer
description: Lidarr instance ID
responses:
'200':
description: 'Lidarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/LidarrSettings'
/settings/lidarr/{lidarrId}/profiles:
get:
summary: Get available Lidarr profiles
description: Returns a list of profiles available on the Lidarr server instance in a JSON array.
tags:
- settings
parameters:
- in: path
name: lidarrId
required: true
schema:
type: integer
description: Lidarr instance ID
responses:
'200':
description: Returned list of profiles
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ServiceProfile'
/settings/public:
get:
summary: Get public settings
@ -4070,6 +4416,8 @@ paths:
- $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult'
- $ref: '#/components/schemas/ArtistResult'
- $ref: '#/components/schemas/ReleaseResult'
/search/keyword:
get:
summary: Search for keywords
@ -4089,6 +4437,11 @@ paths:
type: number
example: 1
default: 1
- in: query
name: type
schema:
type: string
enum: [movie,tv,music]
responses:
'200':
description: Results
@ -4109,7 +4462,9 @@ paths:
results:
type: array
items:
$ref: '#/components/schemas/Keyword'
oneOf:
- $ref: '#/components/schemas/Keyword'
- type: string
/search/company:
get:
summary: Search for companies
@ -4728,6 +5083,57 @@ paths:
type: array
items:
$ref: '#/components/schemas/TvResult'
/discover/musics:
get:
summary: Discover music
description: Returns a list of music in a JSON object.
tags:
- search
parameters:
- in: query
name: page
schema:
type: number
example: 1
default: 1
- in: query
name: keywords
schema:
type: string
example: 1,2
responses:
'200':
description: Results
content:
application/json:
schema:
type: object
properties:
page:
type: number
example: 1
totalPages:
type: number
example: 20
totalResults:
type: number
example: 200
results:
type: array
items:
anyOf:
- $ref: '#/components/schemas/ReleaseResult'
- $ref: '#/components/schemas/ArtistResult'
'500':
description: An error occured while getting musics
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Unable to retrieve release groups.
/discover/trending:
get:
summary: Trending movies and TV
@ -4999,10 +5405,12 @@ paths:
properties:
mediaType:
type: string
enum: [movie, tv]
enum: [movie, tv, music]
example: movie
mediaId:
type: number
oneOf:
- type: number
- type: string
example: 123
tvdbId:
type: number
@ -5029,6 +5437,9 @@ paths:
userId:
type: number
nullable: true
secondaryType:
type: string
enum: [release,artist]
required:
- mediaType
- mediaId
@ -5113,7 +5524,7 @@ paths:
properties:
mediaType:
type: string
enum: [movie, tv]
enum: [movie, tv, music]
seasons:
type: array
items:
@ -5162,7 +5573,7 @@ paths:
post:
summary: Retry failed request
description: |
Retries a request by resending requests to Sonarr or Radarr.
Retries a request by resending requests to Sonarr, Radarr or Lidarr.
Requires the `MANAGE_REQUESTS` permission or `ADMIN`.
tags:
@ -5670,6 +6081,82 @@ paths:
$ref: '#/components/schemas/CreditCrew'
id:
type: number
/music/artist/{artistId}:
get:
summary: Get artist details
description: Returns artist details in a JSON object.
tags:
- music
parameters:
- in: path
name: artistId
required: true
schema:
type: string
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
- in: query
name: full
schema:
type: boolean
example: false
default: false
- in: query
name: maxElements
schema:
type: number
example: 50
default: 25
- in: query
name: offset
schema:
type: number
example: 25
default: 0
responses:
'200':
description: Artist details
content:
application/json:
schema:
$ref: '#/components/schemas/ArtistResult'
/music/release/{releaseId}:
get:
summary: Get release details
description: Returns full release details in a JSON object.
tags:
- music
parameters:
- in: path
name: releaseId
required: true
schema:
type: string
example: 87f17f8a-c0e2-406c-a149-8c8e311bf330
- in: query
name: full
schema:
type: boolean
example: false
default: false
- in: query
name: maxElements
schema:
type: number
example: 50
default: 25
- in: query
name: offset
schema:
type: number
example: 25
default: 0
responses:
'200':
description: Release details
content:
application/json:
schema:
$ref: '#/components/schemas/ReleaseResult'
/media:
get:
summary: Get media
@ -5701,6 +6188,12 @@ paths:
type: string
enum: [added, modified, mediaAdded]
default: added
- in: query
name: type
schema:
type: string
enum: [all,movie,tv,music,artist,release]
default: all
responses:
'200':
description: Returned media
@ -5948,6 +6441,46 @@ paths:
type: array
items:
$ref: '#/components/schemas/SonarrSeries'
/service/lidarr:
get:
summary: Get non-sensitive Lidarr server list
description: Returns a list of Lidarr server IDs and names in a JSON object.
tags:
- service
responses:
'200':
description: Request successful
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/LidarrSettings'
/service/lidarr/{lidarrId}:
get:
summary: Get Lidarr server quality profiles and root folders
description: Returns a Lidarr server's quality profile and root folder details in a JSON object.
tags:
- service
parameters:
- in: path
name: lidarrId
required: true
schema:
type: number
example: 0
responses:
'200':
description: Request successful
content:
application/json:
schema:
type: object
properties:
server:
$ref: '#/components/schemas/LidarrSettings'
profiles:
$ref: '#/components/schemas/ServiceProfile'
/regions:
get:
summary: Regions supported by TMDB

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"dev": "tsc --noEmit --project server/tsconfig.json && nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
"build:next": "next build",
"build": "yarn build:next && yarn build:server",
@ -159,7 +159,7 @@
"prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3",
"semantic-release": "^18.0.0",
"semantic-release": "18.0.0",
"semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.2.7",
"ts-node": "10.9.1",

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

@ -1,5 +1,11 @@
// Purpose: Interfaces for MusicBrainz data.
export interface mbDefaultType {
media_type: string;
id: string;
tags: string[];
}
export enum mbArtistType {
PERSON = 'Person',
GROUP = 'Group',
@ -7,10 +13,10 @@ export enum mbArtistType {
CHOIR = 'Choir',
CHARACTER = 'Character',
OTHER = 'Other',
};
}
export interface mbArtist {
id: string;
export interface mbArtist extends mbDefaultType {
media_type: 'artist';
name: string;
sortName: string;
type: mbArtistType;
@ -22,27 +28,24 @@ export interface mbArtist {
area?: string;
beginDate?: string;
endDate?: string;
tags: string[];
};
}
export interface mbRecording {
id: string;
export interface mbRecording extends mbDefaultType {
media_type: 'recording';
title: string;
artist: mbArtist[];
length: number;
firstReleased?: Date;
tags: string[];
};
}
export interface mbRelease {
id: string;
export interface mbRelease extends mbDefaultType {
media_type: 'release';
title: string;
artist: mbArtist[];
date?: Date;
tracks?: mbRecording[];
tags: string[];
};
releaseGroup?: mbReleaseGroup;
}
export enum mbReleaseGroupType {
ALBUM = 'Album',
@ -50,17 +53,16 @@ export enum mbReleaseGroupType {
EP = 'EP',
BROADCAST = 'Broadcast',
OTHER = 'Other',
};
}
export interface mbReleaseGroup {
id: string;
export interface mbReleaseGroup extends mbDefaultType {
media_type: 'release-group';
title: string;
artist: mbArtist[];
type: mbReleaseGroupType;
firstReleased?: Date;
releases?: mbRelease[];
tags: string[];
};
}
export enum mbWorkType {
ARIA = 'Aria',
@ -93,13 +95,193 @@ export enum mbWorkType {
MUSICAL = 'Musical',
INCIDENTAL_MUSIC = 'Incidental music',
OTHER = 'Other',
};
}
export interface mbWork {
id: string;
export interface mbWork extends mbDefaultType {
media_type: 'work';
title: string;
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;
tags?: string[];
tag?: string;
}

@ -0,0 +1,70 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import { getSettings } from '@server/lib/settings';
import type { mbArtist, mbRelease, mbReleaseGroup } from './interfaces';
async function getPosterFromMB(
element: mbRelease | mbReleaseGroup | mbArtist
): Promise<string | undefined> {
if (element.media_type === 'artist') {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
if (!lidarrSettings) {
throw new Error('No default Lidarr instance found');
}
const lidarr: LidarrAPI = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
try {
const artist = await (lidarr as LidarrAPI).getArtist(element.id);
if (artist.images.find((i) => i.coverType === 'poster')?.url) {
return LidarrAPI.buildUrl(
lidarrSettings,
artist.images.find((i) => i.coverType === 'poster')?.url
);
} else {
return undefined;
}
} catch (e) {
return undefined;
}
}
return `https://coverartarchive.org/${element.media_type}/${element.id}/front-250.jpg`;
}
async function getFanartFromMB(element: mbArtist): Promise<string | undefined> {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
if (!lidarrSettings) {
throw new Error('No default Lidarr instance found');
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
try {
const artist = await lidarr.getArtist(element.id);
return (
artist.images ?? [{ coverType: 'fanart', remoteUrl: undefined }]
).filter((i) => i.coverType === 'fanart')[0].remoteUrl;
} catch (e) {
return undefined;
}
}
const memoize = <T = unknown>(fn: (...val: T[]) => unknown) => {
const cache = new Map();
const cached = function (this: unknown, val: T) {
return cache.has(val)
? cache.get(val)
: cache.set(val, fn.call(this, val) as ReturnType<typeof fn>) &&
cache.get(val);
};
cached.cache = cache;
return cached;
};
const cachedFanartFromMB = memoize(getFanartFromMB);
export default memoize(getPosterFromMB);
export { cachedFanartFromMB };

@ -3,6 +3,12 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import NodePlexAPI from 'plex-api';
const SEARCHTYPES = {
movie: 1,
show: 2,
artist: '8,9',
};
export interface PlexLibraryItem {
ratingKey: string;
parentRatingKey?: string;
@ -16,7 +22,7 @@ export interface PlexLibraryItem {
Guid?: {
id: string;
}[];
type: 'movie' | 'show' | 'season' | 'episode';
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
Media: Media[];
}
@ -28,7 +34,7 @@ interface PlexLibraryResponse {
}
export interface PlexLibrary {
type: 'show' | 'movie';
type: 'show' | 'movie' | 'artist';
key: string;
title: string;
agent: string;
@ -44,7 +50,7 @@ export interface PlexMetadata {
ratingKey: string;
parentRatingKey?: string;
guid: string;
type: 'movie' | 'show' | 'season';
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
title: string;
Guid: {
id: string;
@ -152,7 +158,10 @@ class PlexAPI {
const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show
.filter(
(library) => library.type === 'movie' || library.type === 'show'
(library) =>
library.type === 'movie' ||
library.type === 'show' ||
library.type === 'artist'
)
// Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none')
@ -201,6 +210,26 @@ class PlexAPI {
};
}
public async getMusicLibraryContents(
id: string,
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
const response = await this.getLibraryContents(id, { offset, size });
const response2 = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/albums?includeGuids=1`,
extraHeaders: {
'X-Plex-Container-Start': `${offset}`,
'X-Plex-Container-Size': `${size}`,
},
});
return {
totalSize: response.totalSize + response2.MediaContainer.totalSize,
items: response.items.concat(response2.MediaContainer.Metadata) ?? [],
};
}
public async getMetadata(
key: string,
options: { includeChildren?: boolean } = {}
@ -227,18 +256,17 @@ class PlexAPI {
options: { addedAt: number } = {
addedAt: Date.now() - 1000 * 60 * 60,
},
mediaType: 'movie' | 'show'
mediaType: 'movie' | 'show' | 'artist'
): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1'
SEARCHTYPES[mediaType]
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
extraHeaders: {
'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`,
},
});
return response.MediaContainer.Metadata;
}
}

@ -110,10 +110,14 @@ interface MetadataResponse {
MediaContainer: {
Metadata: {
ratingKey: string;
type: 'movie' | 'show';
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album';
title: string;
Guid: {
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
id:
| `imdb://tt${number}`
| `tmdb://${number}`
| `tvdb://${number}`
| `mbid://${string}`;
}[];
}[];
};
@ -121,9 +125,10 @@ interface MetadataResponse {
export interface PlexWatchlistItem {
ratingKey: string;
tmdbId: number;
tmdbId?: number;
tvdbId?: number;
type: 'movie' | 'show';
musicBrainzId?: string;
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album';
title: string;
}
@ -299,6 +304,9 @@ class PlexTvAPI extends ExternalAPI {
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
const musicBrainzString = metadata.Guid.find((guid) =>
guid.id.startsWith('mbid')
);
return {
ratingKey: metadata.ratingKey,
@ -308,6 +316,9 @@ class PlexTvAPI extends ExternalAPI {
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
musicBrainzId: musicBrainzString
? musicBrainzString.id.split('//')[1]
: undefined,
title: metadata.title,
type: metadata.type,
};
@ -315,7 +326,11 @@ class PlexTvAPI extends ExternalAPI {
)
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
const filteredList = watchlistDetails.filter((detail) =>
['movie', 'show'].includes(detail.type)
? detail.tmdbId
: detail.musicBrainzId
);
return {
offset,

@ -1,16 +1,28 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface LidarrMusicOptions {
title: string;
export interface LidarrAlbumOptions {
profileId: number;
qualityProfileId: number;
tags: number[];
rootFolderPath: string;
title: string;
mbId: string;
monitored: boolean;
tags: string[];
searchNow: boolean;
}
export interface LidarrArtistOptions {
profileId: number;
year: number;
qualityProfileId: number;
rootFolderPath: string;
mbId: number;
monitored?: boolean;
searchNow?: boolean;
mbId: string;
monitored: boolean;
tags: string[];
searchNow: boolean;
monitorNewItems: string;
monitor: string;
searchForMissingAlbums: boolean;
}
export interface LidarrMusic {
@ -19,7 +31,6 @@ export interface LidarrMusic {
isAvailable: boolean;
monitored: boolean;
mbId: number;
imdbId: string;
titleSlug: string;
folderName: string;
path: string;
@ -29,188 +40,351 @@ 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 {
addOptions?: { monitor: string; searchForMissingAlbums: boolean };
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 }> {
static lastArtistsUpdate = 0;
static artists: LidarrArtist[] = [];
static delay = 1000 * 60;
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' });
if (LidarrAPI.lastArtistsUpdate < Date.now() - LidarrAPI.delay) {
this.getArtists();
}
}
public getMusics = async (): Promise<LidarrMusic[]> => {
public getArtists = (): void => {
try {
const response = await this.axios.get<LidarrMusic[]>('/music');
LidarrAPI.lastArtistsUpdate = Date.now();
this.axios.get<LidarrArtist[]>('/artist').then((response) => {
LidarrAPI.artists = response.data;
});
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve artists: ${e.message}`);
}
};
return response.data;
public getArtist = async (id: string | number): Promise<LidarrArtist> => {
try {
if (LidarrAPI.lastArtistsUpdate < Date.now() - LidarrAPI.delay) {
this.getArtists();
}
if (typeof id === 'number') {
const result = LidarrAPI.artists.find((artist) => artist.id === id);
if (result) {
return result;
}
throw new Error(`Artist not found (using Lidarr Id): ${id}`);
}
const result = LidarrAPI.artists.find(
(artist) => artist.foreignArtistId === id
);
if (result) {
return result;
}
const artist = await this.getArtistByMusicBrainzId(id);
if (artist) {
return artist;
}
throw new Error(`Artist not found (using MusicBrainz Id): ${id}`);
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve musics: ${e.message}`);
throw new Error(`[Lidarr] ${e.message}`);
}
};
public getMusic = async ({ id }: { id: number }): Promise<LidarrMusic> => {
public async getArtistByMusicBrainzId(mbId: string): Promise<LidarrArtist> {
try {
const response = await this.axios.get<LidarrMusic>(`/music/${id}`);
const response = await this.axios.get<LidarrArtist[]>('/artist/lookup', {
params: {
term: `mbid:` + mbId,
},
});
if (!response.data[0]) {
throw new Error('Artist not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving artist by MusicBrainz ID', {
label: 'Midarr API',
errorMessage: e.message,
mbId: mbId,
});
throw new Error('Artist not found');
}
}
public getAlbums = async (): Promise<LidarrAlbum[]> => {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album');
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve music: ${e.message}`);
throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`);
}
};
public async getMusicBymbId(id: number): Promise<LidarrMusic> {
public async getAlbum({
artistId,
albumId,
}: {
artistId?: number;
foreignAlbumId?: string;
albumId?: number;
}): Promise<LidarrAlbum[]> {
try {
const response = await this.axios.get<LidarrMusic[]>('/music/lookup', {
const response = await this.axios.get<LidarrAlbum[]>('/album', {
params: {
term: `musicbrainz:${id}`,
artistId,
albumId,
},
});
return response.data;
} catch (e) {
throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`);
}
}
public async getAlbumByMusicBrainzId(mbId: string): Promise<LidarrAlbum> {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album/lookup', {
params: {
term: `mbid:` + mbId,
},
});
if (!response.data[0]) {
throw new Error('Music not found');
throw new Error('Album not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving music by MUSICBRAINZ ID', {
label: 'Lidarr API',
logger.error('Error retrieving album by MusicBrainz ID', {
label: 'Midarr 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);
const album = await this.getAlbumByMusicBrainzId(options.mbId);
if (music.hasFile) {
if (album.id) {
logger.info(
'Title already exists and is available. Skipping add and returning success',
{
label: 'Lidarr',
music,
}
'Album is already monitored in Lidarr. Starting search for download.',
{ label: 'Lidarr' }
);
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,
},
this.axios.post(`/command`, {
name: 'AlbumSearch',
albumIds: [album.id],
});
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');
}
return album;
}
if (music.id) {
logger.info(
'Music is already monitored in Lidarr. Skipping add and returning success',
{ label: 'Lidarr' }
);
return music;
}
const artist = album.artist;
const response = await this.axios.post<LidarrMusic>(`/music`, {
artist.monitored = true;
artist.monitorNewItems = 'all';
artist.qualityProfileId = options.qualityProfileId;
artist.rootFolderPath = options.rootFolderPath;
artist.addOptions = {
monitor: 'none',
searchForMissingAlbums: true,
};
album.anyReleaseOk = true;
const response = await this.axios.post<LidarrAlbum>(`/album/`, {
...album,
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,
foreignAlbumId: options.mbId.toString(),
tags: options.tags,
monitored: options.monitored,
artist: artist,
rootFolderPath: options.rootFolderPath,
addOptions: {
searchForMusic: options.searchNow,
searchForNewAlbum: 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', {
logger.error('Failed to add album to Lidarr', {
label: 'Lidarr',
options,
});
throw new Error('Failed to add music to Lidarr');
throw new Error('Failed to add album 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,
});
public addArtist = async (
options: LidarrArtistOptions
): Promise<LidarrArtist> => {
try {
await this.runCommand('MusicsSearch', { musicIds: [musicId] });
const artist = await this.getArtistByMusicBrainzId(options.mbId);
if (artist.id) {
logger.info('Artist is already monitored in Lidarr. Skipping add.', {
label: 'Lidarr',
artistId: artist.id,
artistName: artist.artistName,
});
return artist;
}
const response = await this.axios.post<LidarrArtist>('/artist', {
...artist,
qualityProfileId: options.qualityProfileId,
metadataProfileId: options.profileId,
monitored: true,
monitorNewItems: options.monitorNewItems,
rootFolderPath: options.rootFolderPath,
addOptions: {
monitor: options.monitor,
searchForMissingAlbums: options.searchForMissingAlbums,
},
});
if (response.data.id) {
logger.info('Lidarr accepted request', { label: 'Lidarr' });
} else {
logger.error('Failed to add artist to Lidarr', {
label: 'Lidarr',
mbId: options.mbId,
});
throw new Error('Failed to add artist to Lidarr');
}
return response.data;
} catch (e) {
logger.error(
'Something went wrong while executing Lidarr music search.',
{
label: 'Lidarr API',
errorMessage: e.message,
musicId,
}
);
logger.error('Error adding artist by MUSICBRAINZ ID', {
label: 'Lidarr API',
errorMessage: e.message,
mbId: options.mbId,
});
throw new Error(`[Lidarr] Failed to add artist: ${options.mbId}`);
}
}
};
}
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',
};

@ -11,6 +11,14 @@ export enum MediaType {
MUSIC = 'music',
}
export enum SecondaryType {
ARTIST = 'artist',
RELEASE_GROUP = 'release-group',
RELEASE = 'release',
RECORDING = 'recording',
WORK = 'work',
}
export enum MediaStatus {
UNKNOWN = 1,
PENDING,

@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
@ -20,25 +21,35 @@ import {
import Issue from './Issue';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
import LidarrAPI from '@server/api/servarr/lidarr';
@Entity()
class Media {
public static async getRelatedMedia(
tmdbIds: number | number[]
tmdbIds: number | number[] = [],
mbIds: string | string[] = []
): Promise<Media[]> {
const mediaRepository = getRepository(Media);
try {
let finalIds: number[];
let finalTmdbIds: number[] = [];
if (!Array.isArray(tmdbIds)) {
finalIds = [tmdbIds];
finalTmdbIds = [tmdbIds];
} else {
finalTmdbIds = tmdbIds;
}
let finalMusicBrainzIds: string[] = [];
if (!Array.isArray(mbIds)) {
finalMusicBrainzIds = [mbIds];
} else {
finalIds = tmdbIds;
finalMusicBrainzIds = mbIds;
}
const media = await mediaRepository.find({
where: { tmdbId: In(finalIds) },
where: [
{ tmdbId: In(finalTmdbIds) },
{ mbId: In(finalMusicBrainzIds) },
],
});
return media;
@ -49,18 +60,43 @@ class Media {
}
public static async getMedia(
id: number,
id: number | string,
mediaType: MediaType
): Promise<Media | undefined> {
const mediaRepository = getRepository(Media);
try {
const media = await mediaRepository.findOne({
where: { tmdbId: id, mediaType },
let media: Media | null = null;
if (mediaType === MediaType.MOVIE || mediaType === MediaType.TV) {
media = await mediaRepository.findOne({
where: { tmdbId: Number(id), mediaType },
relations: { requests: true, issues: true },
});
} else if (mediaType === MediaType.MUSIC) {
media = await mediaRepository.findOne({
where: { mbId: String(id), mediaType },
relations: { requests: true, issues: true },
});
}
return media ?? undefined;
} catch (e) {
logger.error(e.message);
return undefined;
}
}
public static async getChildMedia(
parentId: number
): Promise<Media[] | undefined> {
const mediaRepository = getRepository(Media);
try {
const media = await mediaRepository.find({
where: { parentRatingKey: parentId },
relations: { requests: true, issues: true },
});
return media ?? undefined;
return media;
} catch (e) {
logger.error(e.message);
return undefined;
@ -73,13 +109,16 @@ class Media {
@Column({ type: 'varchar' })
public mediaType: MediaType;
@Column({ type: 'varchar', nullable: true })
public secondaryType?: string;
@Column({ nullable: true })
@Index()
public tmdbId: number;
public tmdbId?: number;
@Column({ nullable: true })
@Index()
public mbId: number;
public mbId?: string;
@Column({ nullable: true })
@Index()
@ -147,6 +186,12 @@ class Media {
@Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null;
@Column({ nullable: true, type: 'varchar' })
public title?: string;
@Column({ nullable: true, type: 'varchar' })
public parentRatingKey?: number;
public serviceUrl?: string;
public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = [];

@ -1,3 +1,10 @@
import MusicBrainz from '@server/api/musicbrainz';
import type { mbRelease } from '@server/api/musicbrainz/interfaces';
import type {
LidarrAlbumOptions,
LidarrArtistOptions,
} 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';
import type {
@ -5,18 +12,21 @@ 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 type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
SecondaryType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import type {
MusicRequestBody,
TvRequestBody,
VideoRequestBody,
} from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
@ -51,7 +61,7 @@ type MediaRequestOptions = {
@Entity()
export class MediaRequest {
public static async request(
requestBody: MediaRequestBody,
requestBody: VideoRequestBody | TvRequestBody | MusicRequestBody,
user: User,
options: MediaRequestOptions = {}
): Promise<MediaRequest> {
@ -115,6 +125,18 @@ export class MediaRequest {
requestBody.is4k ? '4K ' : ''
}series requests.`
);
} else if (
requestBody.mediaType === MediaType.MUSIC &&
!requestUser.hasPermission(
[Permission.REQUEST, Permission.REQUEST_MUSIC],
{
type: 'or',
}
)
) {
throw new RequestPermissionError(
`You do not have permission to make music requests.`
);
}
const quotas = await requestUser.getQuota();
@ -123,49 +145,118 @@ export class MediaRequest {
throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.');
} else if (
requestBody.mediaType === MediaType.MUSIC &&
quotas.music.restricted
) {
throw new QuotaRestrictedError('Music Quota exceeded.');
}
const tmdbMedia =
const metaMedia =
requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId })
: requestBody.mediaType === MediaType.MUSIC
? requestBody.secondaryType === SecondaryType.RELEASE
? await musicbrainz.getRelease(requestBody.mediaId)
: await musicbrainz.getArtist(requestBody.mediaId)
: await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = await mediaRepository.findOne({
where: {
tmdbId: requestBody.mediaId,
mediaType: requestBody.mediaType,
},
relations: ['requests'],
});
let media =
requestBody.mediaType === MediaType.MUSIC
? await mediaRepository.findOne({
where: {
mbId: requestBody.mediaId,
mediaType: MediaType.MUSIC,
secondaryType: requestBody.secondaryType,
},
relations: ['requests'],
})
: await mediaRepository.findOne({
where: {
tmdbId: Number(metaMedia.id), // Convert tmdbId to number
mediaType: requestBody.mediaType,
},
relations: ['requests'],
});
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
} else {
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
media.status = MediaStatus.PENDING;
if (requestBody.mediaType === MediaType.MUSIC) {
media = new Media({
mbId: requestBody.mediaId,
status: MediaStatus.PENDING,
mediaType: MediaType.MUSIC,
secondaryType: requestBody.secondaryType,
title: (metaMedia as mbRelease).title,
});
} else if (requestBody.mediaType === MediaType.MOVIE) {
media = new Media({
tmdbId: requestBody.mediaId,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k
? MediaStatus.PENDING
: MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
} else {
let tvdbId: number | undefined;
if (requestBody.mediaType === MediaType.TV) {
const tvMedia = metaMedia as TmdbTvDetails;
tvdbId = tvMedia.external_ids?.tvdb_id;
}
media = new Media({
tmdbId: requestBody.mediaId,
tvdbId: (requestBody as TvRequestBody).tvdbId ?? tvdbId,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: requestBody.is4k
? MediaStatus.PENDING
: MediaStatus.UNKNOWN,
mediaType: requestBody.mediaType,
});
}
} else {
if (media.mediaType !== MediaType.MUSIC) {
if (
media.status === MediaStatus.UNKNOWN &&
!(requestBody as VideoRequestBody | TvRequestBody).is4k
) {
media.status = MediaStatus.PENDING;
}
if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) {
media.status4k = MediaStatus.PENDING;
if (
media.status4k === MediaStatus.UNKNOWN &&
(requestBody as VideoRequestBody | TvRequestBody).is4k
) {
media.status4k = MediaStatus.PENDING;
}
} else {
if (media.status === MediaStatus.UNKNOWN) {
media.status = MediaStatus.PENDING;
}
}
}
const existing = await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType,
})
.getMany();
const existing =
requestBody.mediaType !== MediaType.MUSIC
? await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('request.is4k = :is4k', { is4k: requestBody.is4k })
.andWhere('media.tmdbId = :tmdbId', { tmdbId: metaMedia.id })
.andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType,
})
.getMany()
: await requestRepository
.createQueryBuilder('request')
.leftJoin('request.media', 'media')
.leftJoinAndSelect('request.requestedBy', 'user')
.where('media.mbId = :mbId', { mbId: requestBody.mediaId })
.andWhere('media.mediaType = :mediaType', {
mediaType: requestBody.mediaType,
})
.getMany();
if (existing && existing.length > 0) {
// If there is an existing movie request that isn't declined, don't allow a new one.
@ -174,7 +265,7 @@ export class MediaRequest {
existing[0].status !== MediaRequestStatus.DECLINED
) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
tmdbId: metaMedia.id,
mediaType: requestBody.mediaType,
is4k: requestBody.is4k,
label: 'Media Request',
@ -244,16 +335,17 @@ export class MediaRequest {
await requestRepository.save(request);
return request;
} else {
const tmdbMediaShow = tmdbMedia as Awaited<
} else if (requestBody.mediaType === MediaType.TV) {
const metaMediaShow = metaMedia as Awaited<
ReturnType<typeof tmdb.getTvShow>
>;
const requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
(requestBody as TvRequestBody).seasons === 'all'
? metaMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
: (requestBody.seasons as number[]);
: ((requestBody as TvRequestBody).seasons as number[]);
let existingSeasons: number[] = [];
// We need to check existing requests on this title to make sure we don't double up on seasons that were
@ -366,6 +458,44 @@ export class MediaRequest {
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
return request;
} else {
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MUSIC,
secondaryType: (requestBody as MusicRequestBody).secondaryType,
media,
requestedBy: requestUser,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: user.hasPermission(
[
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MUSIC,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: user.hasPermission(
[
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MUSIC,
Permission.MANAGE_REQUESTS,
],
{ type: 'or' }
)
? user
: undefined,
serverId: requestBody.serverId,
profileId: requestBody.profileId,
rootFolder: requestBody.rootFolder,
tags: requestBody.tags,
isAutoRequest: options.isAutoRequest ?? false,
});
await requestRepository.save(request);
return request;
}
@ -406,6 +536,9 @@ export class MediaRequest {
@Column({ type: 'varchar' })
public type: MediaType;
@Column({ type: 'varchar', nullable: true })
public secondaryType?: SecondaryType;
@RelationCount((request: MediaRequest) => request.seasons)
public seasonCount: number;
@ -471,7 +604,11 @@ export class MediaRequest {
@AfterUpdate()
@AfterInsert()
public async sendMedia(): Promise<void> {
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]);
await Promise.all([
this.sendToRadarr(),
this.sendToSonarr(),
this.sendToLidarr(),
]);
}
@AfterInsert()
@ -753,7 +890,9 @@ export class MediaRequest {
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
const movie = await tmdb.getMovie({
movieId: Number(this.media.tmdbId),
});
const media = await mediaRepository.findOne({
where: { id: this.media.id },
@ -970,7 +1109,7 @@ export class MediaRequest {
apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const series = await tmdb.getTvShow({ tvId: Number(media.tmdbId) });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) {
@ -1261,10 +1400,8 @@ export class MediaRequest {
const musicbrainz = new MusicBrainz();
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v3'),
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
const music = await musicbrainz.getMusic({ mbId: this.media.mbId });
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
@ -1310,9 +1447,7 @@ export class MediaRequest {
}
}
if (
media['status'] === MediaStatus.AVAILABLE
) {
if (media['status'] === MediaStatus.AVAILABLE) {
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
@ -1325,56 +1460,110 @@ export class MediaRequest {
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,
};
if (this.media.secondaryType === SecondaryType.RELEASE) {
const release = await musicbrainz.getRelease(String(this.media.mbId));
const lidarrAlbumOptions: LidarrAlbumOptions = {
profileId: qualityProfile,
qualityProfileId: qualityProfile,
rootFolderPath: rootFolder,
title: release.title,
mbId: release.releaseGroup?.id ?? release.id,
monitored: true,
tags: tags.map((tag) => String(tag)),
searchNow: !lidarrSettings.preventSearch,
};
// Run this asynchronously so we don't wait for it on the UI side
lidarr
.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 },
});
if (!media) {
throw new Error('Media data not found');
}
// 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 },
media['externalServiceId'] = lidarrAlbum.id;
media['externalServiceSlug'] = lidarrAlbum.disambiguation;
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,
lidarrAlbumOptions,
}
);
this.sendNotification(media, Notification.MEDIA_FAILED);
});
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,
} else if (this.media.secondaryType === SecondaryType.ARTIST) {
const artist = await musicbrainz.getArtist(String(this.media.mbId));
const lidarrArtistOptions: LidarrArtistOptions = {
profileId: qualityProfile,
qualityProfileId: qualityProfile,
rootFolderPath: rootFolder,
mbId: artist.id,
monitored: true,
tags: tags.map((tag) => String(tag)),
searchNow: !lidarrSettings.preventSearch,
monitorNewItems: 'all',
monitor: 'all',
searchForMissingAlbums: true,
};
// Run this asynchronously so we don't wait for it on the UI side
lidarr
.addArtist(lidarrArtistOptions)
.then(async (lidarrArtist) => {
// 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');
}
);
this.sendNotification(media, Notification.MEDIA_FAILED);
});
media['externalServiceId'] = lidarrArtist.id;
media['externalServiceSlug'] = lidarrArtist.disambiguation;
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,
lidarrArtistOptions,
}
);
this.sendNotification(media, Notification.MEDIA_FAILED);
});
}
logger.info('Sent request to Lidarr', {
label: 'Media Request',
requestId: this.id,
@ -1394,7 +1583,7 @@ export class MediaRequest {
private async sendNotification(media: Media, type: Notification) {
const tmdb = new TheMovieDb();
const musicbrainz = new MusicBrainz();
try {
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
let event: string | undefined;
@ -1429,9 +1618,8 @@ export class MediaRequest {
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
break;
}
if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
const movie = await tmdb.getMovie({ movieId: media.tmdbId as number });
notificationManager.sendNotification(type, {
media,
request: this,
@ -1450,7 +1638,7 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
} else if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
const tv = await tmdb.getTvShow({ tvId: media.tmdbId as number });
notificationManager.sendNotification(type, {
media,
request: this,
@ -1477,24 +1665,34 @@ 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
});
if (this.media.secondaryType === SecondaryType.RELEASE) {
const music = await musicbrainz.getRelease(media.mbId as string);
notificationManager.sendNotification(type, {
media,
request: this,
notifyAdmin,
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy,
event,
subject: `${music.title}${
music.date ? ` (${music.date.toLocaleDateString()})` : ''
}`,
message: music.artist.map((artist) => artist.name).join(', '),
image: `http://coverartarchive.org/release/${music.id}/front-250`,
});
} else if (this.media.secondaryType === SecondaryType.ARTIST) {
const artist = await musicbrainz.getArtist(media.mbId as string);
notificationManager.sendNotification(type, {
media,
request: this,
notifyAdmin,
notifySystem,
notifyUser: notifyAdmin ? undefined : this.requestedBy,
event,
subject: artist.name,
image: `http://coverartarchive.org/artist/${artist.id}/front-250`,
});
}
}
} catch (e) {
logger.error('Something went wrong sending media notification(s)', {

@ -103,6 +103,12 @@ export class User {
@Column({ nullable: true })
public tvQuotaDays?: number;
@Column({ nullable: true })
public musicQuotaLimit?: number;
@Column({ nullable: true })
public musicQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true,
eager: true,
@ -306,6 +312,27 @@ export class User {
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 0;
const musicQuotaLimit = !canBypass
? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit
: 0;
const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays;
const musicDate = new Date();
if (musicQuotaDays) {
musicDate.setDate(musicDate.getDate() - musicQuotaDays);
}
const musicQuotaUsed = musicQuotaLimit
? await requestRepository.count({
where: {
requestedBy: {
id: this.id,
},
createdAt: AfterDate(musicDate),
type: MediaType.MUSIC,
status: Not(MediaRequestStatus.DECLINED),
},
})
: 0;
return {
movie: {
days: movieQuotaDays,
@ -329,6 +356,18 @@ export class User {
restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false,
},
music: {
days: musicQuotaDays,
limit: musicQuotaLimit,
used: musicQuotaUsed,
remaining: musicQuotaLimit
? Math.max(0, musicQuotaLimit - musicQuotaUsed)
: undefined,
restricted:
musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0
? true
: false,
},
};
}
}

@ -6,8 +6,9 @@ export interface GenreSliderItem {
export interface WatchlistItem {
ratingKey: string;
tmdbId: number;
mediaType: 'movie' | 'tv';
tmdbId?: number;
musicBrainzId?: string;
mediaType: 'movie' | 'tv' | 'music';
title: string;
}

@ -1,4 +1,4 @@
import type { MediaType } from '@server/constants/media';
import type { MediaType, SecondaryType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { PaginatedResponse } from './common';
@ -6,16 +6,31 @@ export interface RequestResultsResponse extends PaginatedResponse {
results: MediaRequest[];
}
export type MediaRequestBody = {
interface MediaRequestBody {
mediaType: MediaType;
mediaId: number;
tvdbId?: number;
seasons?: number[] | 'all';
is4k?: boolean;
mediaId: number | string;
serverId?: number;
profileId?: number;
rootFolder?: string;
languageProfileId?: number;
userId?: number;
tags?: number[];
};
}
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;
}
export interface MusicRequestBody extends MediaRequestBody {
secondaryType: SecondaryType;
mediaType: MediaType.MUSIC;
mediaId: string;
}

@ -4,7 +4,7 @@ import type { LanguageProfile } from '@server/api/servarr/sonarr';
export interface ServiceCommonServer {
id: number;
name: string;
is4k: boolean;
is4k?: boolean;
isDefault: boolean;
activeProfileId: number;
activeDirectory: string;
@ -12,7 +12,7 @@ export interface ServiceCommonServer {
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeTags: number[];
activeTags: number[] | string[];
activeAnimeTags?: number[];
}

@ -33,6 +33,7 @@ export interface PublicSettingsResponse {
partialRequestsEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
fallbackImage: string;
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;

@ -22,6 +22,7 @@ export interface QuotaStatus {
export interface QuotaResponse {
movie: QuotaStatus;
tv: QuotaStatus;
music: QuotaStatus;
}
export interface UserWatchDataResponse {

@ -1,6 +1,7 @@
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import { lidarrScanner } from '@server/lib/scanners/lidarr';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr';
@ -116,7 +117,22 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr libs
// Run full lidarr scan every 24 hours
scheduledJobs.push({
id: 'lidarr-scan',
name: 'Lidarr Scan',
type: 'process',
interval: 'hours',
cronSchedule: jobs['lidarr-scan'].schedule,
job: schedule.scheduleJob(jobs['lidarr-scan'].schedule, () => {
logger.info('Starting scheduled job: Lidarr Scan', { label: 'Jobs' });
lidarrScanner.run();
}),
running: () => lidarrScanner.status().running,
cancelFn: () => lidarrScanner.cancel(),
});
// Checks if media is still available in plex/sonarr/radarr/lidarr libs
scheduledJobs.push({
id: 'availability-sync',
name: 'Media Availability Sync',

@ -286,16 +286,20 @@ class AvailabilitySync {
id: media.id,
})
.andWhere(
`(request.is4k = :is4k AND media.${
is4k ? 'status4k' : 'status'
} IN (:...mediaStatus))`,
{
mediaStatus: [
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
],
is4k: is4k,
}
['show', 'movie'].includes(media.mediaType)
? `(request.is4k = :is4k AND media.${
is4k ? 'status4k' : 'status'
} IN (:...mediaStatus))`
: '',
['show', 'movie'].includes(media.mediaType)
? {
mediaStatus: [
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
],
is4k: is4k,
}
: {}
)
.getMany();

@ -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' }
);
}

@ -8,24 +8,27 @@ export enum Permission {
AUTO_APPROVE = 128,
AUTO_APPROVE_MOVIE = 256,
AUTO_APPROVE_TV = 512,
REQUEST_4K = 1024,
REQUEST_4K_MOVIE = 2048,
REQUEST_4K_TV = 4096,
REQUEST_ADVANCED = 8192,
REQUEST_VIEW = 16384,
AUTO_APPROVE_4K = 32768,
AUTO_APPROVE_4K_MOVIE = 65536,
AUTO_APPROVE_4K_TV = 131072,
REQUEST_MOVIE = 262144,
REQUEST_TV = 524288,
MANAGE_ISSUES = 1048576,
VIEW_ISSUES = 2097152,
CREATE_ISSUES = 4194304,
AUTO_REQUEST = 8388608,
AUTO_REQUEST_MOVIE = 16777216,
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
AUTO_APPROVE_MUSIC = 268_435_456,
REQUEST_4K = 1_024,
REQUEST_4K_MOVIE = 2_048,
REQUEST_4K_TV = 4_096,
REQUEST_ADVANCED = 8_192,
REQUEST_VIEW = 16_384,
AUTO_APPROVE_4K = 32_768,
AUTO_APPROVE_4K_MOVIE = 65_536,
AUTO_APPROVE_4K_TV = 131_072,
REQUEST_MOVIE = 262_144,
REQUEST_TV = 524_288,
REQUEST_MUSIC = 536_870_912,
MANAGE_ISSUES = 1_048_576,
VIEW_ISSUES = 2_097_152,
CREATE_ISSUES = 4_194_304,
AUTO_REQUEST = 8_388_608,
AUTO_REQUEST_MOVIE = 16_777_216,
AUTO_REQUEST_TV = 33_554_432,
AUTO_REQUEST_MUSIC = 1_073_741_824,
RECENT_VIEW = 67_108_864,
WATCHLIST_VIEW = 134_217_728,
}
export interface PermissionCheckOptions {

@ -1,5 +1,6 @@
import type { LidarrRelease } from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaStatus, MediaType, SecondaryType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
@ -24,7 +25,8 @@ export interface RunnableScanner<T> {
}
export interface MediaIds {
tmdbId: number;
tmdbId?: number;
mbId?: string;
imdbId?: string;
tvdbId?: number;
isHama?: boolean;
@ -39,6 +41,11 @@ interface ProcessOptions {
externalServiceSlug?: string;
title?: string;
processing?: boolean;
parentRatingKey?: string;
}
interface ProcessGroupOptions extends ProcessOptions {
releases?: LidarrRelease[];
}
export interface ProcessableSeason {
@ -79,13 +86,19 @@ class BaseScanner<T> {
this.updateRate = updateRate ?? UPDATE_RATE;
}
private async getExisting(tmdbId: number, mediaType: MediaType) {
private async getExisting(id: number | string, mediaType: MediaType) {
const mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({
where: { tmdbId: tmdbId, mediaType },
});
let existing: Media | null;
if (mediaType === MediaType.MOVIE || mediaType === MediaType.TV) {
existing = await mediaRepository.findOne({
where: { tmdbId: id as number, mediaType },
});
} else {
existing = await mediaRepository.findOne({
where: { mbId: id as string, mediaType },
});
}
return existing;
}
@ -110,8 +123,8 @@ class BaseScanner<T> {
if (existing) {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing
if (existing['status'] !== MediaStatus.AVAILABLE) {
existing['status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
@ -125,29 +138,21 @@ class BaseScanner<T> {
changedExisting = true;
}
if (
ratingKey &&
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
) {
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
if (ratingKey && existing['ratingKey'] !== ratingKey) {
existing['ratingKey'] = ratingKey;
changedExisting = true;
}
if (
serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
) {
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
if (serviceId !== undefined && existing['serviceId'] !== serviceId) {
existing['serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !==
externalServiceId
existing['externalServiceId'] !== externalServiceId
) {
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
existing['externalServiceId'] = externalServiceId;
changedExisting = true;
}
@ -384,12 +389,11 @@ class BaseScanner<T> {
}
if (serviceId !== undefined) {
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
media['serviceId'] = serviceId;
}
if (externalServiceId !== undefined) {
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] =
externalServiceId;
media['externalServiceId'] = externalServiceId;
}
if (externalServiceSlug !== undefined) {
@ -505,6 +509,289 @@ class BaseScanner<T> {
});
}
protected async processArtist(
mbId: string,
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
processing = false,
title = 'Unknown Title',
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(mbId, async () => {
const existing = await this.getExisting(mbId, MediaType.MUSIC);
if (existing) {
let changedExisting = false;
if (existing['status'] !== MediaStatus.AVAILABLE) {
existing['status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (ratingKey && existing['ratingKey'] !== ratingKey) {
existing['ratingKey'] = ratingKey;
changedExisting = true;
}
if (serviceId !== undefined && existing['serviceId'] !== serviceId) {
existing['serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing['externalServiceId'] !== externalServiceId
) {
existing['externalServiceId'] = externalServiceId;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changes were detected and the title will be updated.`,
'info'
);
} else {
this.log(`Title already exists and no changes detected for ${title}`);
}
} else {
const newMedia = new Media();
newMedia.mbId = mbId;
newMedia.title = title;
newMedia.secondaryType = SecondaryType.ARTIST;
newMedia.status = !processing
? MediaStatus.AVAILABLE
: processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MUSIC;
newMedia.serviceId = serviceId;
newMedia.externalServiceId = externalServiceId;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = ratingKey;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
protected async processAlbum(
mbId: string,
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
processing = false,
title = 'Unknown Title',
parentRatingKey = undefined,
}: ProcessOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(mbId, async () => {
const existing = await this.getExisting(mbId, MediaType.MUSIC);
if (existing) {
let changedExisting = false;
if (existing['status'] !== MediaStatus.AVAILABLE) {
existing['status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (ratingKey && existing['ratingKey'] !== ratingKey) {
existing['ratingKey'] = ratingKey;
changedExisting = true;
}
if (serviceId !== undefined && existing['serviceId'] !== serviceId) {
existing['serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing['externalServiceId'] !== externalServiceId
) {
existing['externalServiceId'] = externalServiceId;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changes were detected and the title will be updated.`,
'info'
);
} else {
this.log(`Title already exists and no changes detected for ${title}`);
}
} else {
const newMedia = new Media();
newMedia.mbId = mbId;
newMedia.title = title;
newMedia.parentRatingKey = parentRatingKey
? Number(parentRatingKey.match(/(\d+)/)?.[0])
: undefined;
newMedia.secondaryType = SecondaryType.RELEASE;
newMedia.status = !processing
? MediaStatus.AVAILABLE
: processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MUSIC;
newMedia.serviceId = serviceId;
newMedia.externalServiceId = externalServiceId;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = ratingKey;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
protected async processGroup(
mbId: string,
{
mediaAddedAt,
ratingKey,
serviceId,
externalServiceId,
processing = false,
title = 'Unknown Title',
parentRatingKey = undefined,
releases = [],
}: ProcessGroupOptions = {}
): Promise<void> {
const mediaRepository = getRepository(Media);
await this.asyncLock.dispatch(mbId, async () => {
const existings = (
await Promise.all(
releases.map((release) =>
this.getExisting(release.foreignReleaseId, MediaType.MUSIC)
)
)
).filter((existing) => existing !== null) as Media[];
if (existings.length > 0) {
for (const existing of existings) {
let changedExisting = false;
if (existing['status'] !== MediaStatus.AVAILABLE) {
existing['status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
changedExisting = true;
}
if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
changedExisting = true;
}
if (ratingKey && existing['ratingKey'] !== ratingKey) {
existing['ratingKey'] = ratingKey;
changedExisting = true;
}
if (serviceId !== undefined && existing['serviceId'] !== serviceId) {
existing['serviceId'] = serviceId;
changedExisting = true;
}
if (
externalServiceId !== undefined &&
existing['externalServiceId'] !== externalServiceId
) {
existing['externalServiceId'] = externalServiceId;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Media for ${title} exists. Changes were detected and the title will be updated.`,
'info'
);
} else {
this.log(
`Title already exists and no changes detected for ${title}`
);
}
}
} else {
const newMedia = new Media();
newMedia.mbId = mbId;
newMedia.title = title;
newMedia.parentRatingKey = parentRatingKey
? Number(parentRatingKey.match(/(\d+)/)?.[0])
: undefined;
newMedia.secondaryType = SecondaryType.RELEASE;
newMedia.status = !processing
? MediaStatus.AVAILABLE
: processing
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MUSIC;
newMedia.serviceId = serviceId;
newMedia.externalServiceId = externalServiceId;
if (mediaAddedAt) {
newMedia.mediaAddedAt = mediaAddedAt;
}
if (ratingKey) {
newMedia.ratingKey = ratingKey;
}
await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`);
}
});
}
/**
* Call startRun from child class whenever a run is starting to
* ensure required values are set

@ -0,0 +1,109 @@
import type { LidarrAlbum } from '@server/api/servarr/lidarr';
import LidarrAPI from '@server/api/servarr/lidarr';
import type {
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { LidarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { uniqWith } from 'lodash';
type SyncStatus = StatusBase & {
currentServer: LidarrSettings;
servers: LidarrSettings[];
};
class LidarrScanner
extends BaseScanner<LidarrAlbum>
implements RunnableScanner<SyncStatus>
{
private servers: LidarrSettings[];
private currentServer: LidarrSettings;
private lidarrApi: LidarrAPI;
constructor() {
super('Lidarr Scan', { bundleSize: 50 });
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public async run(): Promise<void> {
const settings = getSettings();
const sessionId = this.startRun();
try {
this.servers = uniqWith(settings.lidarr, (lidarrA, lidarrB) => {
return (
lidarrA.hostname === lidarrB.hostname &&
lidarrA.port === lidarrB.port &&
lidarrA.baseUrl === lidarrB.baseUrl
);
});
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Lidarr server: ${server.name}`,
'info'
);
this.lidarrApi = new LidarrAPI({
apiKey: server.apiKey,
url: LidarrAPI.buildUrl(server, '/api/v1'),
});
this.items = await this.lidarrApi.getAlbums();
await this.loop(this.processLidarrAlbum.bind(this), { sessionId });
} else {
this.log(`Sync not enabled. Skipping Lidarr server: ${server.name}`);
}
}
this.log('Lidarr scan complete', 'info');
} catch (e) {
this.log('Scan interrupted', 'error', { errorMessage: e.message });
} finally {
this.endRun(sessionId);
}
}
private async processLidarrAlbum(lidarrAlbum: LidarrAlbum): Promise<void> {
if (!lidarrAlbum.monitored && !lidarrAlbum.anyReleaseOk) {
this.log(
'Title is unmonitored and has not been downloaded. Skipping item.',
'debug',
{
title: lidarrAlbum.title,
}
);
return;
}
try {
await this.processGroup(lidarrAlbum.foreignAlbumId, {
serviceId: this.currentServer.id,
externalServiceId: lidarrAlbum.id,
title: lidarrAlbum.title,
processing: !lidarrAlbum.anyReleaseOk,
releases: lidarrAlbum.releases,
});
} catch (e) {
this.log('Failed to process Lidarr media', 'error', {
errorMessage: e.message,
title: lidarrAlbum.title,
});
}
}
}
export const lidarrScanner = new LidarrScanner();

@ -19,6 +19,7 @@ import { uniqWith } from 'lodash';
const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/);
const mbRegex = new RegExp(/mbid:\/\/([0-9a-f-]+)/);
const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here:
@ -135,7 +136,13 @@ class PlexScanner
for (const library of this.libraries) {
this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info');
await this.paginateLibrary(library, { sessionId });
try {
await this.paginateLibrary(library, { sessionId });
} catch (e) {
this.log('Failed to paginate library', 'error', {
errorMessage: e.message,
});
}
}
}
this.log(
@ -164,12 +171,16 @@ class PlexScanner
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
const response = await this.plexClient.getLibraryContents(library.id, {
size: this.protectedBundleSize,
offset: start,
});
const response =
library.type === 'artist'
? await this.plexClient.getMusicLibraryContents(library.id, {
size: this.protectedBundleSize,
offset: start,
})
: await this.plexClient.getLibraryContents(library.id, {
size: this.protectedBundleSize,
offset: start,
});
this.progress = start;
this.totalSize = response.totalSize;
@ -209,6 +220,10 @@ class PlexScanner
plexitem.type === 'season'
) {
await this.processPlexShow(plexitem);
} else if (plexitem.type === 'artist') {
await this.processPlexArtist(plexitem);
} else if (plexitem.type === 'album') {
await this.processPlexAlbum(plexitem);
}
} catch (e) {
this.log('Failed to process Plex media', 'error', {
@ -224,13 +239,18 @@ class PlexScanner
const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k'
);
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
if (mediaIds.tmdbId) {
await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
} else {
this.log('No TMDB ID found for movie', 'warn', {
title: plexitem.title,
});
}
}
private async processPlexMovieByTmdbId(
@ -273,7 +293,9 @@ class PlexScanner
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
}
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
const tvShow = await this.tmdb.getTvShow({
tvId: mediaIds.tmdbId as number,
});
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
@ -322,7 +344,7 @@ class PlexScanner
if (mediaIds.tvdbId) {
await this.processShow(
mediaIds.tmdbId,
mediaIds.tmdbId as number,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons,
{
@ -334,6 +356,37 @@ class PlexScanner
}
}
private async processPlexArtist(plexitem: PlexLibraryItem) {
const mediaIds = await this.getMediaIds(plexitem);
if (mediaIds.mbId) {
await this.processArtist(mediaIds.mbId, {
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
});
} else {
this.log('No MusicBrainz ID found for artist', 'warn', {
title: plexitem.title,
});
}
}
private async processPlexAlbum(plexitem: PlexLibraryItem) {
const mediaIds = await this.getMediaIds(plexitem);
if (mediaIds.mbId) {
await this.processAlbum(mediaIds.mbId, {
mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey,
title: plexitem.title,
parentRatingKey: plexitem.parentRatingKey,
});
} else {
this.log('No MusicBrainz ID found for album', 'warn', {
title: plexitem.title,
});
}
}
private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
let mediaIds: Partial<MediaIds> = {};
// Check if item is using new plex movie/tv agent
@ -372,6 +425,8 @@ class PlexScanner
} else if (ref.id.match(tvdbRegex)) {
const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
mediaIds.tvdbId = Number(tvdbMatch);
} else if (ref.id.match(mbRegex)) {
mediaIds.mbId = ref.id.match(mbRegex)?.[1] ?? undefined;
}
});
@ -487,10 +542,16 @@ class PlexScanner
}
}
}
// Check for MusicBrainz
} else if (plexitem.guid.match(mbRegex)) {
const mbMatch = plexitem.guid.match(mbRegex);
if (mbMatch) {
mediaIds.mbId = mbMatch[1];
}
}
if (!mediaIds.tmdbId) {
throw new Error('Unable to find TMDB ID');
if (!mediaIds.tmdbId && !mediaIds.mbId) {
throw new Error('Unable to find either a TMDB ID or a MB ID');
}
// We check above if we have the TMDB ID, so we can safely assert the type below

@ -1,3 +1,4 @@
import MusicBrainz from '@server/api/musicbrainz';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieDetails,
@ -10,6 +11,7 @@ import type {
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import type { MbSearchMultiResponse } from '@server/models/Search';
import {
mapMovieDetailsToResult,
mapPersonDetailsToResult,
@ -31,7 +33,7 @@ interface SearchProvider {
id: string;
language?: string;
query?: string;
}) => Promise<TmdbSearchMultiResponse>;
}) => Promise<TmdbSearchMultiResponse | MbSearchMultiResponse>;
}
const searchProviders: SearchProvider[] = [];
@ -214,3 +216,29 @@ searchProviders.push({
};
},
});
searchProviders.push({
pattern: new RegExp(
/(?<=mb:)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
),
search: async ({ id }) => {
const mb = new MusicBrainz();
const results = [];
try {
results.push(await mb.getArtist(id));
} catch (e) {
// ignore
}
try {
results.push(await mb.getRelease(id));
} catch (e) {
// ignore
}
return {
page: 1,
total_pages: 1,
total_results: results.length,
results,
};
},
});

@ -9,7 +9,7 @@ export interface Library {
id: string;
name: string;
enabled: boolean;
type: 'show' | 'movie';
type: 'show' | 'movie' | 'artist';
lastScan?: number;
}
@ -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 {
@ -82,7 +83,7 @@ export interface SonarrSettings extends DVRSettings {
animeTags?: number[];
enableSeasonFolders: boolean;
}
export type LidarrSettings = ArrSettings;
interface Quota {
quotaLimit?: number;
@ -90,6 +91,7 @@ interface Quota {
}
export interface MainSettings {
fallbackImage: string;
apiKey: string;
applicationTitle: string;
applicationUrl: string;
@ -127,6 +129,7 @@ interface FullPublicSettings extends PublicSettings {
partialRequestsEnabled: boolean;
cacheImages: boolean;
vapidPublic: string;
fallbackImage: string;
enablePushRegistration: boolean;
locale: string;
emailEnabled: boolean;
@ -308,6 +311,7 @@ class Settings {
trustProxy: false,
partialRequestsEnabled: true,
locale: 'en',
fallbackImage: '/images/overseerr_poster_not_found_logo_top.png',
},
plex: {
name: '',
@ -528,6 +532,9 @@ class Settings {
locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled,
newPlexLogin: this.data.main.newPlexLogin,
fallbackImage:
this.data.main.fallbackImage ??
'/images/overseerr_poster_not_found_logo_top.png',
};
}

@ -10,6 +10,11 @@ import {
RequestPermissionError,
} from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
import type {
MusicRequestBody,
TvRequestBody,
VideoRequestBody,
} from '@server/interfaces/api/requestInterfaces';
import logger from '@server/logger';
import { Permission } from './permissions';
@ -45,6 +50,7 @@ class WatchlistSync {
Permission.AUTO_REQUEST,
Permission.AUTO_REQUEST_MOVIE,
Permission.AUTO_APPROVE_TV,
Permission.AUTO_REQUEST_MUSIC,
],
{ type: 'or' }
)
@ -65,7 +71,8 @@ class WatchlistSync {
const response = await plexTvApi.getWatchlist({ size: 200 });
const mediaItems = await Media.getRelatedMedia(
response.items.map((i) => i.tmdbId)
response.items.map((i) => i.tmdbId) as number[],
response.items.map((i) => i.musicBrainzId) as string[]
);
const unavailableItems = response.items.filter(
@ -74,7 +81,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))
)
);
@ -112,13 +120,17 @@ class WatchlistSync {
await MediaRequest.request(
{
mediaId: mediaItem.tmdbId,
mediaId: mediaItem.tmdbId ?? mediaItem.musicBrainzId,
mediaType:
mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE,
mediaItem.type === 'show'
? MediaType.TV
: mediaItem.type === 'movie'
? MediaType.MOVIE
: MediaType.MUSIC,
seasons: mediaItem.type === 'show' ? 'all' : undefined,
tvdbId: mediaItem.tvdbId,
is4k: false,
},
tvdbId: mediaItem.tvdbId ?? undefined,
is4k: ['movie', 'show'].includes(mediaItem.type) ? false : false,
} as MusicRequestBody | TvRequestBody | VideoRequestBody,
user,
{ isAutoRequest: true }
);

@ -14,20 +14,22 @@ export interface Collection {
parts: MovieResult[];
}
export const mapCollection = (
export const mapCollection = async (
collection: TmdbCollection,
media: Media[]
): Collection => ({
): Promise<Collection> => ({
id: collection.id,
name: collection.name,
overview: collection.overview,
posterPath: collection.poster_path,
backdropPath: collection.backdrop_path,
parts: sortBy(collection.parts, 'release_date').map((part) =>
mapMovieResult(
part,
media?.find(
(req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE
parts: await Promise.all(
sortBy(collection.parts, 'release_date').map((part) =>
mapMovieResult(
part,
media?.find(
(req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE
)
)
)
),

@ -0,0 +1,75 @@
import type Media from '@server/entity/Media';
import type {
Cast,
Crew,
ExternalIds,
Genre,
Keyword,
ProductionCompany,
WatchProviders,
} from './common';
export interface Video {
url?: string;
site: 'YouTube';
key: string;
name: string;
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface ReleaseDetails {
id: number;
imdbId?: string;
adult: boolean;
backdropPath?: string;
budget: number;
genres: Genre[];
homepage?: string;
originalLanguage: string;
originalTitle: string;
overview?: string;
popularity: number;
relatedVideos?: Video[];
posterPath?: string;
productionCompanies: ProductionCompany[];
productionCountries: {
iso_3166_1: string;
name: string;
}[];
releaseDate: string;
revenue: number;
runtime?: number;
spokenLanguages: {
iso_639_1: string;
name: string;
}[];
status: string;
tagline?: string;
title: string;
video: boolean;
voteAverage: number;
voteCount: number;
credits: {
cast: Cast[];
crew: Crew[];
};
collection?: {
id: number;
name: string;
posterPath?: string;
backdropPath?: string;
};
mediaInfo?: Media;
externalIds: ExternalIds;
plexUrl?: string;
watchProviders?: WatchProviders[];
keywords: Keyword[];
}

@ -1,3 +1,15 @@
import type {
mbArtist,
mbArtistType,
mbRecording,
mbRelease,
mbReleaseGroup,
mbReleaseGroupType,
mbWork,
} from '@server/api/musicbrainz/interfaces';
import getPosterFromMB, {
cachedFanartFromMB,
} from '@server/api/musicbrainz/poster';
import type {
TmdbCollectionResult,
TmdbMovieDetails,
@ -7,10 +19,19 @@ import type {
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
export type MediaType = 'tv' | 'movie' | 'person' | 'collection';
export type MediaType =
| 'tv'
| 'movie'
| 'music'
| 'person'
| 'collection'
| 'release-group'
| 'release'
| 'recording'
| 'work'
| 'artist';
interface SearchResult {
id: number;
@ -44,6 +65,14 @@ export interface TvResult extends SearchResult {
firstAirDate: string;
}
export interface MusicResult extends SearchResult {
mediaType: 'music';
title: string;
originalTitle: string;
releaseDate: string;
mediaInfo?: Media;
}
export interface CollectionResult {
id: number;
mediaType: 'collection';
@ -54,6 +83,7 @@ export interface CollectionResult {
backdropPath?: string;
overview: string;
originalLanguage: string;
mediaInfo?: Media;
}
export interface PersonResult {
@ -64,14 +94,107 @@ export interface PersonResult {
adult: boolean;
mediaType: 'person';
knownFor: (MovieResult | TvResult)[];
mediaInfo?: Media;
}
export interface ReleaseGroupResult {
id: string;
mediaType: 'release-group';
type: mbReleaseGroupType;
posterPath?: string;
title: string;
releases: ReleaseResult[];
artist: ArtistResult[];
tags: string[];
mediaInfo?: Media;
}
export interface ReleaseResult {
id: string;
mediaType: 'release';
title: string;
artist: ArtistResult[];
posterPath?: string;
date?: Date | string;
tracks?: RecordingResult[];
tags: string[];
mediaInfo?: Media;
releaseGroup?: ReleaseGroupResult;
}
export interface RecordingResult {
id: string;
mediaType: 'recording';
title: string;
artist: ArtistResult[];
length: number;
firstReleased?: Date;
tags: string[];
mediaInfo?: Media;
}
export interface WorkResult {
id: string;
mediaType: 'work';
title: string;
artist: ArtistResult[];
tags: string[];
mediaInfo?: Media;
}
export interface ArtistResult {
id: string;
mediaType: 'artist';
name: string;
type: mbArtistType;
releases: ReleaseResult[];
gender?: string;
area?: string;
beginDate?: string;
endDate?: string;
tags: string[];
mediaInfo?: Media;
posterPath?: string;
fanartPath?: string;
}
export type Results = MovieResult | TvResult | PersonResult | CollectionResult;
export type Results =
| MovieResult
| TvResult
| MusicResult
| PersonResult
| CollectionResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult
| ArtistResult;
export type MbSearchMultiResponse = {
page: number;
total_pages: number;
total_results: number;
results: (mbRelease | mbArtist)[];
};
export type MixedSearchResponse = {
page: number;
total_pages: number;
total_results: number;
results: (
| mbArtist
| mbRelease
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
)[];
};
export const mapMovieResult = (
export const mapMovieResult = async (
movieResult: TmdbMovieResult,
media?: Media
): MovieResult => ({
): Promise<MovieResult> => ({
id: movieResult.id,
mediaType: 'movie',
adult: movieResult.adult,
@ -90,10 +213,10 @@ export const mapMovieResult = (
mediaInfo: media,
});
export const mapTvResult = (
export const mapTvResult = async (
tvResult: TmdbTvResult,
media?: Media
): TvResult => ({
): Promise<TvResult> => ({
id: tvResult.id,
firstAirDate: tvResult.first_air_date,
genreIds: tvResult.genre_ids,
@ -112,9 +235,11 @@ export const mapTvResult = (
mediaInfo: media,
});
export const mapCollectionResult = (
collectionResult: TmdbCollectionResult
): CollectionResult => ({
export const mapCollectionResult = async (
collectionResult: TmdbCollectionResult,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media?: Media
): Promise<CollectionResult> => ({
id: collectionResult.id,
mediaType: collectionResult.media_type || 'collection',
adult: collectionResult.adult,
@ -126,22 +251,122 @@ export const mapCollectionResult = (
posterPath: collectionResult.poster_path,
});
export const mapPersonResult = (
personResult: TmdbPersonResult
): PersonResult => ({
export const mapPersonResult = async (
personResult: TmdbPersonResult,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media?: Media
): Promise<PersonResult> => ({
id: personResult.id,
name: personResult.name,
popularity: personResult.popularity,
adult: personResult.adult,
mediaType: personResult.media_type,
profilePath: personResult.profile_path,
knownFor: personResult.known_for.map((result) => {
if (result.media_type === 'movie') {
return mapMovieResult(result);
}
knownFor: await Promise.all(
personResult.known_for.map((result) => {
if (result.media_type === 'movie') {
return mapMovieResult(result);
}
return mapTvResult(result);
})
),
});
return mapTvResult(result);
}),
export const mapReleaseGroupResult = async (
releaseGroupResult: mbReleaseGroup,
media?: Media
): Promise<ReleaseGroupResult> => {
return {
id: releaseGroupResult.id,
mediaType: releaseGroupResult.media_type,
type: releaseGroupResult.type,
title: releaseGroupResult.title,
artist: await Promise.all(
releaseGroupResult.artist.map((artist) => mapArtistResult(artist))
),
releases: await Promise.all(
(releaseGroupResult.releases ?? []).map((release) =>
mapReleaseResult(release)
)
),
tags: releaseGroupResult.tags,
posterPath: await getPosterFromMB(releaseGroupResult),
mediaInfo: media ?? undefined,
};
};
export const mapArtistResult = async (
artist: mbArtist,
media?: Media
): Promise<ArtistResult> => ({
id: artist.id,
mediaType: 'artist',
name: artist.name,
type: artist.type,
releases: await Promise.all(
Array.isArray(artist.releases)
? artist.releases.map((release) => mapReleaseResult(release))
: []
),
tags: artist.tags,
mediaInfo: media ?? undefined,
posterPath: await getPosterFromMB(artist),
fanartPath: await cachedFanartFromMB(artist),
});
export const mapReleaseResult = async (
release: mbRelease,
media?: Media
): Promise<ReleaseResult> => ({
id: release.id,
mediaType: release.media_type,
title: release.title,
posterPath: await getPosterFromMB(release),
artist: await Promise.all(
release.artist.map((artist) => mapArtistResult(artist))
),
date: release.date,
tracks: await Promise.all(
Array.isArray(release.tracks)
? release.tracks.map((track) => mapRecordingResult(track))
: []
),
tags: release.tags,
releaseGroup: release.releaseGroup
? await mapReleaseGroupResult(release.releaseGroup)
: undefined,
mediaInfo: media,
});
export const mapRecordingResult = async (
recording: mbRecording,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media?: Media
): Promise<RecordingResult> => ({
id: recording.id,
mediaType: recording.media_type,
title: recording.title,
artist: await Promise.all(
recording.artist.map((artist) => mapArtistResult(artist))
),
length: recording.length,
firstReleased: recording.firstReleased,
tags: recording.tags,
});
export const mapWorkResult = async (
work: mbWork,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_media?: Media
): Promise<WorkResult> => ({
id: work.id,
mediaType: work.media_type,
title: work.title,
artist: await Promise.all(
work.artist.map((artist) => mapArtistResult(artist))
),
tags: work.tags,
});
export const mapSearchResults = (
@ -150,34 +375,60 @@ export const mapSearchResults = (
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| mbArtist
| mbRecording
| mbRelease
| mbReleaseGroup
| mbWork
)[],
media?: Media[]
): Results[] =>
results.map((result) => {
switch (result.media_type) {
case 'movie':
return mapMovieResult(
result,
media?.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE
)
);
case 'tv':
return mapTvResult(
result,
media?.find(
(req) =>
req.tmdbId === result.id && req.mediaType === MainMediaType.TV
)
);
case 'collection':
return mapCollectionResult(result);
default:
return mapPersonResult(result);
): Promise<Results[]> => {
const mediaLookup = new Map();
if (media) {
media.forEach((item) => {
mediaLookup.set(item.tmdbId || item.mbId, item);
});
}
const mapFunctions = {
movie: mapMovieResult,
tv: mapTvResult,
collection: mapCollectionResult,
person: mapPersonResult,
'release-group': mapReleaseGroupResult,
release: mapReleaseResult,
recording: mapRecordingResult,
work: mapWorkResult,
artist: mapArtistResult,
};
const transformResults = (
result:
| TmdbMovieResult
| TmdbTvResult
| TmdbPersonResult
| TmdbCollectionResult
| mbArtist
| mbRecording
| mbRelease
| mbReleaseGroup
| mbWork
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapFunction: (result: any, media?: Media) => Promise<any> =
mapFunctions[result.media_type];
if (mapFunction) {
const mediaItem = mediaLookup.get(result.id);
return mapFunction(result, mediaItem);
}
});
};
const out = Promise.all(
results.map((result) => transformResults(result)).filter((result) => result)
);
return out;
};
export const mapMovieDetailsToResult = (
movieDetails: TmdbMovieDetails
): TmdbMovieResult => ({

@ -19,7 +19,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
collection.parts.map((part) => part.id)
);
return res.status(200).json(mapCollection(collection, media));
return res.status(200).json(await mapCollection(collection, media));
} catch (e) {
logger.debug('Something went wrong retrieving collection', {
label: 'API',

@ -1,3 +1,4 @@
import MusicBrainz from '@server/api/musicbrainz';
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
@ -14,9 +15,11 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie';
import {
mapArtistResult,
mapCollectionResult,
mapMovieResult,
mapPersonResult,
mapReleaseResult,
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
@ -845,9 +848,68 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId,
musicBrainzId: item.musicBrainzId,
})),
});
}
);
discoverRoutes.get('/musics', async (req, res, next) => {
const mb = new MusicBrainz();
try {
const query = QueryFilterOptions.parse(req.query);
const results = await mb.searchMulti({
query: '',
tags: query.keywords ? decodeURIComponent(query.keywords).split(',') : [],
limit: 20,
page: Number(query.page),
});
const mbIds = results.releaseResults
.map((result) => result.id)
.concat(results.artistResults.map((result) => result.id));
const media = await Media.getRelatedMedia([], mbIds);
const resultsWithMedia = [
...(await Promise.all(
results.artistResults.map((result) => {
return mapArtistResult(
result,
media.find(
(med) =>
med.mbId === result.id &&
med.mediaType === MediaType.MUSIC &&
med.secondaryType === 'artist'
)
);
})
)),
...(await Promise.all(
results.releaseResults.map((result) => {
return mapReleaseResult(
result,
media.find(
(med) =>
med.mbId === result.id &&
med.mediaType === MediaType.MUSIC &&
med.secondaryType === 'release'
)
);
})
)),
];
return res.status(200).json({
page: query.page,
results: resultsWithMedia,
});
} catch (e) {
logger.debug('Something went wrong retrieving release groups', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve release groups.',
});
}
});
export default discoverRoutes;

@ -28,6 +28,7 @@ import issueRoutes from './issue';
import issueCommentRoutes from './issueComment';
import mediaRoutes from './media';
import movieRoutes from './movie';
import musicRoutes from './music';
import personRoutes from './person';
import requestRoutes from './request';
import searchRoutes from './search';
@ -143,6 +144,7 @@ router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/music', isAuthenticated(), musicRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes);

@ -1,5 +1,5 @@
import TautulliAPI from '@server/api/tautulli';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaStatus, MediaType, SecondaryType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
@ -64,12 +64,47 @@ mediaRoutes.get('/', async (req, res, next) => {
};
}
let typeFilter: FindOneOptions<Media>['where'] = undefined;
switch (req.query.type) {
case 'movie':
typeFilter = {
mediaType: MediaType.MOVIE,
};
break;
case 'tv':
typeFilter = {
mediaType: MediaType.TV,
};
break;
case 'music':
typeFilter = {
mediaType: MediaType.MUSIC,
};
break;
case 'artist':
typeFilter = {
mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.ARTIST,
};
break;
case 'release':
typeFilter = {
mediaType: MediaType.MUSIC,
secondaryType: SecondaryType.RELEASE,
};
break;
}
try {
const [media, mediaCount] = await mediaRepository.findAndCount({
order: sortFilter,
where: statusFilter && {
status: statusFilter,
},
status: statusFilter,
} &&
typeFilter && {
...typeFilter,
},
take: pageSize,
skip,
});

@ -0,0 +1,113 @@
import MusicBrainz from '@server/api/musicbrainz';
import { MediaType, SecondaryType } from '@server/constants/media';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import type { ReleaseResult } from '@server/models/Search';
import { mapArtistResult, mapReleaseResult } from '@server/models/Search';
import { Router } from 'express';
const musicRoutes = Router();
musicRoutes.get('/artist/:id', async (req, res, next) => {
const mb = new MusicBrainz();
try {
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
const maxElements = req.query.maxElements
? parseInt(req.query.maxElements as string)
: 25;
const artist = req.query.full
? await mb.getFullArtist(req.params.id, maxElements, offset)
: await mb.getArtist(req.params.id);
const media = await Media.getMedia(artist.id, MediaType.MUSIC);
const results = await mapArtistResult(artist, media);
let existingReleaseMedia: Media[] = [];
if (media) {
existingReleaseMedia =
(await Media.getChildMedia(Number(media.ratingKey) ?? 0)) ?? [];
}
let newReleases: ReleaseResult[] = await Promise.all(
existingReleaseMedia.map(async (releaseMedia) => {
return await mapReleaseResult(
{
id: <string>releaseMedia.mbId,
media_type: SecondaryType.RELEASE,
title: <string>releaseMedia.title,
artist: [],
tags: [],
},
releaseMedia
);
})
);
newReleases = newReleases.slice(offset, offset + maxElements);
for (const release of results.releases) {
if (newReleases.length >= maxElements) {
break;
}
if (newReleases.find((r: ReleaseResult) => r.id === release.id)) {
continue;
}
if (newReleases.find((r: ReleaseResult) => r.title === release.title)) {
if (
newReleases.find(
(r: ReleaseResult) => r.mediaInfo && !release.mediaInfo
)
) {
continue;
}
if (
newReleases.find(
(r: ReleaseResult) => !r.mediaInfo && !release.mediaInfo
)
) {
continue;
}
}
newReleases.push(release);
}
results.releases = newReleases;
return res.status(200).json(results);
} catch (e) {
logger.debug('Something went wrong retrieving artist', {
label: 'API',
errorMessage: e.message,
artistId: req.params.id,
});
return next({
status: 500,
message: 'Unable to retrieve artist.',
});
}
});
musicRoutes.get('/release/:id', async (req, res, next) => {
const mb = new MusicBrainz();
try {
const release = await mb.getRelease(req.params.id);
const media = await Media.getMedia(release.id, MediaType.MUSIC);
return res.status(200).json(await mapReleaseResult(release, media));
} catch (e) {
logger.debug('Something went wrong retrieving release', {
label: 'API',
errorMessage: e.message,
releaseId: req.params.id,
});
return next({
status: 500,
message: 'Unable to retrieve release.',
});
}
});
export default musicRoutes;

@ -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,41 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
}
);
requestRoutes.post<never, MediaRequest, MediaRequestBody>(
'/',
async (req, res, next) => {
try {
if (!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;
}
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: 401,
message: 'You must be logged in to request media.',
status: 500,
message: error.message,
});
}
const request = await MediaRequest.request(req.body, req.user);
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 });
}
}
}
);
});
requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest);

@ -1,8 +1,13 @@
import MusicBrainz from '@server/api/musicbrainz';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
import Media from '@server/entity/Media';
import { findSearchProvider } from '@server/lib/search';
import logger from '@server/logger';
import type {
MbSearchMultiResponse,
MixedSearchResponse,
} from '@server/models/Search';
import { mapSearchResults } from '@server/models/Search';
import { Router } from 'express';
@ -11,7 +16,10 @@ const searchRoutes = Router();
searchRoutes.get('/', async (req, res, next) => {
const queryString = req.query.query as string;
const searchProvider = findSearchProvider(queryString.toLowerCase());
let results: TmdbSearchMultiResponse;
let results:
| MixedSearchResponse
| TmdbSearchMultiResponse
| MbSearchMultiResponse;
try {
if (searchProvider) {
@ -25,23 +33,40 @@ searchRoutes.get('/', async (req, res, next) => {
});
} else {
const tmdb = new TheMovieDb();
results = await tmdb.searchMulti({
query: queryString,
page: Number(req.query.page),
language: (req.query.language as string) ?? req.locale,
});
const mb = new MusicBrainz();
const mbResults = await mb.searchMulti({
query: queryString,
page: Number(req.query.page),
});
const releaseResults = mbResults.releaseResults;
const artistResults = mbResults.artistResults;
results = {
...results,
results: [...results.results, ...artistResults, ...releaseResults],
};
}
const mbIds = results.results
.filter((result) => typeof result.id === 'string')
.map((result) => result.id as string);
const tmdbIds = results.results
.filter((result) => typeof result.id === 'number')
.map((result) => result.id as number);
const media = await Media.getRelatedMedia(
results.results.map((result) => result.id)
);
const media = await Media.getRelatedMedia(tmdbIds, mbIds);
const mappedResults = await mapSearchResults(results.results, media);
return res.status(200).json({
page: results.page,
totalPages: results.total_pages,
totalResults: results.total_results,
results: mapSearchResults(results.results, media),
results: mappedResults,
});
} catch (e) {
logger.debug('Something went wrong retrieving search results', {
@ -57,15 +82,22 @@ searchRoutes.get('/', async (req, res, next) => {
});
searchRoutes.get('/keyword', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const results = await tmdb.searchKeyword({
query: req.query.query as string,
page: Number(req.query.page),
});
if (!req.query.type || req.query.type !== 'music') {
const tmdb = new TheMovieDb();
const results = await tmdb.searchKeyword({
query: req.query.query as string,
page: Number(req.query.page),
});
return res.status(200).json(results);
return res.status(200).json(results);
} else {
const mb = new MusicBrainz();
const results = await mb.searchTags(req.query.query as string);
return res.status(200).json(results);
}
} catch (e) {
logger.debug('Something went wrong retrieving keyword search results', {
label: 'API',

@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
@ -209,4 +210,69 @@ serviceRoutes.get<{ tmdbId: string }>(
}
);
serviceRoutes.get('/lidarr', async (req, res) => {
const settings = getSettings();
const filteredLidarrServers: ServiceCommonServer[] = settings.lidarr.map(
(lidarr) => ({
id: lidarr.id,
name: lidarr.name,
isDefault: lidarr.isDefault,
activeDirectory: lidarr.activeDirectory,
activeProfileId: lidarr.activeProfileId,
activeTags: lidarr.tags ?? [],
})
);
return res.status(200).json(filteredLidarrServers);
});
serviceRoutes.get<{ lidarrId: string }>(
'/lidarr/:lidarrId',
async (req, res, next) => {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find(
(lidarr) => lidarr.id === Number(req.params.lidarrId)
);
if (!lidarrSettings) {
return next({
status: 404,
message: 'Lidarr server with provided ID does not exist.',
});
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
const profiles = await lidarr.getProfiles();
const rootFolders = await lidarr.getRootFolders();
const tags = await lidarr.getTags();
return res.status(200).json({
server: {
id: lidarrSettings.id,
name: lidarrSettings.name,
isDefault: lidarrSettings.isDefault,
activeDirectory: lidarrSettings.activeDirectory,
activeProfileId: lidarrSettings.activeProfileId,
activeTags: lidarrSettings.tags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
name: profile.name,
})),
rootFolders: rootFolders.map((folder) => ({
id: folder.id,
freeSpace: folder.freeSpace,
path: folder.path,
totalSpace: folder.totalSpace,
})),
tags,
} as ServiceCommonServerWithDetails);
}
);
export default serviceRoutes;

@ -35,12 +35,14 @@ import { URL } from 'url';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
import lidarrRoutes from './lidarr';
const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/lidarr', lidarrRoutes)
settingsRoutes.use('/discover', discoverSettingRoutes);
const filteredMainSettings = (

@ -0,0 +1,135 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import type { LidarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const lidarrRoutes = Router();
lidarrRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.lidarr);
});
lidarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newLidarr = req.body as LidarrSettings;
const lastItem = settings.lidarr[settings.lidarr.length - 1];
newLidarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
settings.lidarr = [...settings.lidarr, newLidarr];
settings.save();
return res.status(201).json(newLidarr);
});
lidarrRoutes.post<
undefined,
Record<string, unknown>,
LidarrSettings & { tagLabel?: string }
>('/test', async (req, res, next) => {
try {
const lidarr = new LidarrAPI({
apiKey: req.body.apiKey,
url: LidarrAPI.buildUrl(req.body, '/api/v1'),
});
const urlBase = await lidarr
.getSystemStatus()
.then((value) => value.urlBase)
.catch(() => req.body.baseUrl);
const profiles = await lidarr.getProfiles();
const folders = await lidarr.getRootFolders();
const tags = await lidarr.getTags();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
tags,
urlBase,
});
} catch (e) {
logger.error('Failed to test Lidarr', {
label: 'Lidarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Lidarr' });
}
});
lidarrRoutes.put<{ id: string }, LidarrSettings, LidarrSettings>(
'/:id',
(req, res, next) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return next({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
settings.lidarr[lidarrIndex] = {
...req.body,
id: Number(req.params.id),
} as LidarrSettings;
settings.save();
return res.status(200).json(settings.lidarr[lidarrIndex]);
}
);
lidarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
const settings = getSettings();
const lidarrSettings = settings.lidarr.find(
(r) => r.id === Number(req.params.id)
);
if (!lidarrSettings) {
return next({ status: '404', message: 'Settings instance not found' });
}
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
const profiles = await lidarr.getProfiles();
return res.status(200).json(
profiles.map((profile) => ({
id: profile.id,
name: profile.name,
}))
);
});
lidarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const lidarrIndex = settings.lidarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (lidarrIndex === -1) {
return next({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.lidarr.splice(lidarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
export default lidarrRoutes;

@ -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}${

@ -4,11 +4,14 @@
"target": "ES2020",
"module": "commonjs",
"outDir": "../dist",
"strict": true,
"noEmit": false,
"baseUrl": ".",
"paths": {
"@server/*": ["*"]
}
},
"typeRoots": ["../node_modules/*", "./types"],
},
"include": ["**/*.ts"]
"include": ["**/*.ts"],
}

@ -1 +1,150 @@
declare module 'nodebrainz';
declare module 'nodebrainz' {
import type {
Artist,
Group,
Recording,
Release,
SearchOptions,
Work,
} from '@server/api/musicbrainz/interfaces';
interface RawSearchResponse {
created: string;
count: number;
offset: number;
}
export interface ArtistSearchResponse extends RawSearchResponse {
artists: Artist[];
}
export interface ReleaseSearchResponse extends RawSearchResponse {
releases: Release[];
}
export interface RecordingSearchResponse extends RawSearchResponse {
recordings: Recording[];
}
export interface ReleaseGroupSearchResponse extends RawSearchResponse {
'release-groups': Group[];
}
export interface WorkSearchResponse extends RawSearchResponse {
works: Work[];
}
export interface TagSearchResponse extends RawSearchResponse {
tags: {
score: number;
name: string;
}[];
}
export interface BrowseRequestParams {
limit?: number;
offset?: number;
artist?: string;
release?: string;
recording?: string;
'release-group'?: string;
work?: string;
// or anything else
[key: string]: string | number | undefined;
}
export interface luceneSearchOptions {
query: string;
limit?: number;
offset?: number;
}
export default class BaseNodeBrainz {
constructor(options: {
userAgent: string;
retryOn: boolean;
retryDelay: number;
retryCount: number;
});
artist(
artistId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Artist) => void
): Promise<Artist>;
recording(
recordingId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Recording) => void
): Promise<Recording>;
release(
releaseId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Release) => void
): Promise<Release>;
releaseGroup(
releaseGroupId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Group) => void
): Promise<Group>;
work(
workId: string,
{ inc }: { inc: string },
callback: (err: Error, data: Work) => void
): Promise<Work>;
search(
type: string,
search: SearchOptions | { tag: string },
callback: (
err: Error,
data:
| ArtistSearchResponse
| ReleaseSearchResponse
| RecordingSearchResponse
| ReleaseGroupSearchResponse
| WorkSearchResponse
| TagSearchResponse
) => void
): Promise<Artist[] | Release[] | Recording[] | Group[] | Work[]>;
browse(
type: string,
data: BrowseRequestParams,
callback: (
err: Error,
data:
| {
'release-group-count': number;
'release-group-offset': number;
'release-groups': Group[];
}
| {
'release-count': number;
'release-offset': number;
releases: Release[];
}
| {
'recording-count': number;
'recording-offset': number;
recordings: Recording[];
}
| {
'work-count': number;
'work-offset': number;
works: Work[];
}
| {
'artist-count': number;
'artist-offset': number;
artists: Artist[];
}
) => void
): Promise<Artist[] | Release[] | Recording[] | Group[] | Work[]>;
luceneSearch(
type: string,
search: luceneSearchOptions,
callback: (
err: Error,
data:
| ArtistSearchResponse
| ReleaseSearchResponse
| RecordingSearchResponse
| ReleaseGroupSearchResponse
| WorkSearchResponse
| TagSearchResponse
) => void
): Promise<Artist[] | Release[] | Recording[] | Group[] | Work[]>;
}
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 flex-shrink-0" viewBox="0 0 1024 1024"><style>.lidarr_svg__a{fill:#989898;stroke-width:24}.lidarr_svg__b{fill:none;stroke-width:16;stroke:#009252}</style><path fill="none" d="M-1-1h1026v1026H-1z"></path><circle cx="512" cy="512" r="410" stroke-width="1.8"></circle><circle cx="512" cy="512" r="460" style="fill: none; stroke-width: 99; stroke: rgb(229, 229, 229);"></circle><circle cx="512" cy="512" r="270" style="fill: rgb(229, 229, 229); stroke-width: 76; stroke: rgb(229, 229, 229);"></circle><circle cy="512" cx="512" r="410" style="fill: none; stroke-width: 12; stroke: rgb(0, 146, 82);"></circle><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z"></path><path d="M512 636V71L182 636h330zM512 388v565l330-565H512z" class="lidarr_svg__b"></path><circle cx="512" cy="512" r="150" style="fill: rgb(0, 146, 82);"></circle></svg>

After

Width:  |  Height:  |  Size: 897 B

@ -1,6 +1,7 @@
import useSettings from '@app/hooks/useSettings';
import type { ImageLoader, ImageProps } from 'next/image';
import Image from 'next/image';
import { useState } from 'react';
const imageLoader: ImageLoader = ({ src }) => src;
@ -10,18 +11,29 @@ const imageLoader: ImageLoader = ({ src }) => src;
**/
const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings();
const [imageUrl, setImageUrl] = useState<string>(src as string);
let imageUrl = src;
const handleError = () => {
setImageUrl(currentSettings?.fallbackImage);
};
if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) {
imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy');
setImageUrl(imageUrl.replace('https://image.tmdb.org', '/imageproxy'));
}
}
return <Image unoptimized loader={imageLoader} src={imageUrl} {...props} />;
return (
<Image
unoptimized
loader={imageLoader}
src={imageUrl}
onError={handleError}
{...props}
/>
);
};
export default CachedImage;

@ -5,29 +5,50 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type {
ArtistResult,
CollectionResult,
MovieResult,
MusicResult,
PersonResult,
RecordingResult,
ReleaseGroupResult,
ReleaseResult,
TvResult,
WorkResult,
} from '@server/models/Search';
import { useIntl } from 'react-intl';
type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[];
items?: (
| TvResult
| MovieResult
| PersonResult
| CollectionResult
| MusicResult
| ArtistResult
| ReleaseResult
| ReleaseGroupResult
| WorkResult
| RecordingResult
)[];
jsxItems?: React.ReactNode[];
plexItems?: WatchlistItem[];
isEmpty?: boolean;
isLoading?: boolean;
isReachingEnd?: boolean;
onScrollBottom: () => void;
force_big?: boolean;
};
const ListView = ({
items,
jsxItems,
isEmpty,
isLoading,
onScrollBottom,
isReachingEnd,
plexItems,
force_big = false,
}: ListViewProps) => {
const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
@ -43,9 +64,9 @@ const ListView = ({
return (
<li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard
id={title.tmdbId}
tmdbId={title.tmdbId}
type={title.mediaType}
id={Number(title.tmdbId)}
tmdbId={Number(title.tmdbId)}
type={title.mediaType as 'movie' | 'tv'}
canExpand
/>
</li>
@ -113,10 +134,66 @@ const ListView = ({
/>
);
break;
case 'artist':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
title={title.name}
mediaType={title.mediaType}
canExpand
force_big={force_big}
/>
);
break;
case 'release':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
title={title.title}
mediaType={title.mediaType}
canExpand
force_big={force_big}
/>
);
break;
case 'release-group':
titleCard = (
<TitleCard
id={title.id}
image={title.posterPath}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'work':
titleCard = (
<TitleCard
id={title.id}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
case 'recording':
titleCard = (
<TitleCard
id={title.id}
title={title.title}
mediaType={title.mediaType}
canExpand
/>
);
break;
}
return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})}
{jsxItems}
{isLoading &&
!isReachingEnd &&
[...Array(20)].map((_item, i) => (

@ -0,0 +1,98 @@
import Button from '@app/components/Common/Button';
import Header from '@app/components/Common/Header';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { FilterOptions } from '@app/components/Discover/constants';
import {
countActiveFilters,
prepareFilterValues,
} from '@app/components/Discover/constants';
import FilterSlideover from '@app/components/Discover/FilterSlideover';
import RecentlyAddedSlider from '@app/components/Discover/RecentlyAddedSlider';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import { FunnelIcon } from '@heroicons/react/24/solid';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
discovermusics: 'Musics',
activefilters:
'{count, plural, one {# Active Filter} other {# Active Filters}}',
discovermoremusics: 'Discover More Musics',
});
const DiscoverMusics = () => {
const intl = useIntl();
const router = useRouter();
const preparedFilters = prepareFilterValues(router.query);
const {
isLoadingInitialData,
isEmpty,
isLoadingMore,
isReachingEnd,
titles,
fetchMore,
error,
} = useDiscover<ReleaseResult | ArtistResult, unknown, FilterOptions>(
'/api/v1/discover/musics',
preparedFilters
);
const [showFilters, setShowFilters] = useState(false);
if (error || !titles) {
return <Error statusCode={500} />;
}
const title = intl.formatMessage(messages.discovermusics);
return (
<>
<PageTitle title={title} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{title}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<FilterSlideover
type="music"
currentFilters={preparedFilters}
onClose={() => setShowFilters(false)}
show={showFilters}
/>
<div className="mb-2 flex flex-grow sm:mb-0 lg:flex-grow-0">
<Button onClick={() => setShowFilters(true)} className="w-full">
<FunnelIcon />
<span>
{intl.formatMessage(messages.activefilters, {
count: countActiveFilters(preparedFilters),
})}
</span>
</Button>
</div>
</div>
</div>
{Object.keys(preparedFilters).length === 0 && (
<RecentlyAddedSlider type="artist" />
)}
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.discovermoremusics)}</span>
</div>
</div>
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
</>
);
};
export default DiscoverMusics;

@ -44,7 +44,7 @@ const messages = defineMessages({
type FilterSlideoverProps = {
show: boolean;
onClose: () => void;
type: 'movie' | 'tv';
type: 'movie' | 'tv' | 'music';
currentFilters: FilterOptions;
};
@ -74,57 +74,59 @@ const FilterSlideover = ({
onClose={() => onClose()}
>
<div className="flex flex-col space-y-4">
<div>
<div className="mb-2 text-lg font-semibold">
{intl.formatMessage(
type === 'movie' ? messages.releaseDate : messages.firstAirDate
)}
</div>
<div className="relative z-40 flex space-x-2">
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateGte] ?? null,
endDate: currentFilters[dateGte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateGte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="fromdate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
{type !== 'music' && (
<div>
<div className="mb-2 text-lg font-semibold">
{intl.formatMessage(
type === 'movie' ? messages.releaseDate : messages.firstAirDate
)}
</div>
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateLte] ?? null,
endDate: currentFilters[dateLte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateLte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="todate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
<div className="relative z-40 flex space-x-2">
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.from)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateGte] ?? null,
endDate: currentFilters[dateGte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateGte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="fromdate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
<div className="flex flex-col">
<div className="mb-2">{intl.formatMessage(messages.to)}</div>
<Datepicker
primaryColor="indigo"
value={{
startDate: currentFilters[dateLte] ?? null,
endDate: currentFilters[dateLte] ?? null,
}}
onChange={(value) => {
updateQueryParams(
dateLte,
value?.startDate ? (value.startDate as string) : undefined
);
}}
inputName="todate"
useRange={false}
asSingle
containerClassName="datepicker-wrapper"
inputClassName="pr-1 sm:pr-4 text-base leading-5"
/>
</div>
</div>
</div>
</div>
)}
{type === 'movie' && (
<>
<span className="text-lg font-semibold">
@ -138,179 +140,199 @@ const FilterSlideover = ({
/>
</>
)}
<span className="text-lg font-semibold">
{intl.formatMessage(messages.genres)}
</span>
<GenreSelector
type={type}
defaultValue={currentFilters.genre}
isMulti
onChange={(value) => {
updateQueryParams('genre', value?.map((v) => v.value).join(','));
}}
/>
{type !== 'music' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.genres)}
</span>
<GenreSelector
type={type}
defaultValue={currentFilters.genre}
isMulti
onChange={(value) => {
updateQueryParams(
'genre',
value?.map((v) => v.value).join(',')
);
}}
/>
</>
)}
<span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)}
</span>
<KeywordSelector
defaultValue={currentFilters.keywords}
isMulti
onChange={(value) => {
updateQueryParams('keywords', value?.map((v) => v.value).join(','));
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.originalLanguage)}
</span>
<LanguageSelector
value={currentFilters.language}
serverValue={currentSettings.originalLanguage}
isUserSettings
setFieldValue={(_key, value) => {
updateQueryParams('language', value);
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.runtime)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={400}
onUpdateMin={(min) => {
updateQueryParams(
'withRuntimeGte',
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'withRuntimeLte',
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
? max.toString()
: undefined
);
}}
defaultMaxValue={
currentFilters.withRuntimeLte
? Number(currentFilters.withRuntimeLte)
: undefined
}
defaultMinValue={
currentFilters.withRuntimeGte
? Number(currentFilters.withRuntimeGte)
: undefined
}
subText={intl.formatMessage(messages.runtimeText, {
minValue: currentFilters.withRuntimeGte ?? 0,
maxValue: currentFilters.withRuntimeLte ?? 400,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuserscore)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={1}
max={10}
defaultMaxValue={
currentFilters.voteAverageLte
? Number(currentFilters.voteAverageLte)
: undefined
}
defaultMinValue={
currentFilters.voteAverageGte
? Number(currentFilters.voteAverageGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteAverageGte',
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteAverageLte',
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.ratingText, {
minValue: currentFilters.voteAverageGte ?? 1,
maxValue: currentFilters.voteAverageLte ?? 10,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuservotecount)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={1000}
defaultMaxValue={
currentFilters.voteCountLte
? Number(currentFilters.voteCountLte)
: undefined
}
defaultMinValue={
currentFilters.voteCountGte
? Number(currentFilters.voteCountGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteCountGte',
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteCountLte',
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.voteCount, {
minValue: currentFilters.voteCountGte ?? 0,
maxValue: currentFilters.voteCountLte ?? 1000,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>
<WatchProviderSelector
type={type}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ??
[]
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
onChange={(value) => {
updateQueryParams(
'keywords',
type === 'music'
? encodeURIComponent(value?.map((v) => v.label).join(',') ?? '')
: encodeURIComponent(value?.map((v) => v.value).join(',') ?? '')
);
}}
/>
{type !== 'music' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.originalLanguage)}
</span>
<LanguageSelector
value={currentFilters.language}
serverValue={currentSettings.originalLanguage}
isUserSettings
setFieldValue={(_key, value) => {
updateQueryParams('language', value);
}}
/>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.runtime)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={400}
onUpdateMin={(min) => {
updateQueryParams(
'withRuntimeGte',
min !== 0 && Number(currentFilters.withRuntimeLte) !== 400
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'withRuntimeLte',
max !== 400 && Number(currentFilters.withRuntimeGte) !== 0
? max.toString()
: undefined
);
}}
defaultMaxValue={
currentFilters.withRuntimeLte
? Number(currentFilters.withRuntimeLte)
: undefined
}
defaultMinValue={
currentFilters.withRuntimeGte
? Number(currentFilters.withRuntimeGte)
: undefined
}
subText={intl.formatMessage(messages.runtimeText, {
minValue: currentFilters.withRuntimeGte ?? 0,
maxValue: currentFilters.withRuntimeLte ?? 400,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuserscore)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={1}
max={10}
defaultMaxValue={
currentFilters.voteAverageLte
? Number(currentFilters.voteAverageLte)
: undefined
}
defaultMinValue={
currentFilters.voteAverageGte
? Number(currentFilters.voteAverageGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteAverageGte',
min !== 1 && Number(currentFilters.voteAverageLte) !== 10
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteAverageLte',
max !== 10 && Number(currentFilters.voteAverageGte) !== 1
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.ratingText, {
minValue: currentFilters.voteAverageGte ?? 1,
maxValue: currentFilters.voteAverageLte ?? 10,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.tmdbuservotecount)}
</span>
<div className="relative z-0">
<MultiRangeSlider
min={0}
max={1000}
defaultMaxValue={
currentFilters.voteCountLte
? Number(currentFilters.voteCountLte)
: undefined
}
defaultMinValue={
currentFilters.voteCountGte
? Number(currentFilters.voteCountGte)
: undefined
}
onUpdateMin={(min) => {
updateQueryParams(
'voteCountGte',
min !== 0 && Number(currentFilters.voteCountLte) !== 1000
? min.toString()
: undefined
);
}}
onUpdateMax={(max) => {
updateQueryParams(
'voteCountLte',
max !== 1000 && Number(currentFilters.voteCountGte) !== 0
? max.toString()
: undefined
);
}}
subText={intl.formatMessage(messages.voteCount, {
minValue: currentFilters.voteCountGte ?? 0,
maxValue: currentFilters.voteCountLte ?? 1000,
})}
/>
</div>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.streamingservices)}
</span>
{type in ['movie', 'tv'] ? (
<WatchProviderSelector
type={type as 'movie' | 'tv'}
region={currentFilters.watchRegion}
activeProviders={
currentFilters.watchProviders
?.split('|')
.map((v) => Number(v)) ?? []
}
onChange={(region, providers) => {
if (providers.length) {
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}}
/>
) : null}
</>
)}
<div className="pt-4">
<Button
className="w-full"

@ -65,10 +65,10 @@ const PlexWatchlistSlider = () => {
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
id={item.tmdbId as number}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
tmdbId={item.tmdbId as number}
type={item.mediaType as 'movie' | 'tv'}
/>
))}
/>

@ -1,19 +1,27 @@
import Slider from '@app/components/Slider';
import MusicTitleCard from '@app/components/TitleCard/MusicTitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import type { SecondaryType } from '@server/constants/media';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
recentlyAdded: 'Recently Added',
recentlyAddedMusic: 'Recently Added Music',
});
const RecentlyAddedSlider = () => {
const RecentlyAddedSlider = ({
type = 'all',
}: {
type?: 'all' | 'movie' | 'tv' | 'music' | 'artist' | 'release';
}) => {
const intl = useIntl();
const { hasPermission } = useUser();
type = type ?? 'all';
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
`/api/v1/media?filter=allavailable&take=20&sort=mediaAdded&type=${type}`,
{ revalidateOnMount: true }
);
@ -26,26 +34,59 @@ const RecentlyAddedSlider = () => {
return null;
}
const videoMedias = (media?.results ?? []).filter((item) =>
['movie', 'tv'].includes(item.mediaType)
);
const musicMedias = (media?.results ?? []).filter(
(item) => !['movie', 'tv'].includes(item.mediaType)
);
return (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={(media?.results ?? []).map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tvdbId={item.tvdbId}
type={item.mediaType}
{videoMedias.length > 0 && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAdded)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={videoMedias.map((item) => (
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId as number}
tvdbId={item.tvdbId}
type={item.mediaType as 'movie' | 'tv'}
/>
))}
/>
</>
)}
{musicMedias.length > 0 && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.recentlyAddedMusic)}</span>
</div>
</div>
<Slider
sliderKey="media"
isLoading={!media}
items={musicMedias.map((item) => (
<MusicTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
mbId={item.mbId ?? ''}
type={item.secondaryType as SecondaryType}
displayType={item.secondaryType as SecondaryType}
/>
))}
/>
))}
/>
</>
)}
</>
);
};

@ -6,6 +6,7 @@ import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { RadioGroup } from '@headlessui/react';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
@ -47,8 +48,10 @@ const classNames = (...classes: string[]) => {
};
interface CreateIssueModalProps {
mediaType: 'movie' | 'tv';
mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number;
mbId?: string;
secondaryType?: SecondaryType;
onCancel?: () => void;
}
@ -56,16 +59,18 @@ const CreateIssueModal = ({
onCancel,
mediaType,
tmdbId,
mbId,
secondaryType,
}: CreateIssueModalProps) => {
const intl = useIntl();
const settings = useSettings();
const { hasPermission } = useUser();
const { addToast } = useToasts();
const { data, error } = useSWR<MovieDetails | TvDetails>(
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : null
tmdbId ? `/api/v1/${mediaType}/${tmdbId}` : mbId ? `/api/v1/music/${secondaryType}/${mbId}` : null
);
if (!tmdbId) {
if (!tmdbId && (!mbId || !secondaryType)) {
return null;
}

@ -1,15 +1,18 @@
import CreateIssueModal from '@app/components/IssueModal/CreateIssueModal';
import { Transition } from '@headlessui/react';
import type { SecondaryType } from '@server/constants/media';
interface IssueModalProps {
show?: boolean;
onCancel: () => void;
mediaType: 'movie' | 'tv';
tmdbId: number;
mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number;
mbId?: string;
secondaryType?: SecondaryType;
issueId?: never;
}
const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
const IssueModal = ({ show, mediaType, onCancel, tmdbId, mbId, secondaryType }: IssueModalProps) => (
<Transition
as="div"
enter="transition-opacity duration-300"
@ -24,6 +27,8 @@ const IssueModal = ({ show, mediaType, onCancel, tmdbId }: IssueModalProps) => (
mediaType={mediaType}
onCancel={onCancel}
tmdbId={tmdbId}
mbId={mbId}
secondaryType={secondaryType}
/>
</Transition>
);

@ -8,6 +8,7 @@ import {
EllipsisHorizontalIcon,
ExclamationTriangleIcon,
FilmIcon,
MusicalNoteIcon,
SparklesIcon,
TvIcon,
UsersIcon,
@ -17,6 +18,7 @@ import {
CogIcon as FilledCogIcon,
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
FilmIcon as FilledFilmIcon,
MusicalNoteIcon as FilledMusicalNoteIcon,
SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon,
UsersIcon as FilledUsersIcon,
@ -77,6 +79,13 @@ const MobileMenu = () => {
svgIconSelected: <FilledTvIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/,
},
{
href: '/discover/music',
content: intl.formatMessage(menuMessages.browsemusic),
svgIcon: <MusicalNoteIcon className="h-6 w-6" />,
svgIconSelected: <FilledMusicalNoteIcon className="h-6 w-6" />,
activeRegExp: /^\/discover\/music$/,
},
{
href: '/requests',
content: intl.formatMessage(menuMessages.requests),

@ -4,7 +4,7 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
searchPlaceholder: 'Search Movies & TV',
searchPlaceholder: 'Search Musics, Movies & TV',
});
const SearchInput = () => {

@ -7,6 +7,7 @@ import {
CogIcon,
ExclamationTriangleIcon,
FilmIcon,
MusicalNoteIcon,
SparklesIcon,
TvIcon,
UsersIcon,
@ -21,10 +22,12 @@ export const menuMessages = defineMessages({
dashboard: 'Discover',
browsemovies: 'Movies',
browsetv: 'Series',
browsemusic: 'Music',
requests: 'Requests',
issues: 'Issues',
users: 'Users',
settings: 'Settings',
music: 'Music',
});
interface SidebarProps {
@ -62,6 +65,12 @@ const SidebarLinks: SidebarLinkProps[] = [
svgIcon: <TvIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/tv$/,
},
{
href: '/discover/music',
messagesKey: 'music',
svgIcon: <MusicalNoteIcon className="mr-3 h-6 w-6" />,
activeRegExp: /^\/discover\/music$/,
},
{
href: '/requests',
messagesKey: 'requests',

@ -0,0 +1,321 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
import PlayButton from '@app/components/Common/PlayButton';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import IssueModal from '@app/components/IssueModal';
import RequestButton from '@app/components/RequestButton';
import Slider from '@app/components/Slider';
import StatusBadge from '@app/components/StatusBadge';
import FetchedDataTitleCard from '@app/components/TitleCard/FetchedDataTitleCard';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error';
import { ExclamationTriangleIcon, PlayIcon } from '@heroicons/react/24/outline';
import { MediaStatus, SecondaryType } from '@server/constants/media';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import 'country-flag-icons/3x2/flags.css';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
overview: 'Overview',
playonplex: 'Play on Plex',
reportissue: 'Report an Issue',
releases: 'Releases',
available: 'Available Albums',
});
interface ArtistDetailsProp {
artist: ArtistResult;
}
const ArtistDetails = ({ artist }: ArtistDetailsProp) => {
const { hasPermission } = useUser();
const router = useRouter();
const intl = useIntl();
const [showIssueModal, setShowIssueModal] = useState(false);
const data = artist;
const { plexUrl } = useDeepLinks({
plexUrl: data?.mediaInfo?.plexUrl,
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
});
const mediaLinks: PlayButtonLink[] = [];
if (
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MUSIC], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: plexUrl,
svg: <PlayIcon />,
});
}
const smartMerge = (a: ReleaseResult[], b: ReleaseResult[]) => {
const out = a;
b.forEach((item) => {
if (
!a.some((i) => i.id === item.id) &&
(!a.some(
(i) =>
i.releaseGroup?.id === item.releaseGroup?.id ||
i.title === item.title
) ||
item.mediaInfo?.status === MediaStatus.AVAILABLE)
) {
out.push(item);
}
});
return out;
};
const cleanDate = (date: Date | string | undefined) => {
date = date ?? '';
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const mainDateDisplay: string =
data.beginDate && data.endDate
? `${cleanDate(data.beginDate)} - ${cleanDate(data.endDate)}`
: cleanDate(data.beginDate);
const filteredAvailableReleases = (releases: ReleaseResult[]) => {
return releases.filter((release) => release.mediaInfo);
};
const [releases, setReleases] = useState<ReleaseResult[]>(
data.releases ?? []
);
const [availableReleases, setAvailableReleases] = useState<ReleaseResult[]>(
filteredAvailableReleases(releases)
);
useEffect(() => {
setAvailableReleases((prev) =>
smartMerge(prev, releases).filter(
(release) => release.mediaInfo?.status === MediaStatus.AVAILABLE
)
);
}, [releases]);
const [currentOffset, setCurrentOffset] = useState(0);
const [isLoading, setLoading] = useState(false);
const getMore = useCallback(() => {
if (isLoading) {
return;
}
setLoading(true);
fetch(
`/api/v1/music/artist/${router.query.mbId}?full=true&offset=${
currentOffset + 25
}`
)
.then((res) => res.json())
.then((res) => {
if (res) {
const r = res.releases ?? [];
setReleases(smartMerge(releases, r));
setCurrentOffset(currentOffset + 25);
setLoading(false);
}
});
}, [currentOffset, isLoading, releases, router.query.mbId]);
useEffect(() => {
const handleScroll = () => {
const bottom =
document.body.scrollHeight - window.scrollY - window.outerHeight <= 1;
if (bottom) {
getMore();
}
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [getMore]);
if (!data) {
return <Error statusCode={404} />;
}
const title = data.name;
const tags: string[] = data.tags ?? [];
return (
<div
className="media-page"
style={{
height: 493,
}}
key={data.id}
>
<div className="media-page-bg-image">
<CachedImage
alt=""
src={data.fanartPath ?? ''}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
<PageTitle title={title} />
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="music"
mbId={data.id}
secondaryType={SecondaryType.ARTIST}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
src={data.posterPath ?? ''}
alt={title + ' poster'}
layout="responsive"
width={600}
height={600}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="music"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
secondaryType={SecondaryType.ARTIST}
/>
</div>
<h1 data-testid="media-title">
{title}{' '}
{mainDateDisplay !== '' && (
<span className="media-year">({mainDateDisplay})</span>
)}
</h1>
<span className="media-attributes"></span>
</div>
<div className="media-actions">
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="music"
media={data.mediaInfo}
mbId={data.id}
secondaryType={SecondaryType.ARTIST}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onUpdate={() => {}}
/>
{data.mediaInfo?.status === MediaStatus.AVAILABLE &&
hasPermission(
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
{
type: 'or',
}
) && (
<Tooltip content={intl.formatMessage(messages.reportissue)}>
<Button
buttonType="warning"
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationTriangleIcon />
</Button>
</Tooltip>
)}
</div>
</div>
<div>
{tags.length > 0 && (
<div className="mt-6">
{tags.map((keyword, idx) => (
<Link
href={`/discover/music?keywords=${keyword}`}
key={`keyword-id-${idx}`}
>
<a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag>{keyword}</Tag>
</a>
</Link>
))}
</div>
)}
{availableReleases.length > 0 && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.available)}</span>
</div>
</div>
<Slider
sliderKey="Available Releases"
isLoading={false}
items={availableReleases.map((item) => {
return (
<FetchedDataTitleCard
key={`media-slider-item-${item.id}`}
data={item}
/>
);
})}
/>
</>
)}
</div>
<div>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.releases)}</span>
</div>
</div>
<ListView
isLoading={false}
jsxItems={releases.map((item) => {
return (
<FetchedDataTitleCard
key={`media-slider-item-${item.id}`}
data={item}
/>
);
})}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onScrollBottom={() => {}}
/>
</div>
</div>
);
};
export default ArtistDetails;

@ -0,0 +1,251 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import List from '@app/components/Common/List';
import PageTitle from '@app/components/Common/PageTitle';
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
import PlayButton from '@app/components/Common/PlayButton';
import Tag from '@app/components/Common/Tag';
import Tooltip from '@app/components/Common/Tooltip';
import IssueModal from '@app/components/IssueModal';
import RequestButton from '@app/components/RequestButton';
import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error';
import { ExclamationTriangleIcon, PlayIcon } from '@heroicons/react/24/outline';
import { MediaStatus, SecondaryType } from '@server/constants/media';
import type { RecordingResult, ReleaseResult } from '@server/models/Search';
import 'country-flag-icons/3x2/flags.css';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
const messages = defineMessages({
originaltitle: 'Original Title',
overview: 'Overview',
recommendations: 'Recommendations',
playonplex: 'Play on Plex',
markavailable: 'Mark as Available',
showmore: 'Show More',
showless: 'Show Less',
digitalrelease: 'Digital Release',
physicalrelease: 'Physical Release',
reportissue: 'Report an Issue',
managemusic: 'Manage Music',
releases: 'Releases',
albums: 'Albums',
singles: 'Singles',
eps: 'EPs',
broadcasts: 'Broadcasts',
others: 'Others',
feats: 'Featured In',
tracks: 'Tracks',
});
interface ReleaseDetailsProp {
release: ReleaseResult;
}
const ReleaseDetails = ({ release }: ReleaseDetailsProp) => {
const { hasPermission } = useUser();
const router = useRouter();
const intl = useIntl();
const [showIssueModal, setShowIssueModal] = useState(false);
const { data: fetched } = useSWR<ReleaseResult>(
`/api/v1/music/release/${router.query.mbId}?full=true`
);
const data = fetched ?? release;
const { plexUrl } = useDeepLinks({
plexUrl: data?.mediaInfo?.plexUrl,
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
});
const mediaLinks: PlayButtonLink[] = [];
if (
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: plexUrl,
svg: <PlayIcon />,
});
}
const cleanDate = (date: Date | string | undefined) => {
date = date ?? '';
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const mainDateDisplay: string = cleanDate(data.date);
const tracks: RecordingResult[] = data.tracks ?? [];
const lengthToTime = (length: number) => {
length /= 1000;
const minutes = Math.floor(length / 60);
const seconds = length - minutes * 60;
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds.toFixed(0)}`;
};
if (!data) {
return <Error statusCode={404} />;
}
const title = data.title;
const tags: string[] = data.tags ?? [];
return (
<div
className="media-page"
style={{
height: 493,
}}
>
<div className="media-page-bg-image">
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
<PageTitle title={title} />
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="music"
mbId={data.id}
secondaryType={SecondaryType.RELEASE}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
src={data.posterPath ?? ''}
alt=""
layout="responsive"
width={600}
height={600}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="music"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
secondaryType={SecondaryType.RELEASE}
/>
</div>
<h1 data-testid="media-title">
{title}{' '}
{mainDateDisplay !== '' && (
<span className="media-year">({mainDateDisplay})</span>
)}
</h1>
<span className="media-attributes">
By&nbsp;
{data.artist.map((artist, index) => (
<div key={`artist-${index}`}>
{' '}
<Link href={`/music/artist/${artist.id}`}>
<a className="hover:underline">{artist.name}</a>
</Link>
{index < data.artist.length - 1 ? ', ' : ''}
</div>
))}
</span>
</div>
<div className="media-actions">
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="music"
media={data.mediaInfo}
mbId={data.id}
secondaryType={SecondaryType.RELEASE}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onUpdate={() => {}}
/>
{data.mediaInfo?.status === MediaStatus.AVAILABLE &&
hasPermission(
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
{
type: 'or',
}
) && (
<Tooltip content={intl.formatMessage(messages.reportissue)}>
<Button
buttonType="warning"
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationTriangleIcon />
</Button>
</Tooltip>
)}
</div>
</div>
<div>
{tags.length > 0 && (
<div className="mt-6">
{tags.map((keyword, idx) => (
<Link
href={`/discover/music?keywords=${keyword}`}
key={`keyword-id-${idx}`}
>
<a className="mb-2 mr-2 inline-flex last:mr-0">
<Tag>{keyword}</Tag>
</a>
</Link>
))}
</div>
)}
<List title={intl.formatMessage(messages.tracks)}>
{tracks.map((track, index) => (
<div key={index}>
<div className="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt className="block text-sm font-bold text-gray-400">
{track.title}
</dt>
<dd className="flex text-sm text-white sm:col-span-2 sm:mt-0">
<span className="flex-grow">
{lengthToTime(track.length)}
</span>
</dd>
<dd>
<span className="flex-grow">
{track.artist.map((artist, index) => (
<span key={index}>{artist.name}</span>
))}
</span>
</dd>
</div>
</div>
))}
</List>
</div>
</div>
);
};
export default ReleaseDetails;

@ -0,0 +1,203 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ListView from '@app/components/Common/ListView';
import PageTitle from '@app/components/Common/PageTitle';
import type { PlayButtonLink } from '@app/components/Common/PlayButton';
import PlayButton from '@app/components/Common/PlayButton';
import Tooltip from '@app/components/Common/Tooltip';
import IssueModal from '@app/components/IssueModal';
import RequestButton from '@app/components/RequestButton';
import StatusBadge from '@app/components/StatusBadge';
import FetchedDataTitleCard from '@app/components/TitleCard/FetchedDataTitleCard';
import useDeepLinks from '@app/hooks/useDeepLinks';
import { Permission, useUser } from '@app/hooks/useUser';
import Error from '@app/pages/_error';
import { ExclamationTriangleIcon, PlayIcon } from '@heroicons/react/24/outline';
import { MediaStatus, SecondaryType } from '@server/constants/media';
import type { ReleaseGroupResult } from '@server/models/Search';
import 'country-flag-icons/3x2/flags.css';
import { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
overview: 'Overview',
recommendations: 'Recommendations',
playonplex: 'Play on Plex',
markavailable: 'Mark as Available',
showmore: 'Show More',
showless: 'Show Less',
reportissue: 'Report an Issue',
releases: 'Releases',
albums: 'Albums',
singles: 'Singles',
eps: 'EPs',
broadcasts: 'Broadcasts',
others: 'Others',
feats: 'Featured In',
});
interface ReleaseGroupDetailsProp {
releaseGroup: ReleaseGroupResult;
}
const ReleaseGroupDetails = ({ releaseGroup }: ReleaseGroupDetailsProp) => {
const { hasPermission } = useUser();
const intl = useIntl();
const [showIssueModal, setShowIssueModal] = useState(false);
const data = releaseGroup;
const { plexUrl } = useDeepLinks({
plexUrl: data?.mediaInfo?.plexUrl,
iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl,
});
const mediaLinks: PlayButtonLink[] = [];
if (
plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: plexUrl,
svg: <PlayIcon />,
});
}
const releases = data.releases;
if (!data) {
return <Error statusCode={404} />;
}
/*
const cleanDate = (date: Date | string | undefined) => {
date = date ?? '';
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
const formatedDate = cleanDate(data.firstReleaseDate ?? '');
*/
const title = data.title;
const tags: string[] = data.tags ?? [];
return (
<div
className="media-page"
style={{
height: 493,
}}
>
<div className="media-page-bg-image">
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
<PageTitle title={title} />
<IssueModal
onCancel={() => setShowIssueModal(false)}
show={showIssueModal}
mediaType="music"
mbId={data.id}
secondaryType={SecondaryType.RELEASE_GROUP}
/>
<div className="media-header">
<div className="media-poster">
<CachedImage
src={data.posterPath ?? ''}
alt={title + ' album cover'}
layout="responsive"
width={600}
height={600}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
downloadItem={data.mediaInfo?.downloadStatus}
title={title}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="music"
plexUrl={plexUrl}
serviceUrl={data.mediaInfo?.serviceUrl}
secondaryType={SecondaryType.RELEASE_GROUP}
/>
</div>
<h1 data-testid="media-title">{title}</h1>
<h2 data-testid="media-subtitle">{data.type}</h2>
<span className="media-attributes">
{tags.map((t, k) => (
<span key={k}>{t}</span>
))}
</span>
</div>
<div className="media-actions">
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="music"
media={data.mediaInfo}
mbId={data.id}
secondaryType={SecondaryType.RELEASE_GROUP}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onUpdate={() => {}}
/>
{data.mediaInfo?.status === MediaStatus.AVAILABLE &&
hasPermission(
[Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES],
{
type: 'or',
}
) && (
<Tooltip content={intl.formatMessage(messages.reportissue)}>
<Button
buttonType="warning"
onClick={() => setShowIssueModal(true)}
className="ml-2 first:ml-0"
>
<ExclamationTriangleIcon />
</Button>
</Tooltip>
)}
</div>
</div>
{releases?.length > 0 && (
<>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.releases)}</span>
</div>
</div>
<ListView
isLoading={false}
jsxItems={releases.map((item) => (
<FetchedDataTitleCard
key={`media-slider-item-${item.id}`}
data={item}
/>
))}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onScrollBottom={() => {}}
/>
</>
)}
</div>
);
};
export default ReleaseGroupDetails;

@ -0,0 +1,51 @@
import ArtistDetails from '@app/components/MusicDetails/ArtistDetails';
import ReleaseDetails from '@app/components/MusicDetails/ReleaseDetails';
import ReleaseGroupDetails from '@app/components/MusicDetails/ReleaseGroupDetails';
import Error from '@app/pages/_error';
import { SecondaryType } from '@server/constants/media';
import type {
ArtistResult,
ReleaseGroupResult,
ReleaseResult,
} from '@server/models/Search';
import 'country-flag-icons/3x2/flags.css';
import { useRouter } from 'next/router';
import useSWR from 'swr';
interface MusicDetailsProps {
type: SecondaryType;
artist?: ArtistResult;
releaseGroup?: ReleaseGroupResult;
release?: ReleaseResult;
}
const MusicDetails = ({
type,
artist,
releaseGroup,
release,
}: MusicDetailsProps) => {
const router = useRouter();
const { data: fetched } = useSWR<
ArtistResult | ReleaseGroupResult | ReleaseResult
>(
`/api/v1/music/${router.query.type}/${router.query.mbId}?full=true&maxElements=50`
);
switch (type) {
case SecondaryType.ARTIST:
return <ArtistDetails artist={(fetched ?? artist) as ArtistResult} />;
case SecondaryType.RELEASE_GROUP:
return (
<ReleaseGroupDetails
releaseGroup={(fetched ?? releaseGroup) as ReleaseGroupResult}
/>
);
case SecondaryType.RELEASE:
return <ReleaseDetails release={(fetched ?? release) as ReleaseResult} />;
default:
return <Error statusCode={404} />;
}
};
export default MusicDetails;

@ -13,7 +13,7 @@ const messages = defineMessages({
});
interface QuotaSelectorProps {
mediaType: 'movie' | 'tv';
mediaType: 'movie' | 'tv' | 'music';
defaultDays?: number;
defaultLimit?: number;
dayOverride?: number;

@ -9,6 +9,7 @@ import {
InformationCircleIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type Media from '@server/entity/Media';
import type { MediaRequest } from '@server/entity/MediaRequest';
@ -43,10 +44,12 @@ interface ButtonOption {
}
interface RequestButtonProps {
mediaType: 'movie' | 'tv';
mediaType: 'movie' | 'tv' | 'music';
onUpdate: () => void;
tmdbId: number;
tmdbId?: number;
media?: Media;
mbId?: string;
secondaryType?: SecondaryType;
isShowComplete?: boolean;
is4kShowComplete?: boolean;
}
@ -56,6 +59,8 @@ const RequestButton = ({
onUpdate,
media,
mediaType,
mbId,
secondaryType,
isShowComplete = false,
is4kShowComplete = false,
}: RequestButtonProps) => {
@ -270,6 +275,8 @@ const RequestButton = ({
Permission.REQUEST,
mediaType === 'movie'
? Permission.REQUEST_MOVIE
: mediaType === 'music'
? Permission.REQUEST_MUSIC
: Permission.REQUEST_TV,
],
{ type: 'or' }
@ -361,6 +368,8 @@ const RequestButton = ({
<>
<RequestModal
tmdbId={tmdbId}
mbId={mbId}
secondaryType={secondaryType}
show={showRequestModal}
type={mediaType}
editRequest={editRequest ? activeRequest : undefined}
@ -370,18 +379,20 @@ const RequestButton = ({
}}
onCancel={() => setShowRequestModal(false)}
/>
<RequestModal
tmdbId={tmdbId}
show={showRequest4kModal}
type={mediaType}
editRequest={editRequest ? active4kRequest : undefined}
is4k
onComplete={() => {
onUpdate();
setShowRequest4kModal(false);
}}
onCancel={() => setShowRequest4kModal(false)}
/>
{mediaType !== 'music' && (
<RequestModal
tmdbId={tmdbId}
show={showRequest4kModal}
type={mediaType}
editRequest={editRequest ? active4kRequest : undefined}
is4k
onComplete={() => {
onUpdate();
setShowRequest4kModal(false);
}}
onCancel={() => setShowRequest4kModal(false)}
/>
)}
<ButtonWithDropdown
text={
<>

@ -19,6 +19,7 @@ import {
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
@ -42,8 +43,32 @@ const messages = defineMessages({
unknowntitle: 'Unknown Title',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
const isMovie = (
movie: MovieDetails | TvDetails | ReleaseResult | ArtistResult
): movie is MovieDetails => {
// Check if the object doesn't have a mediaType property and does have a title property then it's a movie
return !('mediaType' in movie) && 'title' in movie;
};
const isTv = (
tv: MovieDetails | TvDetails | ReleaseResult | ArtistResult
): tv is TvDetails => {
// Check if the object doesn't have a mediaType property and does have a name property then it's a tv show
return !('mediaType' in tv) && 'name' in tv;
};
const isRelease = (
release: MovieDetails | TvDetails | ReleaseResult | ArtistResult
): release is ReleaseResult => {
// Check if the object has a mediaType property and does have a title property then it's a release
return 'mediaType' in release && 'title' in release;
};
const isArtist = (
artist: MovieDetails | TvDetails | ReleaseResult | ArtistResult
): artist is ArtistResult => {
// Check if the object has a mediaType property and does have a name property then it's an artist
return 'mediaType' in artist && 'name' in artist;
};
const RequestCardPlaceholder = () => {
@ -205,7 +230,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
interface RequestCardProps {
request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
onTitleData?: (
requestId: number,
title: MovieDetails | TvDetails | ReleaseResult | ArtistResult
) => void;
}
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
@ -220,11 +248,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const url =
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
: request.type === 'tv'
? `/api/v1/tv/${request.media.tmdbId}`
: `/api/v1/music/${request.media.secondaryType}/${request.media.mbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}` : null
);
const { data: title, error } = useSWR<
MovieDetails | TvDetails | ReleaseResult | ArtistResult
>(inView ? `${url}` : null);
const {
data: requestData,
error: requestError,
@ -319,42 +349,69 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96"
data-testid="request-card"
>
{title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
</div>
)}
{isMovie(title) || isTv(title)
? title.backdropPath && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
</div>
)
: isArtist(title) &&
title.fanartPath && (
<div className="absolute inset-0 z-0">
<CachedImage
alt=""
src={title.fanartPath}
layout="fill"
objectFit="cover"
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
}}
/>
</div>
)}
<div
className="relative z-10 flex min-w-0 flex-1 flex-col pr-4"
data-testid="request-card-title"
>
<div className="hidden text-xs font-medium text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice(
0,
4
)}
{(isMovie(title)
? title.releaseDate
: isTv(title)
? title.firstAirDate
: isRelease(title)
? (title.date as string)
: isArtist(title)
? title.beginDate
: ''
)?.slice(0, 4)}
</div>
<Link
href={
request.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
: request.type === 'tv'
? `/tv/${requestData.media.tmdbId}`
: `/music/${requestData.media.secondaryType}/${requestData.media.mbId}`
}
>
<a className="overflow-hidden overflow-ellipsis whitespace-nowrap text-base font-bold text-white hover:underline sm:text-lg">
{isMovie(title) ? title.title : title.name}
{isMovie(title) || isRelease(title) ? title.title : title.name}
</a>
</Link>
{hasPermission(
@ -376,7 +433,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
</Link>
</div>
)}
{!isMovie(title) && request.seasons.length > 0 && (
{isTv(title) && request.seasons.length > 0 && (
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, {
@ -421,7 +478,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
]
}
title={isMovie(title) ? title.title : title.name}
title={
isMovie(title) || isRelease(title) ? title.title : title.name
}
inProgress={
(
requestData.media[
@ -570,19 +629,25 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
href={
request.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
: request.type === 'tv'
? `/tv/${requestData.media.tmdbId}`
: `/music/${requestData.media.secondaryType}/${requestData.media.mbId}`
}
>
<a className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28">
<CachedImage
src={
title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
isMovie(title) || isTv(title)
? title.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
: title.posterPath
? title.posterPath
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
width={isMovie(title) || isTv(title) ? 600 : 900}
height={900}
/>
</a>

@ -15,9 +15,11 @@ import {
TrashIcon,
XMarkIcon,
} from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
import Link from 'next/link';
@ -40,11 +42,29 @@ const messages = defineMessages({
cancelRequest: 'Cancel Request',
tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID',
mbId: 'MusicBrainz ID',
unknowntitle: 'Unknown Title',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
const isMovie = (
movie: MovieDetails | TvDetails | ReleaseResult | ArtistResult
): movie is MovieDetails => {
// Check if the object doesn't have a mediaType property and does have a title property then it's a movie
return !('mediaType' in movie) && 'title' in movie;
};
const isTv = (
tv: MovieDetails | TvDetails | ReleaseResult | ArtistResult
): tv is TvDetails => {
// Check if the object doesn't have a mediaType property and does have a name property then it's a tv show
return !('mediaType' in tv) && 'name' in tv;
};
const isRelease = (
release: MovieDetails | TvDetails | ReleaseResult | ArtistResult
): release is ReleaseResult => {
// Check if the object has a mediaType property and does have a title property then it's a release
return 'mediaType' in release && 'title' in release;
};
interface RequestItemErrorProps {
@ -81,7 +101,9 @@ const RequestItemError = ({
requestData?.type
? requestData?.type === 'movie'
? globalMessages.movie
: globalMessages.tvshow
: requestData?.type === 'tv'
? globalMessages.tvshow
: globalMessages.music
: globalMessages.request
),
})}
@ -90,10 +112,14 @@ const RequestItemError = ({
<>
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.tmdbid)}
{requestData?.type === 'movie' || requestData?.type === 'tv'
? intl.formatMessage(messages.tmdbid)
: intl.formatMessage(messages.mbId)}
</span>
<span className="flex truncate text-sm text-gray-300">
{requestData.media.tmdbId}
{requestData?.type === 'movie' || requestData?.type === 'tv'
? requestData?.media.tmdbId
: requestData?.media.mbId}
</span>
</div>
{requestData.media.tvdbId && (
@ -286,10 +312,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const url =
request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null
);
: request.type === 'tv'
? `/api/v1/tv/${request.media.tmdbId}`
: `/api/v1/music/${request.secondaryType}/${request.media.mbId}`;
const { data: title, error } = useSWR<
MovieDetails | TvDetails | ReleaseResult | ArtistResult
>(inView ? url : null);
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
{
@ -303,7 +331,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
),
}
);
const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => {
@ -374,9 +401,10 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
revalidateList();
setShowEditModal(false);
}}
secondaryType={request.secondaryType as SecondaryType}
/>
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title.backdropPath && (
{(isMovie(title) || isTv(title)) && title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
@ -399,15 +427,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
: requestData.type === 'tv'
? `/tv/${requestData.media.tmdbId}`
: `/music/${requestData.secondaryType}/${requestData.media.mbId}`
}
>
<a className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105">
<CachedImage
src={
title.posterPath
title.posterPath && (isMovie(title) || isTv(title))
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
: title.posterPath ??
'/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
@ -421,21 +452,29 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{(isMovie(title)
? title.releaseDate
: title.firstAirDate
: isTv(title)
? title.firstAirDate
: isRelease(title)
? new Date(title.date as string).toDateString()
: title.beginDate
)?.slice(0, 4)}
</div>
<Link
href={
requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}`
: `/tv/${requestData.media.tmdbId}`
: requestData.type === 'tv'
? `/tv/${requestData.media.tmdbId}`
: `/music/${requestData.secondaryType}/${requestData.media.mbId}`
}
>
<a className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{isMovie(title) ? title.title : title.name}
{isMovie(title) || isRelease(title)
? title.title
: title.name}
</a>
</Link>
{!isMovie(title) && request.seasons.length > 0 && (
{isTv(title) && request.seasons.length > 0 && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
@ -484,7 +523,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
]
}
title={isMovie(title) ? title.title : title.name}
title={
isMovie(title) || isRelease(title)
? title.title
: title.name
}
inProgress={
(
requestData.media[

@ -6,6 +6,7 @@ import globalMessages from '@app/i18n/globalMessages';
import { formatBytes } from '@app/utils/numberHelpers';
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,
@ -42,14 +43,15 @@ export type RequestOverrides = {
server?: number;
profile?: number;
folder?: string;
tags?: number[];
tags?: number[] | string[];
language?: number;
user?: User;
};
interface AdvancedRequesterProps {
type: 'movie' | 'tv';
is4k: boolean;
type: 'movie' | 'tv' | 'music';
secondaryType?: SecondaryType;
is4k?: boolean;
isAnime?: boolean;
defaultOverrides?: RequestOverrides;
requestUser?: User;
@ -67,7 +69,9 @@ const AdvancedRequester = ({
const intl = useIntl();
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
`/api/v1/service/${
type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr'
}`,
{
refreshInterval: 0,
refreshWhenHidden: false,
@ -91,7 +95,7 @@ const AdvancedRequester = ({
defaultOverrides?.language ?? -1
);
const [selectedTags, setSelectedTags] = useState<number[]>(
const [selectedTags, setSelectedTags] = useState<number[] | string[]>(
defaultOverrides?.tags ?? []
);
@ -99,7 +103,7 @@ const AdvancedRequester = ({
useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null
? `/api/v1/service/${
type === 'movie' ? 'radarr' : 'sonarr'
type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr'
}/${selectedServer}`
: null,
{
@ -133,7 +137,9 @@ const AdvancedRequester = ({
Permission.REQUEST,
type === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
: type === 'tv'
? Permission.REQUEST_TV
: Permission.REQUEST_MUSIC,
],
user.permissions,
{ type: 'or' }

@ -0,0 +1,343 @@
import Alert from '@app/components/Common/Alert';
import Modal from '@app/components/Common/Modal';
import type { RequestOverrides } from '@app/components/RequestModal/AdvancedRequester';
import AdvancedRequester from '@app/components/RequestModal/AdvancedRequester';
import QuotaDisplay from '@app/components/RequestModal/QuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus, SecondaryType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { ArtistResult } from '@server/models/Search';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
const messages = defineMessages({
requestadmin: 'This request will be approved automatically.',
requestSuccess: '<strong>{title}</strong> requested successfully!',
requestCancel: 'Request for <strong>{title}</strong> canceled.',
requestartisttitle: 'Request Artist',
edit: 'Edit Request',
approve: 'Approve Request',
cancel: 'Cancel Request',
pendingrequest: 'Pending Artist Request',
requestfrom: "{username}'s request is pending approval.",
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
requestApproved: 'Request for <strong>{title}</strong> approved!',
requesterror: 'Something went wrong while submitting the request.',
pendingapproval: 'Your request is pending approval.',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
mbId: string;
editRequest?: MediaRequest;
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
}
const ArtistRequestModal = ({
onCancel,
onComplete,
mbId,
onUpdating,
editRequest,
}: RequestModalProps) => {
const [isUpdating, setIsUpdating] = useState(false);
const [requestOverrides, setRequestOverrides] =
useState<RequestOverrides | null>(null);
const { addToast } = useToasts();
const { data, error } = useSWR<ArtistResult>(`/api/v1/music/artist/${mbId}`, {
revalidateOnMount: true,
});
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user &&
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
: null
);
useEffect(() => {
if (onUpdating) {
onUpdating(isUpdating);
}
}, [isUpdating, onUpdating]);
const sendRequest = useCallback(async () => {
setIsUpdating(true);
try {
let overrideParams = {};
if (requestOverrides) {
overrideParams = {
serverId: requestOverrides.server,
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
tags: requestOverrides.tags,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id,
mediaType: 'music',
secondaryType: 'artist',
...overrideParams,
});
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.data) {
if (onComplete) {
onComplete(
hasPermission(Permission.AUTO_APPROVE) ||
hasPermission(Permission.AUTO_APPROVE_MUSIC)
? MediaStatus.PROCESSING
: MediaStatus.PENDING
);
}
addToast(
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.name,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
} catch (e) {
addToast(intl.formatMessage(messages.requesterror), {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
}
}, [data, onComplete, addToast, requestOverrides, hasPermission, intl]);
const cancelRequest = async () => {
setIsUpdating(true);
try {
const response = await axios.delete<MediaRequest>(
`/api/v1/request/${editRequest?.id}`
);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.status === 204) {
if (onComplete) {
onComplete(MediaStatus.UNKNOWN);
}
addToast(
<span>
{intl.formatMessage(messages.requestCancel, {
title: data?.name,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
} catch (e) {
setIsUpdating(false);
}
};
const updateRequest = async (alsoApproveRequest = false) => {
setIsUpdating(true);
try {
await axios.put(`/api/v1/request/${editRequest?.id}`, {
mediaType: 'music',
secondaryType: 'artist',
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
});
if (alsoApproveRequest) {
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
addToast(
<span>
{intl.formatMessage(
alsoApproveRequest
? messages.requestApproved
: messages.requestedited,
{
title: data?.name,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}
)}
</span>,
{
appearance: 'success',
autoDismiss: true,
}
);
if (onComplete) {
onComplete(MediaStatus.PENDING);
}
} catch (e) {
addToast(<span>{intl.formatMessage(messages.errorediting)}</span>, {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
}
};
if (editRequest) {
const isOwner = editRequest.requestedBy.id === user?.id;
return (
<Modal
loading={!data && !error}
backgroundClickable
onCancel={onCancel}
title={intl.formatMessage(messages.pendingrequest)}
subTitle={data?.name}
onOk={() =>
hasPermission(Permission.MANAGE_REQUESTS)
? updateRequest(true)
: hasPermission(Permission.REQUEST_ADVANCED)
? updateRequest()
: cancelRequest()
}
okDisabled={isUpdating}
okText={
hasPermission(Permission.MANAGE_REQUESTS)
? intl.formatMessage(messages.approve)
: hasPermission(Permission.REQUEST_ADVANCED)
? intl.formatMessage(messages.edit)
: intl.formatMessage(messages.cancel)
}
okButtonType={
hasPermission(Permission.MANAGE_REQUESTS)
? 'success'
: hasPermission(Permission.REQUEST_ADVANCED)
? 'primary'
: 'danger'
}
onSecondary={
isOwner &&
hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? () => cancelRequest()
: undefined
}
secondaryDisabled={isUpdating}
secondaryText={
isOwner &&
hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? intl.formatMessage(messages.cancel)
: undefined
}
secondaryButtonType="danger"
cancelText={intl.formatMessage(globalMessages.close)}
backdrop={data?.posterPath}
>
{isOwner
? intl.formatMessage(messages.pendingapproval)
: intl.formatMessage(messages.requestfrom, {
username: editRequest.requestedBy.displayName,
})}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="music"
secondaryType={SecondaryType.ARTIST}
requestUser={editRequest.requestedBy}
defaultOverrides={{
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
tags: editRequest.tags,
}}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
}
const hasAutoApprove = hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MUSIC,
],
{ type: 'or' }
);
return (
<Modal
loading={(!data && !error) || !quota}
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating || quota?.music.restricted}
title={intl.formatMessage(messages.requestartisttitle)}
subTitle={data?.name}
okText={
isUpdating
? intl.formatMessage(globalMessages.requesting)
: intl.formatMessage(globalMessages.request)
}
okButtonType={'primary'}
backdrop={data?.posterPath}
>
{hasAutoApprove && !quota?.music.restricted && (
<div className="mt-6">
<Alert
title={intl.formatMessage(messages.requestadmin)}
type="info"
/>
</div>
)}
{(quota?.music.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="music"
secondaryType={SecondaryType.ARTIST}
quota={quota?.music}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="music"
secondaryType={SecondaryType.ARTIST}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
};
export default ArtistRequestModal;

@ -1,5 +1,6 @@
import ProgressCircle from '@app/components/Common/ProgressCircle';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import type { QuotaStatus } from '@server/interfaces/api/userInterfaces';
import Link from 'next/link';
import { useState } from 'react';
@ -29,7 +30,8 @@ const messages = defineMessages({
interface QuotaDisplayProps {
quota?: QuotaStatus;
mediaType: 'movie' | 'tv';
mediaType: 'movie' | 'tv' | 'music';
secondaryType?: SecondaryType;
userOverride?: number | null;
remaining?: number;
overLimit?: number;

@ -0,0 +1,346 @@
import Alert from '@app/components/Common/Alert';
import Modal from '@app/components/Common/Modal';
import type { RequestOverrides } from '@app/components/RequestModal/AdvancedRequester';
import AdvancedRequester from '@app/components/RequestModal/AdvancedRequester';
import QuotaDisplay from '@app/components/RequestModal/QuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import { MediaStatus, SecondaryType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { ReleaseResult } from '@server/models/Search';
import axios from 'axios';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR, { mutate } from 'swr';
const messages = defineMessages({
requestadmin: 'This request will be approved automatically.',
requestSuccess: '<strong>{title}</strong> requested successfully!',
requestCancel: 'Request for <strong>{title}</strong> canceled.',
requestreleasetitle: 'Request Release',
edit: 'Edit Request',
approve: 'Approve Request',
cancel: 'Cancel Request',
pendingrequest: 'Pending Release Request',
requestfrom: "{username}'s request is pending approval.",
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
requestApproved: 'Request for <strong>{title}</strong> approved!',
requesterror: 'Something went wrong while submitting the request.',
pendingapproval: 'Your request is pending approval.',
});
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
mbId: string;
editRequest?: MediaRequest;
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
}
const ReleaseRequestModal = ({
onCancel,
onComplete,
mbId,
onUpdating,
editRequest,
}: RequestModalProps) => {
const [isUpdating, setIsUpdating] = useState(false);
const [requestOverrides, setRequestOverrides] =
useState<RequestOverrides | null>(null);
const { addToast } = useToasts();
const { data, error } = useSWR<ReleaseResult>(
`/api/v1/music/release/${mbId}`,
{
revalidateOnMount: true,
}
);
const intl = useIntl();
const { user, hasPermission } = useUser();
const { data: quota } = useSWR<QuotaResponse>(
user &&
(!requestOverrides?.user?.id || hasPermission(Permission.MANAGE_USERS))
? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota`
: null
);
useEffect(() => {
if (onUpdating) {
onUpdating(isUpdating);
}
}, [isUpdating, onUpdating]);
const sendRequest = useCallback(async () => {
setIsUpdating(true);
try {
let overrideParams = {};
if (requestOverrides) {
overrideParams = {
serverId: requestOverrides.server,
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
tags: requestOverrides.tags,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
mediaId: data?.id,
mediaType: 'music',
secondaryType: 'release',
...overrideParams,
});
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.data) {
if (onComplete) {
onComplete(
hasPermission(Permission.AUTO_APPROVE) ||
hasPermission(Permission.AUTO_APPROVE_MUSIC)
? MediaStatus.PROCESSING
: MediaStatus.PENDING
);
}
addToast(
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
} catch (e) {
addToast(intl.formatMessage(messages.requesterror), {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
}
}, [data, onComplete, addToast, requestOverrides, hasPermission, intl]);
const cancelRequest = async () => {
setIsUpdating(true);
try {
const response = await axios.delete<MediaRequest>(
`/api/v1/request/${editRequest?.id}`
);
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
if (response.status === 204) {
if (onComplete) {
onComplete(MediaStatus.UNKNOWN);
}
addToast(
<span>
{intl.formatMessage(messages.requestCancel, {
title: data?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
}
} catch (e) {
setIsUpdating(false);
}
};
const updateRequest = async (alsoApproveRequest = false) => {
setIsUpdating(true);
try {
await axios.put(`/api/v1/request/${editRequest?.id}`, {
mediaType: 'music',
secondaryType: 'release',
serverId: requestOverrides?.server,
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
});
if (alsoApproveRequest) {
await axios.post(`/api/v1/request/${editRequest?.id}/approve`);
}
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
addToast(
<span>
{intl.formatMessage(
alsoApproveRequest
? messages.requestApproved
: messages.requestedited,
{
title: data?.title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
}
)}
</span>,
{
appearance: 'success',
autoDismiss: true,
}
);
if (onComplete) {
onComplete(MediaStatus.PENDING);
}
} catch (e) {
addToast(<span>{intl.formatMessage(messages.errorediting)}</span>, {
appearance: 'error',
autoDismiss: true,
});
} finally {
setIsUpdating(false);
}
};
if (editRequest) {
const isOwner = editRequest.requestedBy.id === user?.id;
return (
<Modal
loading={!data && !error}
backgroundClickable
onCancel={onCancel}
title={intl.formatMessage(messages.pendingrequest)}
subTitle={data?.title}
onOk={() =>
hasPermission(Permission.MANAGE_REQUESTS)
? updateRequest(true)
: hasPermission(Permission.REQUEST_ADVANCED)
? updateRequest()
: cancelRequest()
}
okDisabled={isUpdating}
okText={
hasPermission(Permission.MANAGE_REQUESTS)
? intl.formatMessage(messages.approve)
: hasPermission(Permission.REQUEST_ADVANCED)
? intl.formatMessage(messages.edit)
: intl.formatMessage(messages.cancel)
}
okButtonType={
hasPermission(Permission.MANAGE_REQUESTS)
? 'success'
: hasPermission(Permission.REQUEST_ADVANCED)
? 'primary'
: 'danger'
}
onSecondary={
isOwner &&
hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? () => cancelRequest()
: undefined
}
secondaryDisabled={isUpdating}
secondaryText={
isOwner &&
hasPermission(
[Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS],
{ type: 'or' }
)
? intl.formatMessage(messages.cancel)
: undefined
}
secondaryButtonType="danger"
cancelText={intl.formatMessage(globalMessages.close)}
backdrop={data?.posterPath}
>
{isOwner
? intl.formatMessage(messages.pendingapproval)
: intl.formatMessage(messages.requestfrom, {
username: editRequest.requestedBy.displayName,
})}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="music"
secondaryType={SecondaryType.RELEASE}
requestUser={editRequest.requestedBy}
defaultOverrides={{
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
tags: editRequest.tags,
}}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
}
const hasAutoApprove = hasPermission(
[
Permission.MANAGE_REQUESTS,
Permission.AUTO_APPROVE,
Permission.AUTO_APPROVE_MUSIC,
],
{ type: 'or' }
);
return (
<Modal
loading={(!data && !error) || !quota}
backgroundClickable
onCancel={onCancel}
onOk={sendRequest}
okDisabled={isUpdating || quota?.music.restricted}
title={intl.formatMessage(messages.requestreleasetitle)}
subTitle={data?.title}
okText={
isUpdating
? intl.formatMessage(globalMessages.requesting)
: intl.formatMessage(globalMessages.request)
}
okButtonType={'primary'}
backdrop={data?.posterPath}
>
{hasAutoApprove && !quota?.music.restricted && (
<div className="mt-6">
<Alert
title={intl.formatMessage(messages.requestadmin)}
type="info"
/>
</div>
)}
{(quota?.music.limit ?? 0) > 0 && (
<QuotaDisplay
mediaType="music"
secondaryType={SecondaryType.RELEASE}
quota={quota?.music}
userOverride={
requestOverrides?.user && requestOverrides.user.id !== user?.id
? requestOverrides?.user?.id
: undefined
}
/>
)}
{(hasPermission(Permission.REQUEST_ADVANCED) ||
hasPermission(Permission.MANAGE_REQUESTS)) && (
<AdvancedRequester
type="music"
secondaryType={SecondaryType.RELEASE}
onChange={(overrides) => {
setRequestOverrides(overrides);
}}
/>
)}
</Modal>
);
};
export default ReleaseRequestModal;

@ -1,14 +1,18 @@
import ArtistRequestModal from '@app/components/RequestModal/ArtistRequestModal';
import CollectionRequestModal from '@app/components/RequestModal/CollectionRequestModal';
import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal';
import ReleaseRequestModal from '@app/components/RequestModal/ReleaseRequestModal';
import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
import { Transition } from '@headlessui/react';
import type { MediaStatus } from '@server/constants/media';
import type { MediaStatus, SecondaryType } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
interface RequestModalProps {
show: boolean;
type: 'movie' | 'tv' | 'collection';
tmdbId: number;
type: 'movie' | 'tv' | 'collection' | 'music';
secondaryType?: SecondaryType;
tmdbId?: number;
mbId?: string;
is4k?: boolean;
editRequest?: MediaRequest;
onComplete?: (newStatus: MediaStatus) => void;
@ -20,11 +24,13 @@ const RequestModal = ({
type,
show,
tmdbId,
mbId,
is4k,
editRequest,
onComplete,
onUpdating,
onCancel,
secondaryType,
}: RequestModalProps) => {
return (
<Transition
@ -41,7 +47,7 @@ const RequestModal = ({
<MovieRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
tmdbId={tmdbId as number}
onUpdating={onUpdating}
is4k={is4k}
editRequest={editRequest}
@ -50,20 +56,36 @@ const RequestModal = ({
<TvRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
tmdbId={tmdbId as number}
onUpdating={onUpdating}
is4k={is4k}
editRequest={editRequest}
/>
) : (
) : type === 'collection' ? (
<CollectionRequestModal
onComplete={onComplete}
onCancel={onCancel}
tmdbId={tmdbId}
tmdbId={tmdbId as number}
onUpdating={onUpdating}
is4k={is4k}
/>
)}
) : type === 'music' && secondaryType === 'release' ? (
<ReleaseRequestModal
onComplete={onComplete}
onCancel={onCancel}
mbId={mbId as string}
onUpdating={onUpdating}
editRequest={editRequest}
/>
) : type === 'music' && secondaryType === 'artist' ? (
<ArtistRequestModal
onComplete={onComplete}
onCancel={onCancel}
mbId={mbId as string}
onUpdating={onUpdating}
editRequest={editRequest}
/>
) : null}
</Transition>
);
};

@ -4,8 +4,10 @@ import PageTitle from '@app/components/Common/PageTitle';
import useDiscover from '@app/hooks/useDiscover';
import Error from '@app/pages/_error';
import type {
ArtistResult,
MovieResult,
PersonResult,
ReleaseResult,
TvResult,
} from '@server/models/Search';
import { useRouter } from 'next/router';
@ -28,7 +30,9 @@ const Search = () => {
titles,
fetchMore,
error,
} = useDiscover<MovieResult | TvResult | PersonResult>(
} = useDiscover<
MovieResult | TvResult | PersonResult | ArtistResult | ReleaseResult
>(
`/api/v1/search`,
{
query: router.query.query,
@ -54,6 +58,7 @@ const Search = () => {
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
force_big={true}
/>
</>
);

@ -43,12 +43,14 @@ type SingleVal = {
type BaseSelectorMultiProps = {
defaultValue?: string;
isMulti: true;
type?: 'movie' | 'tv' | 'music';
onChange: (value: MultiValue<SingleVal> | null) => void;
};
type BaseSelectorSingleProps = {
defaultValue?: string;
isMulti?: false;
type?: 'movie' | 'tv' | 'music';
onChange: (value: SingleValue<SingleVal> | null) => void;
};
@ -131,7 +133,7 @@ export const CompanySelector = ({
};
type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
type: 'movie' | 'tv';
type: 'movie' | 'tv' | 'music';
};
export const GenreSelector = ({
@ -206,6 +208,7 @@ export const GenreSelector = ({
export const KeywordSelector = ({
isMulti,
defaultValue,
type,
onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
const intl = useIntl();
@ -219,41 +222,60 @@ export const KeywordSelector = ({
return;
}
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
if (type !== 'music') {
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const keyword = await axios.get<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return keyword.data;
})
);
setDefaultDataValue(
keywords.map((keyword) => ({
label: keyword.name,
value: keyword.id,
}))
);
} else {
setDefaultDataValue(
defaultValue.split(',').map((keyword, idx) => ({
label: keyword,
value: idx,
}))
);
}
};
loadDefaultKeywords();
}, [defaultValue]);
}, [defaultValue, type]);
const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>(
const results = await axios.get<TmdbKeywordSearchResponse | string[]>(
'/api/v1/search/keyword',
{
params: {
query: encodeURIExtraParams(inputValue),
type,
},
}
);
return results.data.results.map((result) => ({
label: result.name,
value: result.id,
}));
if (type === 'music') {
return (results.data as string[]).map((result, idx) => ({
label: result,
value: idx,
}));
} else {
return (results.data as TmdbKeywordSearchResponse).results.map(
(result) => ({
label: result.name,
value: result.id,
})
);
}
};
return (

@ -0,0 +1,682 @@
import Modal from '@app/components/Common/Modal';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import type { LidarrSettings } from '@server/lib/settings';
import axios from 'axios';
import { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
type OptionType = {
value: number;
label: string;
};
const messages = defineMessages({
createlidarr: 'Add New Lidarr Server',
editlidarr: 'Edit Lidarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a valid hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
validationApiKeyRequired: 'You must provide an API key',
validationRootFolderRequired: 'You must select a root folder',
validationProfileRequired: 'You must select a quality profile',
toastLidarrTestSuccess: 'Lidarr connection established successfully!',
toastLidarrTestFailure: 'Failed to connect to Lidarr.',
add: 'Add Server',
defaultserver: 'Default Server',
servername: 'Server Name',
hostname: 'Hostname or IP Address',
port: 'Port',
ssl: 'Use SSL',
apiKey: 'API Key',
baseUrl: 'URL Base',
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
selectQualityProfile: 'Select quality profile',
selectRootFolder: 'Select root folder',
loadingprofiles: 'Loading quality profiles…',
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
enableSearch: 'Enable Automatic Search',
tagRequests: 'Tag Requests',
tagRequestsInfo:
"Automatically add an additional tag with the requester's user ID & display name",
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
tags: 'Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
});
interface TestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
interface LidarrModalProps {
lidarr: LidarrSettings | null;
onClose: () => void;
onSave: () => void;
}
const LidarrModal = ({ onClose, lidarr, onSave }: LidarrModalProps) => {
const intl = useIntl();
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(lidarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const LidarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
),
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.matches(
/^(((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])):((([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))@)?(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationHostnameRequired)
),
port: Yup.number()
.nullable()
.required(intl.formatMessage(messages.validationPortRequired)),
apiKey: Yup.string().required(
intl.formatMessage(messages.validationApiKeyRequired)
),
rootFolder: Yup.string().required(
intl.formatMessage(messages.validationRootFolderRequired)
),
activeProfileId: Yup.string().required(
intl.formatMessage(messages.validationProfileRequired)
),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
baseUrl: Yup.string()
.test(
'leading-slash',
intl.formatMessage(messages.validationBaseUrlLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationBaseUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
});
const testConnection = useCallback(
async ({
hostname,
port,
apiKey,
baseUrl,
useSsl = false,
}: {
hostname: string;
port: number;
apiKey: string;
baseUrl?: string;
useSsl?: boolean;
}) => {
setIsTesting(true);
try {
const response = await axios.post<TestResponse>(
'/api/v1/settings/lidarr/test',
{
hostname,
apiKey,
port: Number(port),
baseUrl,
useSsl,
}
);
setIsValidated(true);
setTestResponse(response.data);
if (initialLoad.current) {
addToast(intl.formatMessage(messages.toastLidarrTestSuccess), {
appearance: 'success',
autoDismiss: true,
});
}
} catch (e) {
setIsValidated(false);
if (initialLoad.current) {
addToast(intl.formatMessage(messages.toastLidarrTestFailure), {
appearance: 'error',
autoDismiss: true,
});
}
} finally {
setIsTesting(false);
initialLoad.current = true;
}
},
[addToast, intl]
);
useEffect(() => {
if (lidarr) {
testConnection({
apiKey: lidarr.apiKey,
hostname: lidarr.hostname,
port: lidarr.port,
baseUrl: lidarr.baseUrl,
useSsl: lidarr.useSsl,
});
}
}, [lidarr, testConnection]);
return (
<Transition
as="div"
appear
show
enter="transition-opacity ease-in-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in-out duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
name: lidarr?.name,
hostname: lidarr?.hostname,
port: lidarr?.port ?? 8686,
ssl: lidarr?.useSsl ?? false,
apiKey: lidarr?.apiKey,
baseUrl: lidarr?.baseUrl,
activeProfileId: lidarr?.activeProfileId,
rootFolder: lidarr?.activeDirectory,
isDefault: lidarr?.isDefault ?? false,
tags: lidarr?.tags ?? [],
externalUrl: lidarr?.externalUrl,
syncEnabled: lidarr?.syncEnabled ?? false,
enableSearch: !lidarr?.preventSearch,
tagRequests: lidarr?.tagRequests ?? false,
}}
validationSchema={LidarrSettingsSchema}
onSubmit={async (values) => {
try {
const profileName = testResponse.profiles.find(
(profile) => profile.id === Number(values.activeProfileId)
)?.name;
const submission = {
name: values.name,
hostname: values.hostname,
port: Number(values.port),
apiKey: values.apiKey,
useSsl: values.ssl,
baseUrl: values.baseUrl,
activeProfileId: Number(values.activeProfileId),
activeProfileName: profileName,
activeDirectory: values.rootFolder,
tags: values.tags,
isDefault: values.isDefault,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
preventSearch: !values.enableSearch,
tagRequests: values.tagRequests,
};
if (!lidarr) {
await axios.post('/api/v1/settings/lidarr', submission);
} else {
await axios.put(
`/api/v1/settings/lidarr/${lidarr.id}`,
submission
);
}
onSave();
} catch (e) {
// set error here
}
}}
>
{({
errors,
touched,
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
}) => {
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(globalMessages.saving)
: lidarr
? intl.formatMessage(globalMessages.save)
: intl.formatMessage(messages.add)
}
secondaryButtonType="warning"
secondaryText={
isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)
}
onSecondary={() => {
if (values.apiKey && values.hostname && values.port) {
testConnection({
apiKey: values.apiKey,
baseUrl: values.baseUrl,
hostname: values.hostname,
port: values.port,
useSsl: values.ssl,
});
if (!values.baseUrl || values.baseUrl === '/') {
setFieldValue('baseUrl', testResponse.urlBase);
}
}
}}
secondaryDisabled={
!values.apiKey ||
!values.hostname ||
!values.port ||
isTesting ||
isSubmitting
}
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
onOk={() => handleSubmit()}
title={
!lidarr
? intl.formatMessage(messages.createlidarr)
: intl.formatMessage(messages.editlidarr)
}
>
<div className="mb-6">
<div className="form-row">
<label htmlFor="isDefault" className="checkbox-label">
{intl.formatMessage(messages.defaultserver)}
</label>
<div className="form-input-area">
<Field type="checkbox" id="isDefault" name="isDefault" />
</div>
</div>
<div className="form-row">
<label htmlFor="name" className="text-label">
{intl.formatMessage(messages.servername)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="name"
name="name"
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('name', e.target.value);
}}
/>
</div>
{errors.name &&
touched.name &&
typeof errors.name === 'string' && (
<div className="error">{errors.name}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.hostname)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<span className="protocol">
{values.ssl ? 'https://' : 'http://'}
</span>
<Field
id="hostname"
name="hostname"
type="text"
inputMode="url"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('hostname', e.target.value);
}}
className="rounded-r-only"
/>
</div>
{errors.hostname &&
touched.hostname &&
typeof errors.hostname === 'string' && (
<div className="error">{errors.hostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="port" className="text-label">
{intl.formatMessage(messages.port)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<Field
id="port"
name="port"
type="text"
inputMode="numeric"
className="short"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('port', e.target.value);
}}
/>
{errors.port &&
touched.port &&
typeof errors.port === 'string' && (
<div className="error">{errors.port}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="ssl" className="checkbox-label">
{intl.formatMessage(messages.ssl)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="ssl"
name="ssl"
onChange={() => {
setIsValidated(false);
setFieldValue('ssl', !values.ssl);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="apiKey" className="text-label">
{intl.formatMessage(messages.apiKey)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
as="field"
id="apiKey"
name="apiKey"
autoComplete="one-time-code"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('apiKey', e.target.value);
}}
/>
</div>
{errors.apiKey &&
touched.apiKey &&
typeof errors.apiKey === 'string' && (
<div className="error">{errors.apiKey}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="baseUrl" className="text-label">
{intl.formatMessage(messages.baseUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="baseUrl"
name="baseUrl"
type="text"
inputMode="url"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setIsValidated(false);
setFieldValue('baseUrl', e.target.value);
}}
/>
</div>
{errors.baseUrl &&
touched.baseUrl &&
typeof errors.baseUrl === 'string' && (
<div className="error">{errors.baseUrl}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="activeProfileId" className="text-label">
{intl.formatMessage(messages.qualityprofile)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="activeProfileId"
name="activeProfileId"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingprofiles)
: !isValidated
? intl.formatMessage(
messages.testFirstQualityProfiles
)
: intl.formatMessage(messages.selectQualityProfile)}
</option>
{testResponse.profiles.length > 0 &&
testResponse.profiles.map((profile) => (
<option
key={`loaded-profile-${profile.id}`}
value={profile.id}
>
{profile.name}
</option>
))}
</Field>
</div>
{errors.activeProfileId &&
touched.activeProfileId &&
typeof errors.activeProfileId === 'string' && (
<div className="error">{errors.activeProfileId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="rootFolder" className="text-label">
{intl.formatMessage(messages.rootfolder)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
as="select"
id="rootFolder"
name="rootFolder"
disabled={!isValidated || isTesting}
>
<option value="">
{isTesting
? intl.formatMessage(messages.loadingrootfolders)
: !isValidated
? intl.formatMessage(messages.testFirstRootFolders)
: intl.formatMessage(messages.selectRootFolder)}
</option>
{testResponse.rootFolders.length > 0 &&
testResponse.rootFolders.map((folder) => (
<option
key={`loaded-profile-${folder.id}`}
value={folder.path}
>
{folder.path}
</option>
))}
</Field>
</div>
{errors.rootFolder &&
touched.rootFolder &&
typeof errors.rootFolder === 'string' && (
<div className="error">{errors.rootFolder}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input-area">
<Select<OptionType, true>
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated || isTesting}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
isLoading={isTesting}
className="react-select-container"
classNamePrefix="react-select"
value={
isTesting
? []
: (values.tags
.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
if (!foundTag) {
return undefined;
}
return {
value: foundTag.id,
label: foundTag.label,
};
})
.filter(
(option) => option !== undefined
) as OptionType[])
}
onChange={(value: OnChangeValue<OptionType, true>) => {
setFieldValue(
'tags',
value.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="externalUrl"
name="externalUrl"
type="text"
inputMode="url"
/>
</div>
{errors.externalUrl &&
touched.externalUrl &&
typeof errors.externalUrl === 'string' && (
<div className="error">{errors.externalUrl}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="syncEnabled" className="checkbox-label">
{intl.formatMessage(messages.syncEnabled)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="syncEnabled"
name="syncEnabled"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="enableSearch" className="checkbox-label">
{intl.formatMessage(messages.enableSearch)}
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="enableSearch"
name="enableSearch"
/>
</div>
</div>
<div className="form-row">
<label htmlFor="tagRequests" className="checkbox-label">
{intl.formatMessage(messages.tagRequests)}
<span className="label-tip">
{intl.formatMessage(messages.tagRequestsInfo)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="tagRequests"
name="tagRequests"
/>
</div>
</div>
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default LidarrModal;

@ -56,6 +56,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({
'availability-sync': 'Media Availability Sync',
'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan',
'lidarr-scan': 'Lidarr Scan',
'download-sync': 'Download Sync',
'download-sync-reset': 'Download Sync Reset',
'image-cache-cleanup': 'Image Cache Cleanup',

@ -1,3 +1,4 @@
import LidarrLogo from '@app/assets/services/lidarr.svg';
import RadarrLogo from '@app/assets/services/radarr.svg';
import SonarrLogo from '@app/assets/services/sonarr.svg';
import Alert from '@app/components/Common/Alert';
@ -6,12 +7,17 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import LidarrModal from '@app/components/Settings/LidarrModal';
import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type {
LidarrSettings,
RadarrSettings,
SonarrSettings,
} from '@server/lib/settings';
import axios from 'axios';
import { Fragment, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -21,8 +27,11 @@ const messages = defineMessages({
services: 'Services',
radarrsettings: 'Radarr Settings',
sonarrsettings: 'Sonarr Settings',
serviceSettingsDescription:
lidarrsettings: 'Lidarr Settings',
videoServiceSettingsDescription:
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.',
musicServiceSettingsDescription:
'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only one of them can be marked as default. Administrators are able to override the server used to process new requests prior to approval.',
deleteserverconfirm: 'Are you sure you want to delete this server?',
ssl: 'SSL',
default: 'Default',
@ -32,6 +41,7 @@ const messages = defineMessages({
activeProfile: 'Active Profile',
addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server',
addlidarr: 'Add Lidarr Server',
noDefaultServer:
'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.',
noDefaultNon4kServer:
@ -40,6 +50,7 @@ const messages = defineMessages({
'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.',
mediaTypeMovie: 'movie',
mediaTypeSeries: 'series',
mediaTypeMusic: 'music',
deleteServer: 'Delete {serverType} Server',
});
@ -53,6 +64,7 @@ interface ServerInstanceProps {
externalUrl?: string;
profileName: string;
isSonarr?: boolean;
isLidarr?: boolean;
onEdit: () => void;
onDelete: () => void;
}
@ -66,6 +78,7 @@ const ServerInstance = ({
isDefault = false,
isSSL = false,
isSonarr = false,
isLidarr = false,
externalUrl,
onEdit,
onDelete,
@ -127,6 +140,8 @@ const ServerInstance = ({
<a href={serviceUrl} className="opacity-50 hover:opacity-100">
{isSonarr ? (
<SonarrLogo className="h-10 w-10 flex-shrink-0" />
) : isLidarr ? (
<LidarrLogo className="h-10 w-10 flex-shrink-0" />
) : (
<RadarrLogo className="h-10 w-10 flex-shrink-0" />
)}
@ -170,6 +185,11 @@ const SettingsServices = () => {
error: sonarrError,
mutate: revalidateSonarr,
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
const {
data: lidarrData,
error: lidarrError,
mutate: revalidateLidarr,
} = useSWR<LidarrSettings[]>('/api/v1/settings/lidarr');
const [editRadarrModal, setEditRadarrModal] = useState<{
open: boolean;
radarr: RadarrSettings | null;
@ -184,9 +204,16 @@ const SettingsServices = () => {
open: false,
sonarr: null,
});
const [editLidarrModal, setEditLidarrModal] = useState<{
open: boolean;
lidarr: LidarrSettings | null;
}>({
open: false,
lidarr: null,
});
const [deleteServerModal, setDeleteServerModal] = useState<{
open: boolean;
type: 'radarr' | 'sonarr';
type: 'radarr' | 'sonarr' | 'lidarr';
serverId: number | null;
}>({
open: false,
@ -217,7 +244,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.radarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
{intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Radarr',
})}
</p>
@ -244,6 +271,17 @@ const SettingsServices = () => {
}}
/>
)}
{editLidarrModal.open && (
<LidarrModal
lidarr={editLidarrModal.lidarr}
onClose={() => setEditLidarrModal({ open: false, lidarr: null })}
onSave={() => {
revalidateLidarr();
mutate('/api/v1/settings/public');
setEditLidarrModal({ open: false, lidarr: null });
}}
/>
)}
<Transition
as={Fragment}
show={deleteServerModal.open}
@ -267,7 +305,11 @@ const SettingsServices = () => {
}
title={intl.formatMessage(messages.deleteServer, {
serverType:
deleteServerModal.type === 'radarr' ? 'Radarr' : 'Sonarr',
deleteServerModal.type === 'radarr'
? 'Radarr'
: deleteServerModal.type === 'sonarr'
? 'Sonarr'
: 'Lidarr',
})}
>
{intl.formatMessage(messages.deleteserverconfirm)}
@ -356,7 +398,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.sonarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, {
{intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Sonarr',
})}
</p>
@ -439,6 +481,68 @@ const SettingsServices = () => {
</>
)}
</div>
<div className="mt-10 mb-6">
<h3 className="heading">
{intl.formatMessage(messages.lidarrsettings)}
</h3>
<p className="description">
{intl.formatMessage(messages.musicServiceSettingsDescription, {
serverType: 'Lidarr',
})}
</p>
</div>
<div className="section">
{!lidarrData && !lidarrError && <LoadingSpinner />}
{lidarrData && !lidarrError && (
<>
{lidarrData.length > 0 &&
(!lidarrData.some((lidarr) => lidarr.isDefault) ? (
<Alert
title={intl.formatMessage(messages.noDefaultServer, {
serverType: 'Lidarr',
mediaType: intl.formatMessage(messages.mediaTypeSeries),
})}
/>
) : null)}
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{lidarrData.map((lidarr) => (
<ServerInstance
key={`lidarr-config-${lidarr.id}`}
name={lidarr.name}
hostname={lidarr.hostname}
port={lidarr.port}
profileName={lidarr.activeProfileName}
isSSL={lidarr.useSsl}
isLidarr={true}
isDefault={lidarr.isDefault}
externalUrl={lidarr.externalUrl}
onEdit={() => setEditLidarrModal({ open: true, lidarr })}
onDelete={() =>
setDeleteServerModal({
open: true,
serverId: lidarr.id,
type: 'lidarr',
})
}
/>
))}
<li className="col-span-1 h-32 rounded-lg border-2 border-dashed border-gray-400 shadow sm:h-44">
<div className="flex h-full w-full items-center justify-center">
<Button
buttonType="ghost"
onClick={() =>
setEditLidarrModal({ open: true, lidarr: null })
}
>
<PlusIcon />
<span>{intl.formatMessage(messages.addlidarr)}</span>
</Button>
</div>
</li>
</ul>
</>
)}
</div>
</>
);
};

@ -5,6 +5,7 @@ import DownloadBlock from '@app/components/DownloadBlock';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import type { SecondaryType } from '@server/constants/media';
import { MediaStatus } from '@server/constants/media';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, useIntl } from 'react-intl';
@ -26,7 +27,9 @@ interface StatusBadgeProps {
plexUrl?: string;
serviceUrl?: string;
tmdbId?: number;
mediaType?: 'movie' | 'tv';
secondaryType?: SecondaryType;
mbId?: string;
mediaType?: 'movie' | 'tv' | 'music';
title?: string | string[];
}
@ -38,6 +41,8 @@ const StatusBadge = ({
plexUrl,
serviceUrl,
tmdbId,
mbId,
secondaryType,
mediaType,
title,
}: StatusBadgeProps) => {
@ -52,50 +57,63 @@ const StatusBadge = ({
return Math.round(((media?.size - media?.sizeLeft) / media?.size) * 100);
};
if (
mediaType &&
plexUrl &&
hasPermission(
is4k
? [
Permission.REQUEST_4K,
mediaType === 'movie'
? Permission.REQUEST_4K_MOVIE
: Permission.REQUEST_4K_TV,
]
: [
Permission.REQUEST,
if (mediaType && plexUrl) {
if (mediaType === 'music') {
mediaLink = plexUrl;
mediaLinkDescription = intl.formatMessage(messages.playonplex);
} else if (
hasPermission(
is4k
? [
Permission.REQUEST_4K,
mediaType === 'movie'
? Permission.REQUEST_4K_MOVIE
: Permission.REQUEST_4K_TV,
]
: [
Permission.REQUEST,
mediaType === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
{
type: 'or',
}
) &&
(!is4k ||
(mediaType === 'movie'
? settings.currentSettings.movie4kEnabled
: settings.currentSettings.series4kEnabled))
) {
mediaLink = plexUrl;
mediaLinkDescription = intl.formatMessage(messages.playonplex);
}
if (hasPermission(Permission.MANAGE_REQUESTS)) {
if ((mediaType === 'movie' || mediaType === 'tv') && tmdbId) {
mediaLink = `/${mediaType}/${tmdbId}?manage=1`;
mediaLinkDescription = intl.formatMessage(messages.managemedia, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow
),
});
} else if (mediaType === 'music' && mbId && secondaryType) {
mediaLink = `/music/${secondaryType}/${mbId}?manage=1`;
mediaLinkDescription = intl.formatMessage(messages.managemedia, {
mediaType: intl.formatMessage(globalMessages.music),
});
} else if (hasPermission(Permission.ADMIN) && serviceUrl) {
mediaLink = serviceUrl;
mediaLinkDescription = intl.formatMessage(messages.openinarr, {
arr:
mediaType === 'movie'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
],
{
type: 'or',
? 'Radarr'
: mediaType === 'tv'
? 'Sonarr'
: 'Lidarr',
});
}
) &&
(!is4k ||
(mediaType === 'movie'
? settings.currentSettings.movie4kEnabled
: settings.currentSettings.series4kEnabled))
) {
mediaLink = plexUrl;
mediaLinkDescription = intl.formatMessage(messages.playonplex);
} else if (hasPermission(Permission.MANAGE_REQUESTS)) {
if (mediaType && tmdbId) {
mediaLink = `/${mediaType}/${tmdbId}?manage=1`;
mediaLinkDescription = intl.formatMessage(messages.managemedia, {
mediaType: intl.formatMessage(
mediaType === 'movie' ? globalMessages.movie : globalMessages.tvshow
),
});
} else if (hasPermission(Permission.ADMIN) && serviceUrl) {
mediaLink = serviceUrl;
mediaLinkDescription = intl.formatMessage(messages.openinarr, {
arr: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
});
}
}
const tooltipContent = (
<ul>
{downloadItem.map((status, index) => (

@ -7,9 +7,10 @@ import { mutate } from 'swr';
interface ErrorCardProps {
id: number;
tmdbId: number;
tmdbId?: number;
tvdbId?: number;
type: 'movie' | 'tv';
mbId?: string;
type: 'movie' | 'tv' | 'music';
canExpand?: boolean;
}
@ -17,6 +18,7 @@ const messages = defineMessages({
mediaerror: '{mediaType} Not Found',
tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID',
mbId: 'MusicBrainz ID',
cleardata: 'Clear Data',
});
@ -37,20 +39,27 @@ const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
<div
className="relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover shadow outline-none ring-1 ring-gray-700 transition duration-300"
style={{
paddingBottom: '150%',
aspectRatio: type === 'music' ? '1/1' : undefined,
paddingBottom: type !== 'music' ? '150%' : undefined,
}}
>
<div className="absolute inset-0 h-full w-full overflow-hidden">
<div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div
className={`pointer-events-none z-40 rounded-full shadow ${
type === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
type === 'movie'
? 'bg-blue-500'
: type === 'tv'
? 'bg-purple-600'
: 'bg-green-600'
}`}
>
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{type === 'movie'
? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.tvshow)}
: type === 'tv'
? intl.formatMessage(globalMessages.tvshow)
: intl.formatMessage(globalMessages.music)}
</div>
</div>
<div className="pointer-events-none z-40">
@ -77,7 +86,9 @@ const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
mediaType: intl.formatMessage(
type === 'movie'
? globalMessages.movie
: globalMessages.tvshow
: type === 'tv'
? globalMessages.tvshow
: globalMessages.music
),
})}
</h1>
@ -93,7 +104,9 @@ const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
>
<div className="flex items-center">
<span className="mr-2 font-bold text-gray-400">
{intl.formatMessage(messages.tmdbid)}
{intl.formatMessage(
type === 'music' ? messages.mbId : messages.tmdbid
)}
</span>
{tmdbId}
</div>

@ -0,0 +1,65 @@
import TitleCard from '@app/components/TitleCard';
import { MediaType } from '@server/constants/media';
import type {
ArtistResult,
RecordingResult,
ReleaseGroupResult,
ReleaseResult,
WorkResult,
} from '@server/models/Search';
export interface FetchedDataTitleCardProps {
data:
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| WorkResult
| RecordingResult;
canExpand?: boolean;
}
const FetchedDataTitleCard = ({
canExpand,
data,
}: FetchedDataTitleCardProps) => {
if (data.mediaType === 'artist') {
const newData = data as ArtistResult;
return (
<TitleCard
id={data.id}
image={data.posterPath}
status={newData.mediaInfo?.status}
title={newData.name}
mediaType={data.mediaType}
canExpand={canExpand}
/>
);
} else if (data.mediaType === 'release-group') {
return (
<TitleCard
id={data.id}
image={data.posterPath}
status={data.mediaInfo?.status}
title={data.title}
mediaType={data.mediaType}
canExpand={canExpand}
type={data.type ?? MediaType.MUSIC}
/>
);
} else if (data.mediaType === 'release') {
return (
<TitleCard
id={data.id}
image={data.posterPath}
status={data.mediaInfo?.status}
title={data.title}
mediaType={data.mediaType}
canExpand={canExpand}
type={data.releaseGroup?.type ?? MediaType.MUSIC}
/>
);
}
return null;
};
export default FetchedDataTitleCard;

@ -0,0 +1,95 @@
import TitleCard from '@app/components/TitleCard';
import { Permission, useUser } from '@app/hooks/useUser';
import type { SecondaryType } from '@server/constants/media';
import { MediaType } from '@server/constants/media';
import type {
ArtistResult,
RecordingResult,
ReleaseGroupResult,
ReleaseResult,
WorkResult,
} from '@server/models/Search';
import { useInView } from 'react-intersection-observer';
import useSWR from 'swr';
export interface MusicBrainTitleCardProps {
id: number;
mbId: string;
type?: SecondaryType;
canExpand?: boolean;
displayType?: string;
preData?:
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| WorkResult
| RecordingResult;
}
const MusicTitleCard = ({
id,
mbId,
canExpand,
type,
displayType,
}: MusicBrainTitleCardProps) => {
const { hasPermission } = useUser();
const { ref, inView } = useInView({
triggerOnce: true,
});
const url = `/api/v1/music/${type}/${mbId}`;
const { data, error } = useSWR<
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| WorkResult
| RecordingResult
>(inView ? `${url}` : null);
if (!data && !error) {
return (
<div ref={ref}>
<TitleCard.Placeholder canExpand={canExpand} type="music" />
</div>
);
}
if (!data) {
return hasPermission(Permission.ADMIN) ? (
<TitleCard.ErrorCard id={id} mbId={mbId} type="music" />
) : null;
}
if (data.mediaType === 'artist') {
const newData = data as ArtistResult;
return (
<TitleCard
id={mbId}
image={data.posterPath}
status={newData.mediaInfo?.status}
title={newData.name}
mediaType={data.mediaType}
canExpand={canExpand}
/>
);
} else if (
data.mediaType === 'release-group' ||
data.mediaType === 'release'
) {
return (
<TitleCard
id={mbId}
image={data.posterPath}
status={data.mediaInfo?.status}
title={data.title}
mediaType={data.mediaType}
canExpand={canExpand}
type={displayType ?? MediaType.MUSIC}
/>
);
}
return null;
};
export default MusicTitleCard;

@ -1,15 +1,24 @@
interface PlaceholderProps {
canExpand?: boolean;
type?: 'music' | 'movie' | 'tv';
}
const Placeholder = ({ canExpand = false }: PlaceholderProps) => {
const Placeholder = ({
canExpand = false,
type = 'movie',
}: PlaceholderProps) => {
return (
<div
className={`relative animate-pulse rounded-xl bg-gray-700 ${
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'
}`}
>
<div className="w-full" style={{ paddingBottom: '150%' }} />
<div
className="w-full"
style={
type === 'music' ? { aspectRatio: '1/1' } : { paddingBottom: '150%' }
}
/>
</div>
);
};

@ -11,6 +11,7 @@ import globalMessages from '@app/i18n/globalMessages';
import { withProperties } from '@app/utils/typeHelpers';
import { Transition } from '@headlessui/react';
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline';
import type { SecondaryType } from '@server/constants/media';
import { MediaStatus } from '@server/constants/media';
import type { MediaType } from '@server/models/Search';
import Link from 'next/link';
@ -18,7 +19,7 @@ import { Fragment, useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
interface TitleCardProps {
id: number;
id: number | string;
image?: string;
summary?: string;
year?: string;
@ -28,6 +29,8 @@ interface TitleCardProps {
status?: MediaStatus;
canExpand?: boolean;
inProgress?: boolean;
type?: string;
force_big?: boolean;
}
const TitleCard = ({
@ -40,6 +43,8 @@ const TitleCard = ({
mediaType,
inProgress = false,
canExpand = false,
type,
force_big = false,
}: TitleCardProps) => {
const isTouch = useIsTouch();
const intl = useIntl();
@ -75,29 +80,29 @@ const TitleCard = ({
Permission.REQUEST,
mediaType === 'movie' || mediaType === 'collection'
? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV,
: mediaType === 'tv'
? Permission.REQUEST_TV
: Permission.REQUEST_MUSIC,
],
{ type: 'or' }
);
const tmdbOrMbId: boolean = ['movie', 'tv', 'collection'].includes(mediaType);
return (
<div
className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'}
data-testid="title-card"
>
<RequestModal
tmdbId={id}
tmdbId={tmdbOrMbId ? (id as number) : -1}
mbId={tmdbOrMbId ? '' : (id as string)}
show={showRequestModal}
type={
mediaType === 'movie'
? 'movie'
: mediaType === 'collection'
? 'collection'
: 'tv'
tmdbOrMbId ? (mediaType as 'collection' | 'movie' | 'tv') : 'music'
}
onComplete={requestComplete}
onUpdating={requestUpdating}
onCancel={closeModal}
{...(tmdbOrMbId ? {} : { secondaryType: mediaType as SecondaryType })}
/>
<div
className={`relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover outline-none ring-1 transition duration-300 ${
@ -105,9 +110,11 @@ const TitleCard = ({
? 'scale-105 shadow-lg ring-gray-500'
: 'scale-100 shadow ring-gray-700'
}`}
style={{
paddingBottom: '150%',
}}
style={
tmdbOrMbId || force_big
? { paddingBottom: '150%' }
: { aspectRatio: '1/1' }
}
onMouseEnter={() => {
if (!isTouch) {
setShowDetail(true);
@ -129,7 +136,9 @@ const TitleCard = ({
alt=""
src={
image
? `https://image.tmdb.org/t/p/w300_and_h450_face${image}`
? tmdbOrMbId
? `https://image.tmdb.org/t/p/w300_and_h450_face${image}`
: image
: `/images/overseerr_poster_not_found_logo_top.png`
}
layout="fill"
@ -140,7 +149,9 @@ const TitleCard = ({
className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${
mediaType === 'movie' || mediaType === 'collection'
? 'border-blue-500 bg-blue-600'
: 'border-purple-600 bg-purple-600'
: mediaType === 'tv'
? 'border-purple-600 bg-purple-600'
: 'border-green-600 bg-green-600'
}`}
>
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
@ -148,7 +159,15 @@ const TitleCard = ({
? intl.formatMessage(globalMessages.movie)
: mediaType === 'collection'
? intl.formatMessage(globalMessages.collection)
: intl.formatMessage(globalMessages.tvshow)}
: mediaType === 'tv'
? intl.formatMessage(globalMessages.tvshow)
: mediaType === 'release'
? intl.formatMessage(globalMessages.release)
: mediaType === 'artist'
? intl.formatMessage(globalMessages.artist)
: mediaType === 'release-group'
? type
: ''}
</div>
</div>
{currentStatus && currentStatus !== MediaStatus.UNKNOWN && (
@ -178,7 +197,7 @@ const TitleCard = ({
<Transition
as={Fragment}
show={!image || showDetail || showRequestModal}
show={showDetail || showRequestModal}
enter="transition-opacity"
enterFrom="opacity-0"
enterTo="opacity-100"
@ -189,11 +208,9 @@ const TitleCard = ({
<div className="absolute inset-0 overflow-hidden rounded-xl">
<Link
href={
mediaType === 'movie'
? `/movie/${id}`
: mediaType === 'collection'
? `/collection/${id}`
: `/tv/${id}`
tmdbOrMbId
? `/${mediaType}/${id}`
: `/music/${mediaType}/${id as string}`
}
>
<a

@ -16,6 +16,7 @@ import type {
UserWatchDataResponse,
} from '@server/interfaces/api/userInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import type { TvDetails } from '@server/models/Tv';
import Link from 'next/link';
import { useRouter } from 'next/router';
@ -38,7 +39,7 @@ const messages = defineMessages({
'Media added to your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink> will appear here.',
});
type MediaTitle = MovieDetails | TvDetails;
type MediaTitle = MovieDetails | TvDetails | ReleaseResult | ArtistResult;
const UserProfile = () => {
const intl = useIntl();
@ -126,11 +127,13 @@ const UserProfile = () => {
key={user.id}
isDarker
backgroundImages={Object.values(availableTitles)
.filter((media) => media.backdropPath)
.map(
(media) =>
`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`
)
.filter((media) => 'backdropPath' in media && media.backdropPath)
.map((media) => {
if ('backdropPath' in media) {
return `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}`;
}
return '';
})
.slice(0, 6)}
/>
</div>
@ -354,10 +357,10 @@ const UserProfile = () => {
})}
items={watchlistItems?.results.map((item) => (
<TmdbTitleCard
id={item.tmdbId}
id={item.tmdbId as number}
key={`watchlist-slider-item-${item.ratingKey}`}
tmdbId={item.tmdbId}
type={item.mediaType}
tmdbId={item.tmdbId as number}
type={item.mediaType as 'movie' | 'tv'}
/>
))}
/>
@ -381,9 +384,9 @@ const UserProfile = () => {
<TmdbTitleCard
key={`media-slider-item-${item.id}`}
id={item.id}
tmdbId={item.tmdbId}
tmdbId={item.tmdbId as number}
tvdbId={item.tvdbId}
type={item.mediaType}
type={item.mediaType as 'movie' | 'tv'}
/>
))}
/>

@ -15,6 +15,7 @@ const defaultSettings = {
localLogin: true,
movie4kEnabled: false,
series4kEnabled: false,
fallbackImage: '/images/overseerr_poster_not_found_logo_top.png',
region: '',
originalLanguage: '',
partialRequestsEnabled: true,

@ -19,6 +19,10 @@ const globalMessages = defineMessages({
collection: 'Collection',
tvshow: 'Series',
tvshows: 'Series',
music: 'Music',
musics: 'Musics',
artist: 'Artist',
release: 'Release',
cancel: 'Cancel',
canceling: 'Canceling…',
approve: 'Approve',

@ -198,7 +198,7 @@
"components.LanguageSelector.languageServerDefault": "Default ({language})",
"components.LanguageSelector.originalLanguageDefault": "All Languages",
"components.Layout.LanguagePicker.displaylanguage": "Display Language",
"components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV",
"components.Layout.SearchInput.searchPlaceholder": "Search Musics, Movies & TV",
"components.Layout.Sidebar.browsemovies": "Movies",
"components.Layout.Sidebar.browsetv": "Series",
"components.Layout.Sidebar.dashboard": "Discover",

@ -186,6 +186,7 @@ CoreApp.getInitialProps = async (initialProps) => {
locale: 'en',
emailEnabled: false,
newPlexLogin: true,
fallbackImage: '/images/overseerr_poster_not_found_logo_top.png',
};
if (ctx.res) {

@ -0,0 +1,8 @@
import DiscoverMusics from '@app/components/Discover/DiscoverMusics';
import type { NextPage } from 'next';
const DiscoverMusicsPage: NextPage = () => {
return <DiscoverMusics />;
};
export default DiscoverMusicsPage;

@ -0,0 +1,68 @@
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import MusicDetails from '@app/components/MusicDetails';
import Error from '@app/pages/_error';
import { SecondaryType } from '@server/constants/media';
import type {
ArtistResult,
RecordingResult,
ReleaseGroupResult,
ReleaseResult,
WorkResult,
} from '@server/models/Search';
import axios from 'axios';
import type { GetServerSideProps, NextPage } from 'next';
interface MusicPageProps {
music?:
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult;
}
const MusicPage: NextPage<MusicPageProps> = ({ music }) => {
if (!music) {
return <LoadingSpinner />;
}
switch (music?.mediaType) {
case SecondaryType.ARTIST:
return <MusicDetails type={SecondaryType.ARTIST} artist={music} />;
case SecondaryType.RELEASE_GROUP:
return (
<MusicDetails type={SecondaryType.RELEASE_GROUP} releaseGroup={music} />
);
case SecondaryType.RELEASE:
return <MusicDetails type={SecondaryType.RELEASE} release={music} />;
default:
return <Error statusCode={404} />;
}
};
export const getServerSideProps: GetServerSideProps<MusicPageProps> = async (
ctx
) => {
const response = await axios.get<
| ArtistResult
| ReleaseGroupResult
| ReleaseResult
| RecordingResult
| WorkResult
>(
`http://localhost:${process.env.PORT || 5055}/api/v1/music/${
ctx.query.type
}/${ctx.query.mbId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return {
props: {
music: response.data,
},
};
};
export default MusicPage;

@ -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", "server/**/*.d.ts"],
"exclude": ["node_modules"]
}

34129
yarn.lock

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save