pull/3800/merge
Anatole Sot 2 months ago committed by GitHub
commit be4d75bc4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

1
.gitignore vendored

@ -67,3 +67,4 @@ tsconfig.tsbuildinfo
# Config Cache Directory # Config Cache Directory
config/cache config/cache

Binary file not shown.

@ -26,6 +26,8 @@ tags:
description: Endpoints related to retrieving movies and their details. description: Endpoints related to retrieving movies and their details.
- name: tv - name: tv
description: Endpoints related to retrieving TV series and their details. description: Endpoints related to retrieving TV series and their details.
- name: music
description: Endpoints related to retrieving music and details about artists,...
- name: other - name: other
description: Endpoints related to other TMDB data description: Endpoints related to other TMDB data
- name: person - name: person
@ -35,7 +37,7 @@ tags:
- name: collection - name: collection
description: Endpoints related to retrieving collection details. description: Endpoints related to retrieving collection details.
- name: service - name: service
description: Endpoints related to getting service (Radarr/Sonarr) details. description: Endpoints related to getting service (Radarr/Sonarr/Lidarr) details.
servers: servers:
- url: '{server}/api/v1' - url: '{server}/api/v1'
variables: variables:
@ -466,6 +468,61 @@ components:
- is4k - is4k
- enableSeasonFolders - enableSeasonFolders
- isDefault - 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: ServarrTag:
type: object type: object
properties: properties:
@ -593,6 +650,149 @@ components:
oneOf: oneOf:
- $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult' - $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: Genre:
type: object type: object
properties: properties:
@ -1067,6 +1267,8 @@ components:
type: string type: string
example: '2020-09-12T10:00:27.000Z' example: '2020-09-12T10:00:27.000Z'
readOnly: true readOnly: true
secondaryType:
type: string
Cast: Cast:
type: object type: object
properties: properties:
@ -2408,6 +2610,150 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/SonarrSettings' $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: /settings/public:
get: get:
summary: Get public settings summary: Get public settings
@ -4076,6 +4422,8 @@ paths:
- $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/MovieResult'
- $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/TvResult'
- $ref: '#/components/schemas/PersonResult' - $ref: '#/components/schemas/PersonResult'
- $ref: '#/components/schemas/ArtistResult'
- $ref: '#/components/schemas/ReleaseResult'
/search/keyword: /search/keyword:
get: get:
summary: Search for keywords summary: Search for keywords
@ -4095,6 +4443,11 @@ paths:
type: number type: number
example: 1 example: 1
default: 1 default: 1
- in: query
name: type
schema:
type: string
enum: [movie,tv,music]
responses: responses:
'200': '200':
description: Results description: Results
@ -4115,7 +4468,9 @@ paths:
results: results:
type: array type: array
items: items:
$ref: '#/components/schemas/Keyword' oneOf:
- $ref: '#/components/schemas/Keyword'
- type: string
/search/company: /search/company:
get: get:
summary: Search for companies summary: Search for companies
@ -4734,6 +5089,57 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/TvResult' $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: /discover/trending:
get: get:
summary: Trending movies and TV summary: Trending movies and TV
@ -5005,10 +5411,12 @@ paths:
properties: properties:
mediaType: mediaType:
type: string type: string
enum: [movie, tv] enum: [movie, tv, music]
example: movie example: movie
mediaId: mediaId:
type: number oneOf:
- type: number
- type: string
example: 123 example: 123
tvdbId: tvdbId:
type: number type: number
@ -5035,6 +5443,9 @@ paths:
userId: userId:
type: number type: number
nullable: true nullable: true
secondaryType:
type: string
enum: [release,artist]
required: required:
- mediaType - mediaType
- mediaId - mediaId
@ -5119,7 +5530,7 @@ paths:
properties: properties:
mediaType: mediaType:
type: string type: string
enum: [movie, tv] enum: [movie, tv, music]
seasons: seasons:
type: array type: array
items: items:
@ -5168,7 +5579,7 @@ paths:
post: post:
summary: Retry failed request summary: Retry failed request
description: | 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`. Requires the `MANAGE_REQUESTS` permission or `ADMIN`.
tags: tags:
@ -5676,6 +6087,82 @@ paths:
$ref: '#/components/schemas/CreditCrew' $ref: '#/components/schemas/CreditCrew'
id: id:
type: number 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: /media:
get: get:
summary: Get media summary: Get media
@ -5707,6 +6194,12 @@ paths:
type: string type: string
enum: [added, modified, mediaAdded] enum: [added, modified, mediaAdded]
default: added default: added
- in: query
name: type
schema:
type: string
enum: [all,movie,tv,music,artist,release]
default: all
responses: responses:
'200': '200':
description: Returned media description: Returned media
@ -5954,6 +6447,46 @@ paths:
type: array type: array
items: items:
$ref: '#/components/schemas/SonarrSeries' $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: /regions:
get: get:
summary: Regions supported by TMDB summary: Regions supported by TMDB

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "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: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:next": "next build",
"build": "yarn build:next && yarn build:server", "build": "yarn build:next && yarn build:server",
@ -43,7 +43,7 @@
"axios-rate-limit": "1.3.0", "axios-rate-limit": "1.3.0",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "2.0.0",
"cookie-parser": "1.4.6", "cookie-parser": "1.4.6",
"copy-to-clipboard": "3.3.3", "copy-to-clipboard": "3.3.3",
"country-flag-icons": "1.5.5", "country-flag-icons": "1.5.5",
@ -62,17 +62,18 @@
"lodash": "4.17.21", "lodash": "4.17.21",
"next": "12.3.4", "next": "12.3.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-gyp": "9.3.1", "node-gyp": "^8.0.0",
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
"nodebrainz": "^2.1.1",
"nodemailer": "6.9.1", "nodemailer": "6.9.1",
"openpgp": "5.7.0", "openpgp": "5.7.0",
"plex-api": "5.3.2", "plex-api": "5.3.2",
"pug": "3.0.2", "pug": "3.0.2",
"react": "18.2.0", "react": "^17.0.0",
"react-ace": "10.1.0", "react-ace": "10.1.0",
"react-animate-height": "2.1.2", "react-animate-height": "2.1.2",
"react-aria": "3.23.0", "react-aria": "3.23.0",
"react-dom": "18.2.0", "react-dom": "^17.0.0",
"react-intersection-observer": "9.4.3", "react-intersection-observer": "9.4.3",
"react-intl": "6.2.10", "react-intl": "6.2.10",
"react-markdown": "8.0.5", "react-markdown": "8.0.5",
@ -89,7 +90,7 @@
"sqlite3": "5.1.4", "sqlite3": "5.1.4",
"swagger-ui-express": "4.6.2", "swagger-ui-express": "4.6.2",
"swr": "2.0.4", "swr": "2.0.4",
"typeorm": "0.3.12", "typeorm": "0.3.11",
"web-push": "3.5.0", "web-push": "3.5.0",
"winston": "3.8.2", "winston": "3.8.2",
"winston-daily-rotate-file": "4.7.1", "winston-daily-rotate-file": "4.7.1",
@ -100,6 +101,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "7.21.0", "@babel/cli": "7.21.0",
"@babel/core": "^7.0.0",
"@commitlint/cli": "17.4.4", "@commitlint/cli": "17.4.4",
"@commitlint/config-conventional": "17.4.4", "@commitlint/config-conventional": "17.4.4",
"@semantic-release/changelog": "6.0.2", "@semantic-release/changelog": "6.0.2",
@ -157,7 +159,7 @@
"prettier": "2.8.4", "prettier": "2.8.4",
"prettier-plugin-organize-imports": "3.2.2", "prettier-plugin-organize-imports": "3.2.2",
"prettier-plugin-tailwindcss": "0.2.3", "prettier-plugin-tailwindcss": "0.2.3",
"semantic-release": "19.0.5", "semantic-release": "18.0.0",
"semantic-release-docker-buildx": "1.0.1", "semantic-release-docker-buildx": "1.0.1",
"tailwindcss": "3.2.7", "tailwindcss": "3.2.7",
"ts-node": "10.9.1", "ts-node": "10.9.1",

@ -0,0 +1,944 @@
import logger from '@server/logger';
import type {
ArtistSearchResponse,
luceneSearchOptions,
RecordingSearchResponse,
ReleaseGroupSearchResponse,
ReleaseSearchResponse,
TagSearchResponse,
WorkSearchResponse,
} from 'nodebrainz';
import BaseNodeBrainz from 'nodebrainz';
import type {
Artist,
ArtistCredit,
Group,
mbArtist,
mbRecording,
mbRelease,
mbReleaseGroup,
mbWork,
Medium,
Recording,
Relation,
Release,
SearchOptions,
Tag,
Track,
Work,
} from './interfaces';
import { mbArtistType, mbReleaseGroupType, mbWorkType } from './interfaces';
interface ArtistSearchOptions {
query: string;
tags?: string[]; // (part of) a tag attached to the artist
limit?: number;
offset?: number;
}
interface RecordingSearchOptions {
query: string;
tags?: string[]; // (part of) a tag attached to the recording
artistname?: string; // (part of) the name of any of the recording artists
release?: string; // the name of a release that the recording appears on
offset?: number;
limit?: number;
}
interface ReleaseSearchOptions {
query: string;
artistname?: string; // (part of) the name of any of the release artists
tags?: string[]; // (part of) a tag attached to the release
limit?: number;
offset?: number;
}
interface ReleaseGroupSearchOptions {
query: string;
artistname?: string; // (part of) the name of any of the release group artists
tags?: string[]; // (part of) a tag attached to the release group
limit?: number;
offset?: number;
}
interface WorkSearchOptions {
query: string;
artist?: string; // (part of) the name of an artist related to the work (e.g. a composer or lyricist)
tags?: string[]; // (part of) a tag attached to the work
limit?: number;
offset?: number;
}
function searchOptionstoArtistSearchOptions(
options: SearchOptions
): ArtistSearchOptions {
const data: ArtistSearchOptions = {
query: options.query,
};
if (options.tags) {
data.tags = options.tags;
}
if (options.limit) {
data.limit = options.limit;
} else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page - 1) * data.limit;
}
return data;
}
function searchOptionstoReleaseSearchOptions(
options: SearchOptions
): ReleaseSearchOptions {
const data: ReleaseSearchOptions = {
query: options.query,
};
if (options.artistname) {
data.artistname = options.artistname;
}
if (options.tags) {
data.tags = options.tags;
}
if (options.limit) {
data.limit = options.limit;
} else {
data.limit = 25;
}
if (options.page) {
data.offset = (options.page - 1) * data.limit;
}
return data;
}
function convertRelease(release: Release): mbRelease {
return {
media_type: 'release',
id: release.id,
title: release.title,
artist: (release['artist-credit'] ?? []).map(convertArtistCredit),
date:
release['release-events'] && release['release-events'].length > 0
? new Date(String(release['release-events'][0].date))
: undefined,
tracks: (release.media ?? []).flatMap(convertMedium),
tags: (release.tags ?? []).map(convertTag),
releaseGroup: convertReleaseGroup(release['release-group'] ?? {}),
};
}
function convertReleaseGroup(releaseGroup: Group): mbReleaseGroup {
return {
media_type: 'release-group',
id: releaseGroup.id,
title: releaseGroup.title,
artist: (releaseGroup['artist-credit'] ?? []).map(convertArtistCredit),
releases: (releaseGroup.releases ?? []).map(convertRelease),
type:
(releaseGroup['primary-type'] as mbReleaseGroupType) ||
mbReleaseGroupType.OTHER,
firstReleased: new Date(releaseGroup['first-release-date']),
tags: (releaseGroup.tags ?? []).map((tag: Tag) => tag.name),
};
}
function convertRecording(recording: Recording): mbRecording {
return {
media_type: 'recording',
id: recording.id,
title: recording.title,
artist: (recording['artist-credit'] ?? []).map(convertArtistCredit),
length: recording.length,
firstReleased: new Date(recording['first-release-date']),
tags: (recording.tags ?? []).map(convertTag),
};
}
function convertArtist(artist: Artist): mbArtist {
return {
media_type: 'artist',
id: artist.id,
name: artist.name,
sortName: artist['sort-name'],
type: (artist.type as mbArtistType) || mbArtistType.OTHER,
recordings: (artist.recordings ?? []).map(convertRecording),
releases: (artist.releases ?? []).map(convertRelease),
releaseGroups: (artist['release-groups'] ?? []).map(convertReleaseGroup),
works: (artist.works ?? []).map(convertWork),
tags: (artist.tags ?? []).map(convertTag),
};
}
function convertWork(work: Work): mbWork {
return {
media_type: 'work',
id: work.id,
title: work.title,
type: (work.type as mbWorkType) || mbWorkType.OTHER,
artist: (work.relations ?? []).map(convertRelation),
tags: (work.tags ?? []).map(convertTag),
};
}
function convertArtistCredit(artistCredit: ArtistCredit): mbArtist {
return {
media_type: 'artist',
id: artistCredit.artist.id,
name: artistCredit.artist.name,
sortName: artistCredit.artist['sort-name'],
type: (artistCredit.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: (artistCredit.artist.tags ?? []).map(convertTag),
};
}
function convertRelation(relation: Relation): mbArtist {
return {
media_type: 'artist',
id: relation.artist.id,
name: relation.artist.name,
sortName: relation.artist['sort-name'],
type: (relation.artist.type as mbArtistType) || mbArtistType.OTHER,
tags: (relation.artist.tags ?? []).map(convertTag),
};
}
function convertTag(tag: Tag): string {
return tag.name;
}
function convertMedium(medium: Medium): mbRecording[] {
return (medium.tracks ?? []).map(convertTrack);
}
function convertTrack(track: Track): mbRecording {
return {
media_type: 'recording',
id: track.id,
title: track.title,
artist: (track.recording['artist-credit'] ?? []).map(convertArtistCredit),
length: track.recording.length,
tags: (track.recording.tags ?? []).map(convertTag),
};
}
function processReleaseSearchParams(
search: ReleaseSearchOptions
): luceneSearchOptions {
const processedSearchParams: luceneSearchOptions = {
query: search.query,
limit: search.limit,
offset: search.offset,
};
if (search.artistname) {
processedSearchParams.query += ` AND artist:"${search.artistname}"`;
}
if (search.tags) {
processedSearchParams.query += ` AND tag:"${search.tags.join(
'" AND tag:"'
)}"`;
}
return processedSearchParams;
}
function processArtistSearchParams(
search: ArtistSearchOptions
): luceneSearchOptions {
const processedSearchParams: luceneSearchOptions = {
query: search.query,
limit: search.limit,
offset: search.offset,
};
if (search.tags) {
processedSearchParams.query += ` AND tag:"${search.tags.join(
'" AND tag:"'
)}"`;
}
return processedSearchParams;
}
class MusicBrainz extends BaseNodeBrainz {
constructor() {
super({
userAgent:
'Overseer-with-lidar-support/0.1 ( https://github.com/ano0002/overseerr )',
retryOn: true,
retryDelay: 3000,
retryCount: 3,
});
}
public searchMulti = async (search: SearchOptions) => {
try {
const artistSearch = searchOptionstoArtistSearchOptions(search);
const releaseSearch = searchOptionstoReleaseSearchOptions(search);
const artistResults = await this.searchArtists(artistSearch);
const releaseResults = await this.searchReleases(releaseSearch);
const combinedResults = {
status: 'ok',
artistResults,
releaseResults,
};
return combinedResults;
} catch (e) {
return {
status: 'error',
artistResults: [],
releaseResults: [],
};
}
};
public searchArtists = async (
search: ArtistSearchOptions
): Promise<mbArtist[]> => {
try {
return await new Promise<mbArtist[]>((resolve, reject) => {
const processedSearch = processArtistSearchParams(search);
this.luceneSearch('artist', processedSearch, (error, data) => {
if (error) {
reject(error);
} else {
const rawResults = data as unknown as ArtistSearchResponse;
const results = rawResults.artists.map(convertArtist);
resolve(results);
}
});
});
} catch (e) {
logger.error('Failed to search for artists', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbArtist[]>((resolve) => resolve([]));
}
};
public searchRecordings = async (
search: RecordingSearchOptions
): Promise<mbRecording[]> => {
try {
return await new Promise<mbRecording[]>((resolve, reject) => {
this.search('recording', search, (error, data) => {
if (error) {
reject(error);
} else {
const rawResults = data as unknown as RecordingSearchResponse;
const results = rawResults.recordings.map(convertRecording);
resolve(results);
}
});
});
} catch (e) {
logger.error('Failed to search for recordings', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRecording[]>((resolve) => resolve([]));
}
};
public searchReleaseGroups = (
search: ReleaseGroupSearchOptions
): Promise<mbReleaseGroup[]> => {
try {
return new Promise<mbReleaseGroup[]>((resolve, reject) => {
this.search('release-group', search, (error, data) => {
if (error) {
reject(error);
} else {
const rawResults = data as unknown as ReleaseGroupSearchResponse;
const results =
rawResults['release-groups'].map(convertReleaseGroup);
resolve(results);
}
});
});
} catch (e) {
logger.error('Failed to search for release groups', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbReleaseGroup[]>((resolve) => resolve([]));
}
};
public searchReleases = (
search: ReleaseSearchOptions
): Promise<mbRelease[]> => {
try {
const processedSearchParams = processReleaseSearchParams(search);
return new Promise<mbRelease[]>((resolve, reject) => {
this.luceneSearch('release', processedSearchParams, (error, data) => {
if (error) {
reject(error);
} else {
const rawResults = data as unknown as ReleaseSearchResponse;
const results = rawResults.releases.map(convertRelease);
resolve(results);
}
});
});
} catch (e) {
logger.error('Failed to search for releases', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRelease[]>((resolve) => resolve([]));
}
};
public searchWorks = (search: WorkSearchOptions): Promise<mbWork[]> => {
try {
return new Promise<mbWork[]>((resolve, reject) => {
this.search('work', search, (error, data) => {
if (error) {
reject(error);
} else {
const rawResults = data as unknown as WorkSearchResponse;
const results = rawResults.works.map(convertWork);
resolve(results);
}
});
});
} catch (e) {
logger.error('Failed to search for works', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbWork[]>((resolve) => resolve([]));
}
};
public searchTags = (query: string): Promise<string[]> => {
try {
return new Promise<string[]>((resolve, reject) => {
this.search('tag', { tag: query }, (error, data) => {
if (error) {
reject(error);
} else {
const rawResults = data as TagSearchResponse;
const results = rawResults.tags.map((tag) => tag.name);
resolve(results);
}
});
});
} catch (e) {
logger.error('Failed to search for tags', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<string[]>((resolve) => resolve([]));
}
};
public getArtist = (artistId: string): Promise<mbArtist> => {
try {
return new Promise<mbArtist>((resolve, reject) => {
this.artist(
artistId,
{
inc: 'tags+recordings+releases+release-groups+works',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertArtist(data as Artist);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get artist', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbArtist>((resolve) => resolve({} as mbArtist));
}
};
public getFullArtist = (
artistId: string,
maxElements = 25,
startOffset = 0
): Promise<mbArtist> => {
try {
return new Promise<mbArtist>((resolve, reject) => {
this.artist(
artistId,
{
inc: 'tags',
},
async (error, data) => {
if (error) {
reject(error);
} else {
const results = convertArtist(data as Artist);
results.releases = await this.getReleases(
artistId,
maxElements,
startOffset
);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get full artist', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbArtist>((resolve) => resolve({} as mbArtist));
}
};
public getRecordings = (
artistId: string,
maxElements = 50,
startOffset = 0
): Promise<mbRecording[]> => {
try {
return new Promise<mbRecording[]>((resolve, reject) => {
this.browse(
'recording',
{ artist: artistId, offset: startOffset },
async (error, data) => {
if (error) {
reject(error);
} else {
data = data as {
'recording-count': number;
'recording-offset': number;
recordings: Recording[];
};
// Get the first 25 results
const total = data['recording-count'];
let results: mbRecording[] =
data.recordings.map(convertRecording);
// Slice the results into smaller chunks to avoid hitting the limit of 100
for (
let i = data.recordings.length + startOffset;
i < total && i < maxElements;
i += 100
) {
results = results.concat(
await new Promise<mbRecording[]>((resolve2, reject2) => {
this.browse(
'recording',
{
artist: artistId,
offset: i,
limit: 100,
},
(error, data) => {
if (error) {
reject2(error);
} else {
const results = (
(
data as {
'recording-count': number;
'recording-offset': number;
recordings: Recording[];
}
).recordings ?? []
).map(convertRecording);
resolve2(results);
}
}
);
})
);
}
results = results.reduce((arr: mbRecording[], item) => {
const exists = !!arr.find((x) => x.title === item.title);
if (!exists) {
arr.push(item);
}
return arr;
}, []);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get recordings by artist', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRecording[]>((resolve) => resolve([]));
}
};
public getRecording = (recordingId: string): Promise<mbRecording> => {
try {
return new Promise<mbRecording>((resolve, reject) => {
this.recording(
recordingId,
{
inc: 'tags+artists+releases',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertRecording(data as Recording);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get recording', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRecording>((resolve) => resolve({} as mbRecording));
}
};
public getReleaseGroups = (
artistId: string,
maxElements = 50,
startOffset = 0
): Promise<mbReleaseGroup[]> => {
try {
return new Promise<mbReleaseGroup[]>((resolve, reject) => {
this.browse(
'release-group',
{ artist: artistId, offset: startOffset },
async (error, data) => {
if (error) {
reject(error);
} else {
data = data as {
'release-group-count': number;
'release-group-offset': number;
'release-groups': Group[];
};
// Get the first 25 results
const total = data['release-group-count'];
let results: mbReleaseGroup[] =
data['release-groups'].map(convertReleaseGroup);
// Slice the results into smaller chunks to avoid hitting the limit of 100
for (
let i = data['release-groups'].length + startOffset;
i < total && i < maxElements;
i += 100
) {
results = results.concat(
await new Promise<mbReleaseGroup[]>((resolve2, reject2) => {
this.browse(
'release-group',
{
artist: artistId,
offset: i,
limit: 100,
},
(error, data) => {
if (error) {
reject2(error);
} else {
const results = (
(
data as {
'release-group-count': number;
'release-group-offset': number;
'release-groups': Group[];
}
)['release-groups'] ?? []
).map(convertReleaseGroup);
resolve2(results);
}
}
);
})
);
}
results = results.reduce((arr: mbReleaseGroup[], item) => {
const exists = !!arr.find((x) => x.title === item.title);
if (!exists) {
arr.push(item);
}
return arr;
}, []);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get release-groups by artist', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbReleaseGroup[]>((resolve) => resolve([]));
}
};
public getReleaseGroup = (
releaseGroupId: string
): Promise<mbReleaseGroup> => {
try {
return new Promise<mbReleaseGroup>((resolve, reject) => {
this.releaseGroup(
releaseGroupId,
{
inc: 'tags+artists+releases',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertReleaseGroup(data as Group);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get release-group', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbReleaseGroup>((resolve) =>
resolve({} as mbReleaseGroup)
);
}
};
public getReleases = (
artistId: string,
maxElements = 50,
startOffset = 0
): Promise<mbRelease[]> => {
try {
return new Promise<mbRelease[]>((resolve, reject) => {
this.browse(
'release',
{ artist: artistId, offset: startOffset, inc: 'tags+release-groups' },
async (error, data) => {
if (error) {
reject(error);
} else {
data = data as {
'release-count': number;
'release-offset': number;
releases: Release[];
};
// Get the first 25 results
const total = data['release-count'];
let results: mbRelease[] = data.releases.map(convertRelease);
// Slice the results into smaller chunks to avoid hitting the limit of 100
for (
let i = data.releases.length + startOffset;
i < total && i < maxElements;
i += 100
) {
results = results.concat(
await new Promise<mbRelease[]>((resolve2, reject2) => {
this.browse(
'release',
{
artist: artistId,
offset: i,
limit: 100,
inc: 'tags+release-groups',
},
(error, data) => {
if (error) {
reject2(error);
} else {
const results = (
(
data as {
'release-count': number;
'release-offset': number;
releases: Release[];
}
).releases ?? []
).map(convertRelease);
resolve2(results);
}
}
);
})
);
}
results = results.reduce((arr: mbRelease[], item) => {
const exists = !!arr.find((x) => x.title === item.title);
if (!exists) {
arr.push(item);
}
return arr;
}, []);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get releases by artist', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRelease[]>((resolve) => resolve([]));
}
};
public getRelease = (releaseId: string): Promise<mbRelease> => {
try {
return new Promise<mbRelease>((resolve, reject) => {
this.release(
releaseId,
{
inc: 'tags+artists+recordings+release-groups',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertRelease(data as Release);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get release', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbRelease>((resolve) => resolve({} as mbRelease));
}
};
public getWorks = (
artistId: string,
maxElements = 50,
startOffset = 0
): Promise<mbWork[]> => {
try {
return new Promise<mbWork[]>((resolve, reject) => {
this.browse(
'work',
{ artist: artistId, offset: startOffset },
async (error, data) => {
if (error) {
reject(error);
} else {
data = data as {
'work-count': number;
'work-offset': number;
works: Work[];
};
// Get the first 25 results
const total = data['work-count'];
let results: mbWork[] = data.works.map(convertWork);
// Slice the results into smaller chunks to avoid hitting the limit of 100
for (
let i = data.works.length + startOffset;
i < total && i < maxElements;
i += 100
) {
results = results.concat(
await new Promise<mbWork[]>((resolve2, reject2) => {
this.browse(
'work',
{
artist: artistId,
offset: i,
limit: 100,
},
(error, data) => {
if (error) {
reject2(error);
} else {
const results = (
(
data as {
'work-count': number;
'work-offset': number;
works: Work[];
}
).works ?? []
).map(convertWork);
resolve2(results);
}
}
);
})
);
}
results = results.reduce((arr: mbWork[], item) => {
const exists = !!arr.find((x) => x.title === item.title);
if (!exists) {
arr.push(item);
}
return arr;
}, []);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get works by artist', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbWork[]>((resolve) => resolve([]));
}
};
public getWork = (workId: string): Promise<mbWork> => {
try {
return new Promise<mbWork>((resolve, reject) => {
this.work(
workId,
{
inc: 'tags+artist-rels',
},
(error, data) => {
if (error) {
reject(error);
} else {
const results = convertWork(data as Work);
resolve(results);
}
}
);
});
} catch (e) {
logger.error('Failed to get work', {
label: 'MusicBrainz',
message: e.message,
});
return new Promise<mbWork>((resolve) => resolve({} as mbWork));
}
};
}
export default MusicBrainz;
export type {
SearchOptions,
ArtistSearchOptions,
RecordingSearchOptions,
ReleaseSearchOptions,
ReleaseGroupSearchOptions,
WorkSearchOptions,
};

@ -0,0 +1,287 @@
// Purpose: Interfaces for MusicBrainz data.
export interface mbDefaultType {
media_type: string;
id: string;
tags: string[];
}
export enum mbArtistType {
PERSON = 'Person',
GROUP = 'Group',
ORCHESTRA = 'Orchestra',
CHOIR = 'Choir',
CHARACTER = 'Character',
OTHER = 'Other',
}
export interface mbArtist extends mbDefaultType {
media_type: 'artist';
name: string;
sortName: string;
type: mbArtistType;
recordings?: mbRecording[];
releases?: mbRelease[];
releaseGroups?: mbReleaseGroup[];
works?: mbWork[];
gender?: string;
area?: string;
beginDate?: string;
endDate?: string;
}
export interface mbRecording extends mbDefaultType {
media_type: 'recording';
title: string;
artist: mbArtist[];
length: number;
firstReleased?: Date;
}
export interface mbRelease extends mbDefaultType {
media_type: 'release';
title: string;
artist: mbArtist[];
date?: Date;
tracks?: mbRecording[];
releaseGroup?: mbReleaseGroup;
}
export enum mbReleaseGroupType {
ALBUM = 'Album',
SINGLE = 'Single',
EP = 'EP',
BROADCAST = 'Broadcast',
OTHER = 'Other',
}
export interface mbReleaseGroup extends mbDefaultType {
media_type: 'release-group';
title: string;
artist: mbArtist[];
type: mbReleaseGroupType;
firstReleased?: Date;
releases?: mbRelease[];
}
export enum mbWorkType {
ARIA = 'Aria',
BALLET = 'Ballet',
CANTATA = 'Cantata',
CONCERTO = 'Concerto',
SONATA = 'Sonata',
SUITE = 'Suite',
MADRIGAL = 'Madrigal',
MASS = 'Mass',
MOTET = 'Motet',
OPERA = 'Opera',
ORATORIO = 'Oratorio',
OVERTURE = 'Overture',
PARTITA = 'Partita',
QUARTET = 'Quartet',
SONG_CYCLE = 'Song-cycle',
SYMPHONY = 'Symphony',
SONG = 'Song',
SYMPHONIC_POEM = 'Symphonic poem',
ZARZUELA = 'Zarzuela',
ETUDE = 'Étude',
POEM = 'Poem',
SOUNDTRACK = 'Soundtrack',
PROSE = 'Prose',
OPERETTA = 'Operetta',
AUDIO_DRAMA = 'Audio drama',
BEIJING_OPERA = 'Beijing opera',
PLAY = 'Play',
MUSICAL = 'Musical',
INCIDENTAL_MUSIC = 'Incidental music',
OTHER = 'Other',
}
export interface mbWork extends mbDefaultType {
media_type: 'work';
title: string;
type: mbWorkType;
artist: mbArtist[];
}
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 logger from '@server/logger';
import NodePlexAPI from 'plex-api'; import NodePlexAPI from 'plex-api';
const SEARCHTYPES = {
movie: 1,
show: 2,
artist: '8,9',
};
export interface PlexLibraryItem { export interface PlexLibraryItem {
ratingKey: string; ratingKey: string;
parentRatingKey?: string; parentRatingKey?: string;
@ -16,7 +22,7 @@ export interface PlexLibraryItem {
Guid?: { Guid?: {
id: string; id: string;
}[]; }[];
type: 'movie' | 'show' | 'season' | 'episode'; type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
Media: Media[]; Media: Media[];
} }
@ -28,7 +34,7 @@ interface PlexLibraryResponse {
} }
export interface PlexLibrary { export interface PlexLibrary {
type: 'show' | 'movie'; type: 'show' | 'movie' | 'artist';
key: string; key: string;
title: string; title: string;
agent: string; agent: string;
@ -44,7 +50,7 @@ export interface PlexMetadata {
ratingKey: string; ratingKey: string;
parentRatingKey?: string; parentRatingKey?: string;
guid: string; guid: string;
type: 'movie' | 'show' | 'season'; type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track';
title: string; title: string;
Guid: { Guid: {
id: string; id: string;
@ -152,7 +158,10 @@ class PlexAPI {
const newLibraries: Library[] = libraries const newLibraries: Library[] = libraries
// Remove libraries that are not movie or show // Remove libraries that are not movie or show
.filter( .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) // Remove libraries that do not have a metadata agent set (usually personal video libraries)
.filter((library) => library.agent !== 'com.plexapp.agents.none') .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( public async getMetadata(
key: string, key: string,
options: { includeChildren?: boolean } = {} options: { includeChildren?: boolean } = {}
@ -227,18 +256,17 @@ class PlexAPI {
options: { addedAt: number } = { options: { addedAt: number } = {
addedAt: Date.now() - 1000 * 60 * 60, addedAt: Date.now() - 1000 * 60 * 60,
}, },
mediaType: 'movie' | 'show' mediaType: 'movie' | 'show' | 'artist'
): Promise<PlexLibraryItem[]> { ): Promise<PlexLibraryItem[]> {
const response = await this.plexClient.query<PlexLibraryResponse>({ const response = await this.plexClient.query<PlexLibraryResponse>({
uri: `/library/sections/${id}/all?type=${ uri: `/library/sections/${id}/all?type=${
mediaType === 'show' ? '4' : '1' SEARCHTYPES[mediaType]
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`, }&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
extraHeaders: { extraHeaders: {
'X-Plex-Container-Start': `0`, 'X-Plex-Container-Start': `0`,
'X-Plex-Container-Size': `500`, 'X-Plex-Container-Size': `500`,
}, },
}); });
return response.MediaContainer.Metadata; return response.MediaContainer.Metadata;
} }
} }

@ -110,10 +110,14 @@ interface MetadataResponse {
MediaContainer: { MediaContainer: {
Metadata: { Metadata: {
ratingKey: string; ratingKey: string;
type: 'movie' | 'show'; type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album';
title: string; title: string;
Guid: { 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 { export interface PlexWatchlistItem {
ratingKey: string; ratingKey: string;
tmdbId: number; tmdbId?: number;
tvdbId?: number; tvdbId?: number;
type: 'movie' | 'show'; musicBrainzId?: string;
type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album';
title: string; title: string;
} }
@ -299,6 +304,9 @@ class PlexTvAPI extends ExternalAPI {
const tvdbString = metadata.Guid.find((guid) => const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb') guid.id.startsWith('tvdb')
); );
const musicBrainzString = metadata.Guid.find((guid) =>
guid.id.startsWith('mbid')
);
return { return {
ratingKey: metadata.ratingKey, ratingKey: metadata.ratingKey,
@ -308,6 +316,9 @@ class PlexTvAPI extends ExternalAPI {
tvdbId: tvdbString tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1]) ? Number(tvdbString.id.split('//')[1])
: undefined, : undefined,
musicBrainzId: musicBrainzString
? musicBrainzString.id.split('//')[1]
: undefined,
title: metadata.title, title: metadata.title,
type: metadata.type, 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 { return {
offset, offset,

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

@ -0,0 +1,390 @@
import logger from '@server/logger';
import ServarrBase from './base';
export interface LidarrAlbumOptions {
profileId: number;
qualityProfileId: number;
rootFolderPath: string;
title: string;
mbId: string;
monitored: boolean;
tags: string[];
searchNow: boolean;
}
export interface LidarrArtistOptions {
profileId: number;
qualityProfileId: number;
rootFolderPath: string;
mbId: string;
monitored: boolean;
tags: string[];
searchNow: boolean;
monitorNewItems: string;
monitor: string;
searchForMissingAlbums: boolean;
}
export interface LidarrMusic {
id: number;
title: string;
isAvailable: boolean;
monitored: boolean;
mbId: number;
titleSlug: string;
folderName: string;
path: string;
profileId: number;
qualityProfileId: number;
added: string;
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 getArtists = (): void => {
try {
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}`);
}
};
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] ${e.message}`);
}
};
public async getArtistByMusicBrainzId(mbId: string): Promise<LidarrArtist> {
try {
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 albums: ${e.message}`);
}
};
public async getAlbum({
artistId,
albumId,
}: {
artistId?: number;
foreignAlbumId?: string;
albumId?: number;
}): Promise<LidarrAlbum[]> {
try {
const response = await this.axios.get<LidarrAlbum[]>('/album', {
params: {
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('Album not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving album by MusicBrainz ID', {
label: 'Midarr API',
errorMessage: e.message,
mbId: mbId,
});
throw new Error('Album not found');
}
}
public addAlbum = async (
options: LidarrAlbumOptions
): Promise<LidarrAlbum> => {
try {
const album = await this.getAlbumByMusicBrainzId(options.mbId);
if (album.id) {
logger.info(
'Album is already monitored in Lidarr. Starting search for download.',
{ label: 'Lidarr' }
);
this.axios.post(`/command`, {
name: 'AlbumSearch',
albumIds: [album.id],
});
return album;
}
const artist = album.artist;
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,
foreignAlbumId: options.mbId.toString(),
tags: options.tags,
monitored: options.monitored,
artist: artist,
rootFolderPath: options.rootFolderPath,
addOptions: {
searchForNewAlbum: options.searchNow,
},
});
if (response.data.id) {
logger.info('Lidarr accepted request', { label: 'Lidarr' });
} else {
logger.error('Failed to add album to Lidarr', {
label: 'Lidarr',
options,
});
throw new Error('Failed to add album to Lidarr');
}
return response.data;
} catch (e) {
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 addArtist = async (
options: LidarrArtistOptions
): Promise<LidarrArtist> => {
try {
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('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 { export enum IssueType {
VIDEO = 1, VIDEO = 1,
AUDIO = 2, AUDIO = 2,
SUBTITLES = 3, MUSIC = 3,
OTHER = 4, SUBTITLES = 4,
OTHER = 5,
} }
export enum IssueStatus { export enum IssueStatus {
@ -13,6 +14,7 @@ export enum IssueStatus {
export const IssueTypeName = { export const IssueTypeName = {
[IssueType.AUDIO]: 'Audio', [IssueType.AUDIO]: 'Audio',
[IssueType.VIDEO]: 'Video', [IssueType.VIDEO]: 'Video',
[IssueType.MUSIC]: 'Music',
[IssueType.SUBTITLES]: 'Subtitle', [IssueType.SUBTITLES]: 'Subtitle',
[IssueType.OTHER]: 'Other', [IssueType.OTHER]: 'Other',
}; };

@ -8,6 +8,15 @@ export enum MediaRequestStatus {
export enum MediaType { export enum MediaType {
MOVIE = 'movie', MOVIE = 'movie',
TV = 'tv', TV = 'tv',
MUSIC = 'music',
}
export enum SecondaryType {
ARTIST = 'artist',
RELEASE_GROUP = 'release-group',
RELEASE = 'release',
RECORDING = 'recording',
WORK = 'work',
} }
export enum MediaStatus { export enum MediaStatus {

@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaStatus, MediaType } from '@server/constants/media';
@ -24,20 +25,31 @@ import Season from './Season';
@Entity() @Entity()
class Media { class Media {
public static async getRelatedMedia( public static async getRelatedMedia(
tmdbIds: number | number[] tmdbIds: number | number[] = [],
mbIds: string | string[] = []
): Promise<Media[]> { ): Promise<Media[]> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
try { try {
let finalIds: number[]; let finalTmdbIds: number[] = [];
if (!Array.isArray(tmdbIds)) { if (!Array.isArray(tmdbIds)) {
finalIds = [tmdbIds]; finalTmdbIds = [tmdbIds];
} else { } else {
finalIds = tmdbIds; finalTmdbIds = tmdbIds;
}
let finalMusicBrainzIds: string[] = [];
if (!Array.isArray(mbIds)) {
finalMusicBrainzIds = [mbIds];
} else {
finalMusicBrainzIds = mbIds;
} }
const media = await mediaRepository.find({ const media = await mediaRepository.find({
where: { tmdbId: In(finalIds) }, where: [
{ tmdbId: In(finalTmdbIds) },
{ mbId: In(finalMusicBrainzIds) },
],
}); });
return media; return media;
@ -48,18 +60,43 @@ class Media {
} }
public static async getMedia( public static async getMedia(
id: number, id: number | string,
mediaType: MediaType mediaType: MediaType
): Promise<Media | undefined> { ): Promise<Media | undefined> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
try { try {
const media = await mediaRepository.findOne({ let media: Media | null = null;
where: { tmdbId: id, mediaType }, 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 }, relations: { requests: true, issues: true },
}); });
return media ?? undefined; return media;
} catch (e) { } catch (e) {
logger.error(e.message); logger.error(e.message);
return undefined; return undefined;
@ -72,14 +109,25 @@ class Media {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
public mediaType: MediaType; public mediaType: MediaType;
@Column() @Column({ type: 'varchar', nullable: true })
public secondaryType?: string;
@Column({ nullable: true })
@Index() @Index()
public tmdbId: number; public tmdbId?: number;
@Column({ unique: true, nullable: true }) @Column({ nullable: true })
@Index()
public mbId?: string;
@Column({ nullable: true })
@Index() @Index()
public tvdbId?: number; public tvdbId?: number;
@Column({ nullable: true })
@Index()
public musicdbId?: number;
@Column({ nullable: true }) @Column({ nullable: true })
@Index() @Index()
public imdbId?: string; public imdbId?: string;
@ -138,6 +186,12 @@ class Media {
@Column({ nullable: true, type: 'varchar' }) @Column({ nullable: true, type: 'varchar' })
public ratingKey4k?: string | null; public ratingKey4k?: string | null;
@Column({ nullable: true, type: 'varchar' })
public title?: string;
@Column({ nullable: true, type: 'varchar' })
public parentRatingKey?: number;
public serviceUrl?: string; public serviceUrl?: string;
public serviceUrl4k?: string; public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = []; public downloadStatus?: DownloadingItem[] = [];
@ -253,6 +307,21 @@ class Media {
} }
} }
} }
if (this.mediaType === MediaType.MUSIC) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.lidarr.find(
(lidarr) => lidarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
: LidarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`);
}
}
}
} }
@AfterLoad() @AfterLoad()
@ -308,6 +377,20 @@ class Media {
); );
} }
} }
if (this.mediaType === MediaType.MUSIC) {
if (
this.externalServiceId !== undefined &&
this.externalServiceId !== null &&
this.serviceId !== undefined &&
this.serviceId !== null
) {
this.downloadStatus = downloadTracker.getMusicProgress(
this.serviceId,
this.externalServiceId
);
}
}
} }
} }

@ -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 type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import type { import type {
@ -7,13 +14,19 @@ import type {
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { import {
MediaRequestStatus, MediaRequestStatus,
MediaStatus, MediaStatus,
MediaType, MediaType,
SecondaryType,
} from '@server/constants/media'; } from '@server/constants/media';
import { getRepository } from '@server/datasource'; 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 notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
@ -48,11 +61,12 @@ type MediaRequestOptions = {
@Entity() @Entity()
export class MediaRequest { export class MediaRequest {
public static async request( public static async request(
requestBody: MediaRequestBody, requestBody: VideoRequestBody | TvRequestBody | MusicRequestBody,
user: User, user: User,
options: MediaRequestOptions = {} options: MediaRequestOptions = {}
): Promise<MediaRequest> { ): Promise<MediaRequest> {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const musicbrainz = new MusicBrainz();
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);
const userRepository = getRepository(User); const userRepository = getRepository(User);
@ -111,6 +125,18 @@ export class MediaRequest {
requestBody.is4k ? '4K ' : '' requestBody.is4k ? '4K ' : ''
}series requests.` }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(); const quotas = await requestUser.getQuota();
@ -119,49 +145,118 @@ export class MediaRequest {
throw new QuotaRestrictedError('Movie Quota exceeded.'); throw new QuotaRestrictedError('Movie Quota exceeded.');
} else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) {
throw new QuotaRestrictedError('Series Quota exceeded.'); 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 requestBody.mediaType === MediaType.MOVIE
? await tmdb.getMovie({ movieId: requestBody.mediaId }) ? 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 }); : await tmdb.getTvShow({ tvId: requestBody.mediaId });
let media = await mediaRepository.findOne({ let media =
where: { requestBody.mediaType === MediaType.MUSIC
tmdbId: requestBody.mediaId, ? await mediaRepository.findOne({
mediaType: requestBody.mediaType, where: {
}, mbId: requestBody.mediaId,
relations: ['requests'], 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) { if (!media) {
media = new Media({ if (requestBody.mediaType === MediaType.MUSIC) {
tmdbId: tmdbMedia.id, media = new Media({
tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, mbId: requestBody.mediaId,
status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status: MediaStatus.PENDING,
status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: MediaType.MUSIC,
mediaType: requestBody.mediaType, secondaryType: requestBody.secondaryType,
}); title: (metaMedia as mbRelease).title,
} else { });
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { } else if (requestBody.mediaType === MediaType.MOVIE) {
media.status = MediaStatus.PENDING; 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) { if (
media.status4k = MediaStatus.PENDING; 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 const existing =
.createQueryBuilder('request') requestBody.mediaType !== MediaType.MUSIC
.leftJoin('request.media', 'media') ? await requestRepository
.leftJoinAndSelect('request.requestedBy', 'user') .createQueryBuilder('request')
.where('request.is4k = :is4k', { is4k: requestBody.is4k }) .leftJoin('request.media', 'media')
.andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) .leftJoinAndSelect('request.requestedBy', 'user')
.andWhere('media.mediaType = :mediaType', { .where('request.is4k = :is4k', { is4k: requestBody.is4k })
mediaType: requestBody.mediaType, .andWhere('media.tmdbId = :tmdbId', { tmdbId: metaMedia.id })
}) .andWhere('media.mediaType = :mediaType', {
.getMany(); 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 (existing && existing.length > 0) {
// If there is an existing movie request that isn't declined, don't allow a new one. // If there is an existing movie request that isn't declined, don't allow a new one.
@ -170,7 +265,7 @@ export class MediaRequest {
existing[0].status !== MediaRequestStatus.DECLINED existing[0].status !== MediaRequestStatus.DECLINED
) { ) {
logger.warn('Duplicate request for media blocked', { logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id, tmdbId: metaMedia.id,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
is4k: requestBody.is4k, is4k: requestBody.is4k,
label: 'Media Request', label: 'Media Request',
@ -240,16 +335,17 @@ export class MediaRequest {
await requestRepository.save(request); await requestRepository.save(request);
return request; return request;
} else { } else if (requestBody.mediaType === MediaType.TV) {
const tmdbMediaShow = tmdbMedia as Awaited< const metaMediaShow = metaMedia as Awaited<
ReturnType<typeof tmdb.getTvShow> ReturnType<typeof tmdb.getTvShow>
>; >;
const requestedSeasons = const requestedSeasons =
requestBody.seasons === 'all' (requestBody as TvRequestBody).seasons === 'all'
? tmdbMediaShow.seasons ? metaMediaShow.seasons
.map((season) => season.season_number) .map((season) => season.season_number)
.filter((sn) => sn > 0) .filter((sn) => sn > 0)
: (requestBody.seasons as number[]); : ((requestBody as TvRequestBody).seasons as number[]);
let existingSeasons: 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 // We need to check existing requests on this title to make sure we don't double up on seasons that were
@ -362,6 +458,44 @@ export class MediaRequest {
isAutoRequest: options.isAutoRequest ?? false, 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); await requestRepository.save(request);
return request; return request;
} }
@ -402,6 +536,9 @@ export class MediaRequest {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
public type: MediaType; public type: MediaType;
@Column({ type: 'varchar', nullable: true })
public secondaryType?: SecondaryType;
@RelationCount((request: MediaRequest) => request.seasons) @RelationCount((request: MediaRequest) => request.seasons)
public seasonCount: number; public seasonCount: number;
@ -467,7 +604,11 @@ export class MediaRequest {
@AfterUpdate() @AfterUpdate()
@AfterInsert() @AfterInsert()
public async sendMedia(): Promise<void> { public async sendMedia(): Promise<void> {
await Promise.all([this.sendToRadarr(), this.sendToSonarr()]); await Promise.all([
this.sendToRadarr(),
this.sendToSonarr(),
this.sendToLidarr(),
]);
} }
@AfterInsert() @AfterInsert()
@ -749,7 +890,9 @@ export class MediaRequest {
apiKey: radarrSettings.apiKey, apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), 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({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
@ -966,7 +1109,7 @@ export class MediaRequest {
apiKey: sonarrSettings.apiKey, apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), 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; const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) { if (!tvdbId) {
@ -1160,9 +1303,287 @@ export class MediaRequest {
} }
} }
public async sendToLidarr(): Promise<void> {
if (
this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.MUSIC
) {
try {
const mediaRepository = getRepository(Media);
const settings = getSettings();
if (settings.lidarr.length === 0 && !settings.lidarr[0]) {
logger.info(
'No Lidarr server configured, skipping request processing',
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
let lidarrSettings = settings.lidarr.find((lidarr) => lidarr.isDefault);
if (
this.serverId !== null &&
this.serverId >= 0 &&
lidarrSettings?.id !== this.serverId
) {
lidarrSettings = settings.lidarr.find(
(lidarr) => lidarr.id === this.serverId
);
logger.info(
`Request has an override server: ${lidarrSettings?.name}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (!lidarrSettings) {
logger.warn(
`There is no default Lidarr server configured. Did you set any of your Lidarr servers as default?`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
return;
}
let rootFolder = lidarrSettings.activeDirectory;
let qualityProfile = lidarrSettings.activeProfileId;
let tags = lidarrSettings.tags ? [...lidarrSettings.tags] : [];
if (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== lidarrSettings.activeDirectory
) {
rootFolder = this.rootFolder;
logger.info(`Request has an override root folder: ${rootFolder}`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
}
if (
this.profileId &&
this.profileId !== lidarrSettings.activeProfileId
) {
qualityProfile = this.profileId;
logger.info(
`Request has an override quality profile ID: ${qualityProfile}`,
{
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
}
);
}
if (this.tags && !isEqual(this.tags, lidarrSettings.tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
tagIds: tags,
});
}
const musicbrainz = new MusicBrainz();
const lidarr = new LidarrAPI({
apiKey: lidarrSettings.apiKey,
url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'),
});
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('Media data not found', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
return;
}
if (lidarrSettings.tagRequests) {
let userTag = (await lidarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
);
if (!userTag) {
logger.info(`Requester has no active tag. Creating new`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
newTag:
this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
userTag = await lidarr.createTag({
label: this.requestedBy.id + ' - ' + this.requestedBy.displayName,
});
}
if (userTag.id) {
if (!tags?.find((v) => v === userTag?.id)) {
tags?.push(userTag.id);
}
} else {
logger.warn(`Requester has no tag and failed to add one`, {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
userId: this.requestedBy.id,
lidarrServer: lidarrSettings.hostname + ':' + lidarrSettings.port,
});
}
}
if (media['status'] === MediaStatus.AVAILABLE) {
logger.warn('Media already exists, marking request as APPROVED', {
label: 'Media Request',
requestId: this.id,
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
}
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');
}
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);
});
} 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');
}
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,
mediaId: this.media.id,
});
} catch (e) {
logger.error('Something went wrong sending request to Lidarr', {
label: 'Media Request',
errorMessage: e.message,
requestId: this.id,
mediaId: this.media.id,
});
throw new Error(e.message);
}
}
}
private async sendNotification(media: Media, type: Notification) { private async sendNotification(media: Media, type: Notification) {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const musicbrainz = new MusicBrainz();
try { try {
const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series';
let event: string | undefined; let event: string | undefined;
@ -1197,9 +1618,8 @@ export class MediaRequest {
event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`; event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`;
break; break;
} }
if (this.type === MediaType.MOVIE) { 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, { notificationManager.sendNotification(type, {
media, media,
request: this, request: this,
@ -1218,7 +1638,7 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
}); });
} else if (this.type === MediaType.TV) { } 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, { notificationManager.sendNotification(type, {
media, media,
request: this, request: this,
@ -1244,6 +1664,35 @@ export class MediaRequest {
}, },
], ],
}); });
} else if (this.type === MediaType.MUSIC) {
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) { } catch (e) {
logger.error('Something went wrong sending media notification(s)', { logger.error('Something went wrong sending media notification(s)', {

@ -103,6 +103,12 @@ export class User {
@Column({ nullable: true }) @Column({ nullable: true })
public tvQuotaDays?: number; public tvQuotaDays?: number;
@Column({ nullable: true })
public musicQuotaLimit?: number;
@Column({ nullable: true })
public musicQuotaDays?: number;
@OneToOne(() => UserSettings, (settings) => settings.user, { @OneToOne(() => UserSettings, (settings) => settings.user, {
cascade: true, cascade: true,
eager: true, eager: true,
@ -306,6 +312,27 @@ export class User {
).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0) ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0)
: 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 { return {
movie: { movie: {
days: movieQuotaDays, days: movieQuotaDays,
@ -329,6 +356,18 @@ export class User {
restricted: restricted:
tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false, 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 { export interface WatchlistItem {
ratingKey: string; ratingKey: string;
tmdbId: number; tmdbId?: number;
mediaType: 'movie' | 'tv'; musicBrainzId?: string;
mediaType: 'movie' | 'tv' | 'music';
title: string; 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 { MediaRequest } from '@server/entity/MediaRequest';
import type { PaginatedResponse } from './common'; import type { PaginatedResponse } from './common';
@ -6,16 +6,31 @@ export interface RequestResultsResponse extends PaginatedResponse {
results: MediaRequest[]; results: MediaRequest[];
} }
export type MediaRequestBody = { interface MediaRequestBody {
mediaType: MediaType; mediaType: MediaType;
mediaId: number; mediaId: number | string;
tvdbId?: number;
seasons?: number[] | 'all';
is4k?: boolean;
serverId?: number; serverId?: number;
profileId?: number; profileId?: number;
rootFolder?: string; rootFolder?: string;
languageProfileId?: number; languageProfileId?: number;
userId?: number; userId?: number;
tags?: 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 { export interface ServiceCommonServer {
id: number; id: number;
name: string; name: string;
is4k: boolean; is4k?: boolean;
isDefault: boolean; isDefault: boolean;
activeProfileId: number; activeProfileId: number;
activeDirectory: string; activeDirectory: string;
@ -12,7 +12,7 @@ export interface ServiceCommonServer {
activeAnimeProfileId?: number; activeAnimeProfileId?: number;
activeAnimeDirectory?: string; activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number; activeAnimeLanguageProfileId?: number;
activeTags: number[]; activeTags: number[] | string[];
activeAnimeTags?: number[]; activeAnimeTags?: number[];
} }

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

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

@ -1,6 +1,7 @@
import availabilitySync from '@server/lib/availabilitySync'; import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy'; import ImageProxy from '@server/lib/imageproxy';
import { lidarrScanner } from '@server/lib/scanners/lidarr';
import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex';
import { radarrScanner } from '@server/lib/scanners/radarr'; import { radarrScanner } from '@server/lib/scanners/radarr';
import { sonarrScanner } from '@server/lib/scanners/sonarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr';
@ -116,7 +117,22 @@ export const startJobs = (): void => {
cancelFn: () => sonarrScanner.cancel(), 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({ scheduledJobs.push({
id: 'availability-sync', id: 'availability-sync',
name: 'Media Availability Sync', name: 'Media Availability Sync',

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

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

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

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

@ -1,5 +1,6 @@
import type { LidarrRelease } from '@server/api/servarr/lidarr';
import TheMovieDb from '@server/api/themoviedb'; 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 { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import Season from '@server/entity/Season'; import Season from '@server/entity/Season';
@ -24,7 +25,8 @@ export interface RunnableScanner<T> {
} }
export interface MediaIds { export interface MediaIds {
tmdbId: number; tmdbId?: number;
mbId?: string;
imdbId?: string; imdbId?: string;
tvdbId?: number; tvdbId?: number;
isHama?: boolean; isHama?: boolean;
@ -39,6 +41,11 @@ interface ProcessOptions {
externalServiceSlug?: string; externalServiceSlug?: string;
title?: string; title?: string;
processing?: boolean; processing?: boolean;
parentRatingKey?: string;
}
interface ProcessGroupOptions extends ProcessOptions {
releases?: LidarrRelease[];
} }
export interface ProcessableSeason { export interface ProcessableSeason {
@ -79,13 +86,19 @@ class BaseScanner<T> {
this.updateRate = updateRate ?? UPDATE_RATE; 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 mediaRepository = getRepository(Media);
const existing = await mediaRepository.findOne({ let existing: Media | null;
where: { tmdbId: tmdbId, mediaType }, 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; return existing;
} }
@ -110,8 +123,8 @@ class BaseScanner<T> {
if (existing) { if (existing) {
let changedExisting = false; let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) { if (existing['status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing existing['status'] = processing
? MediaStatus.PROCESSING ? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE; : MediaStatus.AVAILABLE;
if (mediaAddedAt) { if (mediaAddedAt) {
@ -125,29 +138,21 @@ class BaseScanner<T> {
changedExisting = true; changedExisting = true;
} }
if ( if (ratingKey && existing['ratingKey'] !== ratingKey) {
ratingKey && existing['ratingKey'] = ratingKey;
existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey
) {
existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey;
changedExisting = true; changedExisting = true;
} }
if ( if (serviceId !== undefined && existing['serviceId'] !== serviceId) {
serviceId !== undefined && existing['serviceId'] = serviceId;
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
) {
existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId;
changedExisting = true; changedExisting = true;
} }
if ( if (
externalServiceId !== undefined && externalServiceId !== undefined &&
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !== existing['externalServiceId'] !== externalServiceId
externalServiceId
) { ) {
existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] = existing['externalServiceId'] = externalServiceId;
externalServiceId;
changedExisting = true; changedExisting = true;
} }
@ -384,12 +389,11 @@ class BaseScanner<T> {
} }
if (serviceId !== undefined) { if (serviceId !== undefined) {
media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId; media['serviceId'] = serviceId;
} }
if (externalServiceId !== undefined) { if (externalServiceId !== undefined) {
media[is4k ? 'externalServiceId4k' : 'externalServiceId'] = media['externalServiceId'] = externalServiceId;
externalServiceId;
} }
if (externalServiceSlug !== undefined) { 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 * Call startRun from child class whenever a run is starting to
* ensure required values are set * 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 imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/);
const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/);
const tvdbRegex = new RegExp(/tvdb:\/\/([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 tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/);
const plexRegex = new RegExp(/plex:\/\//); const plexRegex = new RegExp(/plex:\/\//);
// Hama agent uses ASS naming, see details here: // Hama agent uses ASS naming, see details here:
@ -135,7 +136,13 @@ class PlexScanner
for (const library of this.libraries) { for (const library of this.libraries) {
this.currentLibrary = library; this.currentLibrary = library;
this.log(`Beginning to process library: ${library.name}`, 'info'); 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( this.log(
@ -164,12 +171,16 @@ class PlexScanner
if (this.sessionId !== sessionId) { if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.'); throw new Error('New session was started. Old session aborted.');
} }
const response =
const response = await this.plexClient.getLibraryContents(library.id, { library.type === 'artist'
size: this.protectedBundleSize, ? await this.plexClient.getMusicLibraryContents(library.id, {
offset: start, size: this.protectedBundleSize,
}); offset: start,
})
: await this.plexClient.getLibraryContents(library.id, {
size: this.protectedBundleSize,
offset: start,
});
this.progress = start; this.progress = start;
this.totalSize = response.totalSize; this.totalSize = response.totalSize;
@ -209,6 +220,10 @@ class PlexScanner
plexitem.type === 'season' plexitem.type === 'season'
) { ) {
await this.processPlexShow(plexitem); await this.processPlexShow(plexitem);
} else if (plexitem.type === 'artist') {
await this.processPlexArtist(plexitem);
} else if (plexitem.type === 'album') {
await this.processPlexAlbum(plexitem);
} }
} catch (e) { } catch (e) {
this.log('Failed to process Plex media', 'error', { this.log('Failed to process Plex media', 'error', {
@ -224,13 +239,18 @@ class PlexScanner
const has4k = plexitem.Media.some( const has4k = plexitem.Media.some(
(media) => media.videoResolution === '4k' (media) => media.videoResolution === '4k'
); );
if (mediaIds.tmdbId) {
await this.processMovie(mediaIds.tmdbId, { await this.processMovie(mediaIds.tmdbId, {
is4k: has4k && this.enable4kMovie, is4k: has4k && this.enable4kMovie,
mediaAddedAt: new Date(plexitem.addedAt * 1000), mediaAddedAt: new Date(plexitem.addedAt * 1000),
ratingKey: plexitem.ratingKey, ratingKey: plexitem.ratingKey,
title: plexitem.title, title: plexitem.title,
}); });
} else {
this.log('No TMDB ID found for movie', 'warn', {
title: plexitem.title,
});
}
} }
private async processPlexMovieByTmdbId( private async processPlexMovieByTmdbId(
@ -273,7 +293,9 @@ class PlexScanner
await this.processHamaSpecials(metadata, mediaIds.tvdbId); 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 seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = []; const processableSeasons: ProcessableSeason[] = [];
@ -322,7 +344,7 @@ class PlexScanner
if (mediaIds.tvdbId) { if (mediaIds.tvdbId) {
await this.processShow( await this.processShow(
mediaIds.tmdbId, mediaIds.tmdbId as number,
mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id, mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id,
processableSeasons, 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> { private async getMediaIds(plexitem: PlexLibraryItem): Promise<MediaIds> {
let mediaIds: Partial<MediaIds> = {}; let mediaIds: Partial<MediaIds> = {};
// Check if item is using new plex movie/tv agent // Check if item is using new plex movie/tv agent
@ -372,6 +425,8 @@ class PlexScanner
} else if (ref.id.match(tvdbRegex)) { } else if (ref.id.match(tvdbRegex)) {
const tvdbMatch = ref.id.match(tvdbRegex)?.[1]; const tvdbMatch = ref.id.match(tvdbRegex)?.[1];
mediaIds.tvdbId = Number(tvdbMatch); 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) { if (!mediaIds.tmdbId && !mediaIds.mbId) {
throw new Error('Unable to find TMDB ID'); 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 // 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 TheMovieDb from '@server/api/themoviedb';
import type { import type {
TmdbMovieDetails, TmdbMovieDetails,
@ -10,6 +11,7 @@ import type {
TmdbTvDetails, TmdbTvDetails,
TmdbTvResult, TmdbTvResult,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import type { MbSearchMultiResponse } from '@server/models/Search';
import { import {
mapMovieDetailsToResult, mapMovieDetailsToResult,
mapPersonDetailsToResult, mapPersonDetailsToResult,
@ -31,7 +33,7 @@ interface SearchProvider {
id: string; id: string;
language?: string; language?: string;
query?: string; query?: string;
}) => Promise<TmdbSearchMultiResponse>; }) => Promise<TmdbSearchMultiResponse | MbSearchMultiResponse>;
} }
const searchProviders: SearchProvider[] = []; 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; id: string;
name: string; name: string;
enabled: boolean; enabled: boolean;
type: 'show' | 'movie'; type: 'show' | 'movie' | 'artist';
lastScan?: number; lastScan?: number;
} }
@ -44,7 +44,7 @@ export interface TautulliSettings {
externalUrl?: string; externalUrl?: string;
} }
export interface DVRSettings { export interface ArrSettings {
id: number; id: number;
name: string; name: string;
hostname: string; hostname: string;
@ -55,13 +55,17 @@ export interface DVRSettings {
activeProfileId: number; activeProfileId: number;
activeProfileName: string; activeProfileName: string;
activeDirectory: string; activeDirectory: string;
tags: number[];
is4k: boolean;
isDefault: boolean; isDefault: boolean;
externalUrl?: string; externalUrl?: string;
syncEnabled: boolean; syncEnabled: boolean;
preventSearch: boolean; preventSearch: boolean;
tagRequests: boolean; tagRequests: boolean;
tags: string[] | number[];
}
export interface DVRSettings extends ArrSettings {
is4k: boolean;
tags: number[];
} }
export interface RadarrSettings extends DVRSettings { export interface RadarrSettings extends DVRSettings {
@ -79,6 +83,7 @@ export interface SonarrSettings extends DVRSettings {
animeTags?: number[]; animeTags?: number[];
enableSeasonFolders: boolean; enableSeasonFolders: boolean;
} }
export type LidarrSettings = ArrSettings;
interface Quota { interface Quota {
quotaLimit?: number; quotaLimit?: number;
@ -86,6 +91,7 @@ interface Quota {
} }
export interface MainSettings { export interface MainSettings {
fallbackImage: string;
apiKey: string; apiKey: string;
applicationTitle: string; applicationTitle: string;
applicationUrl: string; applicationUrl: string;
@ -95,6 +101,7 @@ export interface MainSettings {
defaultQuotas: { defaultQuotas: {
movie: Quota; movie: Quota;
tv: Quota; tv: Quota;
music: Quota;
}; };
hideAvailable: boolean; hideAvailable: boolean;
localLogin: boolean; localLogin: boolean;
@ -122,6 +129,7 @@ interface FullPublicSettings extends PublicSettings {
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
cacheImages: boolean; cacheImages: boolean;
vapidPublic: string; vapidPublic: string;
fallbackImage: string;
enablePushRegistration: boolean; enablePushRegistration: boolean;
locale: string; locale: string;
emailEnabled: boolean; emailEnabled: boolean;
@ -250,6 +258,7 @@ export type JobId =
| 'plex-watchlist-sync' | 'plex-watchlist-sync'
| 'radarr-scan' | 'radarr-scan'
| 'sonarr-scan' | 'sonarr-scan'
| 'lidarr-scan'
| 'download-sync' | 'download-sync'
| 'download-sync-reset' | 'download-sync-reset'
| 'image-cache-cleanup' | 'image-cache-cleanup'
@ -264,6 +273,7 @@ interface AllSettings {
tautulli: TautulliSettings; tautulli: TautulliSettings;
radarr: RadarrSettings[]; radarr: RadarrSettings[];
sonarr: SonarrSettings[]; sonarr: SonarrSettings[];
lidarr: ArrSettings[];
public: PublicSettings; public: PublicSettings;
notifications: NotificationSettings; notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>; jobs: Record<JobId, JobSettings>;
@ -291,6 +301,7 @@ class Settings {
defaultQuotas: { defaultQuotas: {
movie: {}, movie: {},
tv: {}, tv: {},
music: {},
}, },
hideAvailable: false, hideAvailable: false,
localLogin: true, localLogin: true,
@ -300,6 +311,7 @@ class Settings {
trustProxy: false, trustProxy: false,
partialRequestsEnabled: true, partialRequestsEnabled: true,
locale: 'en', locale: 'en',
fallbackImage: '/images/overseerr_poster_not_found_logo_top.png',
}, },
plex: { plex: {
name: '', name: '',
@ -311,6 +323,7 @@ class Settings {
tautulli: {}, tautulli: {},
radarr: [], radarr: [],
sonarr: [], sonarr: [],
lidarr: [],
public: { public: {
initialized: false, initialized: false,
}, },
@ -415,6 +428,9 @@ class Settings {
'sonarr-scan': { 'sonarr-scan': {
schedule: '0 30 4 * * *', schedule: '0 30 4 * * *',
}, },
'lidarr-scan': {
schedule: '0 0 5 * * *',
},
'availability-sync': { 'availability-sync': {
schedule: '0 0 5 * * *', schedule: '0 0 5 * * *',
}, },
@ -478,6 +494,14 @@ class Settings {
this.data.sonarr = data; this.data.sonarr = data;
} }
get lidarr(): ArrSettings[] {
return this.data.lidarr;
}
set lidarr(data: ArrSettings[]) {
this.data.lidarr = data;
}
get public(): PublicSettings { get public(): PublicSettings {
return this.data.public; return this.data.public;
} }
@ -508,6 +532,9 @@ class Settings {
locale: this.data.main.locale, locale: this.data.main.locale,
emailEnabled: this.data.notifications.agents.email.enabled, emailEnabled: this.data.notifications.agents.email.enabled,
newPlexLogin: this.data.main.newPlexLogin, newPlexLogin: this.data.main.newPlexLogin,
fallbackImage:
this.data.main.fallbackImage ??
'/images/overseerr_poster_not_found_logo_top.png',
}; };
} }

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

@ -14,20 +14,22 @@ export interface Collection {
parts: MovieResult[]; parts: MovieResult[];
} }
export const mapCollection = ( export const mapCollection = async (
collection: TmdbCollection, collection: TmdbCollection,
media: Media[] media: Media[]
): Collection => ({ ): Promise<Collection> => ({
id: collection.id, id: collection.id,
name: collection.name, name: collection.name,
overview: collection.overview, overview: collection.overview,
posterPath: collection.poster_path, posterPath: collection.poster_path,
backdropPath: collection.backdrop_path, backdropPath: collection.backdrop_path,
parts: sortBy(collection.parts, 'release_date').map((part) => parts: await Promise.all(
mapMovieResult( sortBy(collection.parts, 'release_date').map((part) =>
part, mapMovieResult(
media?.find( part,
(req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE 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 { import type {
TmdbCollectionResult, TmdbCollectionResult,
TmdbMovieDetails, TmdbMovieDetails,
@ -7,10 +19,19 @@ import type {
TmdbTvDetails, TmdbTvDetails,
TmdbTvResult, TmdbTvResult,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/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 { interface SearchResult {
id: number; id: number;
@ -44,6 +65,14 @@ export interface TvResult extends SearchResult {
firstAirDate: string; firstAirDate: string;
} }
export interface MusicResult extends SearchResult {
mediaType: 'music';
title: string;
originalTitle: string;
releaseDate: string;
mediaInfo?: Media;
}
export interface CollectionResult { export interface CollectionResult {
id: number; id: number;
mediaType: 'collection'; mediaType: 'collection';
@ -54,6 +83,7 @@ export interface CollectionResult {
backdropPath?: string; backdropPath?: string;
overview: string; overview: string;
originalLanguage: string; originalLanguage: string;
mediaInfo?: Media;
} }
export interface PersonResult { export interface PersonResult {
@ -64,14 +94,107 @@ export interface PersonResult {
adult: boolean; adult: boolean;
mediaType: 'person'; mediaType: 'person';
knownFor: (MovieResult | TvResult)[]; 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, movieResult: TmdbMovieResult,
media?: Media media?: Media
): MovieResult => ({ ): Promise<MovieResult> => ({
id: movieResult.id, id: movieResult.id,
mediaType: 'movie', mediaType: 'movie',
adult: movieResult.adult, adult: movieResult.adult,
@ -90,10 +213,10 @@ export const mapMovieResult = (
mediaInfo: media, mediaInfo: media,
}); });
export const mapTvResult = ( export const mapTvResult = async (
tvResult: TmdbTvResult, tvResult: TmdbTvResult,
media?: Media media?: Media
): TvResult => ({ ): Promise<TvResult> => ({
id: tvResult.id, id: tvResult.id,
firstAirDate: tvResult.first_air_date, firstAirDate: tvResult.first_air_date,
genreIds: tvResult.genre_ids, genreIds: tvResult.genre_ids,
@ -112,9 +235,11 @@ export const mapTvResult = (
mediaInfo: media, mediaInfo: media,
}); });
export const mapCollectionResult = ( export const mapCollectionResult = async (
collectionResult: TmdbCollectionResult collectionResult: TmdbCollectionResult,
): CollectionResult => ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars
_media?: Media
): Promise<CollectionResult> => ({
id: collectionResult.id, id: collectionResult.id,
mediaType: collectionResult.media_type || 'collection', mediaType: collectionResult.media_type || 'collection',
adult: collectionResult.adult, adult: collectionResult.adult,
@ -126,22 +251,122 @@ export const mapCollectionResult = (
posterPath: collectionResult.poster_path, posterPath: collectionResult.poster_path,
}); });
export const mapPersonResult = ( export const mapPersonResult = async (
personResult: TmdbPersonResult personResult: TmdbPersonResult,
): PersonResult => ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars
_media?: Media
): Promise<PersonResult> => ({
id: personResult.id, id: personResult.id,
name: personResult.name, name: personResult.name,
popularity: personResult.popularity, popularity: personResult.popularity,
adult: personResult.adult, adult: personResult.adult,
mediaType: personResult.media_type, mediaType: personResult.media_type,
profilePath: personResult.profile_path, profilePath: personResult.profile_path,
knownFor: personResult.known_for.map((result) => { knownFor: await Promise.all(
if (result.media_type === 'movie') { personResult.known_for.map((result) => {
return mapMovieResult(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 = ( export const mapSearchResults = (
@ -150,34 +375,60 @@ export const mapSearchResults = (
| TmdbTvResult | TmdbTvResult
| TmdbPersonResult | TmdbPersonResult
| TmdbCollectionResult | TmdbCollectionResult
| mbArtist
| mbRecording
| mbRelease
| mbReleaseGroup
| mbWork
)[], )[],
media?: Media[] media?: Media[]
): Results[] => ): Promise<Results[]> => {
results.map((result) => { const mediaLookup = new Map();
switch (result.media_type) { if (media) {
case 'movie': media.forEach((item) => {
return mapMovieResult( mediaLookup.set(item.tmdbId || item.mbId, item);
result, });
media?.find( }
(req) =>
req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE const mapFunctions = {
) movie: mapMovieResult,
); tv: mapTvResult,
case 'tv': collection: mapCollectionResult,
return mapTvResult( person: mapPersonResult,
result, 'release-group': mapReleaseGroupResult,
media?.find( release: mapReleaseResult,
(req) => recording: mapRecordingResult,
req.tmdbId === result.id && req.mediaType === MainMediaType.TV work: mapWorkResult,
) artist: mapArtistResult,
); };
case 'collection':
return mapCollectionResult(result); const transformResults = (
default: result:
return mapPersonResult(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 = ( export const mapMovieDetailsToResult = (
movieDetails: TmdbMovieDetails movieDetails: TmdbMovieDetails
): TmdbMovieResult => ({ ): TmdbMovieResult => ({

@ -19,7 +19,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
collection.parts.map((part) => part.id) 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) { } catch (e) {
logger.debug('Something went wrong retrieving collection', { logger.debug('Something went wrong retrieving collection', {
label: 'API', label: 'API',

@ -1,3 +1,4 @@
import MusicBrainz from '@server/api/musicbrainz';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb'; import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb 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 logger from '@server/logger';
import { mapProductionCompany } from '@server/models/Movie'; import { mapProductionCompany } from '@server/models/Movie';
import { import {
mapArtistResult,
mapCollectionResult, mapCollectionResult,
mapMovieResult, mapMovieResult,
mapPersonResult, mapPersonResult,
mapReleaseResult,
mapTvResult, mapTvResult,
} from '@server/models/Search'; } from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv'; import { mapNetwork } from '@server/models/Tv';
@ -845,9 +848,68 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
title: item.title, title: item.title,
mediaType: item.type === 'show' ? 'tv' : 'movie', mediaType: item.type === 'show' ? 'tv' : 'movie',
tmdbId: item.tmdbId, 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; export default discoverRoutes;

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

@ -1,5 +1,5 @@
import TautulliAPI from '@server/api/tautulli'; 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 { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { User } from '@server/entity/User'; 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 { try {
const [media, mediaCount] = await mediaRepository.findAndCount({ const [media, mediaCount] = await mediaRepository.findAndCount({
order: sortFilter, order: sortFilter,
where: statusFilter && { where: statusFilter && {
status: statusFilter, status: statusFilter,
}, } &&
typeFilter && {
...typeFilter,
},
take: pageSize, take: pageSize,
skip, 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 SeasonRequest from '@server/entity/SeasonRequest';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import type { import type {
MediaRequestBody, MusicRequestBody,
RequestResultsResponse, RequestResultsResponse,
TvRequestBody,
VideoRequestBody,
} from '@server/interfaces/api/requestInterfaces'; } from '@server/interfaces/api/requestInterfaces';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import logger from '@server/logger'; import logger from '@server/logger';
@ -158,38 +160,41 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
} }
); );
requestRoutes.post<never, MediaRequest, MediaRequestBody>( requestRoutes.post<
'/', never,
async (req, res, next) => { MediaRequest,
try { MusicRequestBody | VideoRequestBody | TvRequestBody
if (!req.user) { >('/', 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({ return next({
status: 401, status: 500,
message: 'You must be logged in to request media.', 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) => { requestRoutes.get('/count', async (_req, res, next) => {
const requestRepository = getRepository(MediaRequest); const requestRepository = getRepository(MediaRequest);

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

@ -1,3 +1,4 @@
import LidarrAPI from '@server/api/servarr/lidarr';
import RadarrAPI from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb'; 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; export default serviceRoutes;

@ -35,12 +35,14 @@ import { URL } from 'url';
import notificationRoutes from './notifications'; import notificationRoutes from './notifications';
import radarrRoutes from './radarr'; import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr'; import sonarrRoutes from './sonarr';
import lidarrRoutes from './lidarr';
const settingsRoutes = Router(); const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/lidarr', lidarrRoutes)
settingsRoutes.use('/discover', discoverSettingRoutes); settingsRoutes.use('/discover', discoverSettingRoutes);
const filteredMainSettings = ( 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) { 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}${ title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`; }`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else { } else {
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId }); const tvshow = await tmdb.getTvShow({ tvId: Number(media.tmdbId) });
title = `${tvshow.name}${ title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''

@ -26,14 +26,18 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
try { try {
if (entity.media.mediaType === MediaType.MOVIE) { 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}${ title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`; }`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else { } else {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); const tvshow = await tmdb.getTvShow({
tvId: Number(entity.media.tmdbId),
});
title = `${tvshow.name}${ title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' 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(); const tmdb = new TheMovieDb();
try { try {
const movie = await tmdb.getMovie({ movieId: entity.tmdbId }); const movie = await tmdb.getMovie({
movieId: Number(entity.tmdbId),
});
relatedRequests.forEach((request) => { relatedRequests.forEach((request) => {
notificationManager.sendNotification( notificationManager.sendNotification(
@ -136,7 +138,7 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
); );
try { try {
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId }); const tv = await tmdb.getTvShow({ tvId: Number(entity.tmdbId) });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: `${is4k ? '4K ' : ''}Series Request Now Available`, event: `${is4k ? '4K ' : ''}Series Request Now Available`,
subject: `${tv.name}${ subject: `${tv.name}${

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

@ -0,0 +1,150 @@
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 useSettings from '@app/hooks/useSettings';
import type { ImageLoader, ImageProps } from 'next/image'; import type { ImageLoader, ImageProps } from 'next/image';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react';
const imageLoader: ImageLoader = ({ src }) => src; const imageLoader: ImageLoader = ({ src }) => src;
@ -10,18 +11,29 @@ const imageLoader: ImageLoader = ({ src }) => src;
**/ **/
const CachedImage = ({ src, ...props }: ImageProps) => { const CachedImage = ({ src, ...props }: ImageProps) => {
const { currentSettings } = useSettings(); 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')) { if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) {
const parsedUrl = new URL(imageUrl); const parsedUrl = new URL(imageUrl);
if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) { 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; export default CachedImage;

@ -5,29 +5,50 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces';
import type { import type {
ArtistResult,
CollectionResult, CollectionResult,
MovieResult, MovieResult,
MusicResult,
PersonResult, PersonResult,
RecordingResult,
ReleaseGroupResult,
ReleaseResult,
TvResult, TvResult,
WorkResult,
} from '@server/models/Search'; } from '@server/models/Search';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
type ListViewProps = { type ListViewProps = {
items?: (TvResult | MovieResult | PersonResult | CollectionResult)[]; items?: (
| TvResult
| MovieResult
| PersonResult
| CollectionResult
| MusicResult
| ArtistResult
| ReleaseResult
| ReleaseGroupResult
| WorkResult
| RecordingResult
)[];
jsxItems?: React.ReactNode[];
plexItems?: WatchlistItem[]; plexItems?: WatchlistItem[];
isEmpty?: boolean; isEmpty?: boolean;
isLoading?: boolean; isLoading?: boolean;
isReachingEnd?: boolean; isReachingEnd?: boolean;
onScrollBottom: () => void; onScrollBottom: () => void;
force_big?: boolean;
}; };
const ListView = ({ const ListView = ({
items, items,
jsxItems,
isEmpty, isEmpty,
isLoading, isLoading,
onScrollBottom, onScrollBottom,
isReachingEnd, isReachingEnd,
plexItems, plexItems,
force_big = false,
}: ListViewProps) => { }: ListViewProps) => {
const intl = useIntl(); const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
@ -43,9 +64,9 @@ const ListView = ({
return ( return (
<li key={`${title.ratingKey}-${index}`}> <li key={`${title.ratingKey}-${index}`}>
<TmdbTitleCard <TmdbTitleCard
id={title.tmdbId} id={Number(title.tmdbId)}
tmdbId={title.tmdbId} tmdbId={Number(title.tmdbId)}
type={title.mediaType} type={title.mediaType as 'movie' | 'tv'}
canExpand canExpand
/> />
</li> </li>
@ -113,10 +134,66 @@ const ListView = ({
/> />
); );
break; 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>; return <li key={`${title.id}-${index}`}>{titleCard}</li>;
})} })}
{jsxItems}
{isLoading && {isLoading &&
!isReachingEnd && !isReachingEnd &&
[...Array(20)].map((_item, i) => ( [...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 = { type FilterSlideoverProps = {
show: boolean; show: boolean;
onClose: () => void; onClose: () => void;
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music';
currentFilters: FilterOptions; currentFilters: FilterOptions;
}; };
@ -74,57 +74,59 @@ const FilterSlideover = ({
onClose={() => onClose()} onClose={() => onClose()}
> >
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<div> {type !== 'music' && (
<div className="mb-2 text-lg font-semibold"> <div>
{intl.formatMessage( <div className="mb-2 text-lg font-semibold">
type === 'movie' ? messages.releaseDate : messages.firstAirDate {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"
/>
</div> </div>
<div className="flex flex-col"> <div className="relative z-40 flex space-x-2">
<div className="mb-2">{intl.formatMessage(messages.to)}</div> <div className="flex flex-col">
<Datepicker <div className="mb-2">{intl.formatMessage(messages.from)}</div>
primaryColor="indigo" <Datepicker
value={{ primaryColor="indigo"
startDate: currentFilters[dateLte] ?? null, value={{
endDate: currentFilters[dateLte] ?? null, startDate: currentFilters[dateGte] ?? null,
}} endDate: currentFilters[dateGte] ?? null,
onChange={(value) => { }}
updateQueryParams( onChange={(value) => {
dateLte, updateQueryParams(
value?.startDate ? (value.startDate as string) : undefined dateGte,
); value?.startDate ? (value.startDate as string) : undefined
}} );
inputName="todate" }}
useRange={false} inputName="fromdate"
asSingle useRange={false}
containerClassName="datepicker-wrapper" asSingle
inputClassName="pr-1 sm:pr-4 text-base leading-5" 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> </div>
</div> )}
{type === 'movie' && ( {type === 'movie' && (
<> <>
<span className="text-lg font-semibold"> <span className="text-lg font-semibold">
@ -138,179 +140,199 @@ const FilterSlideover = ({
/> />
</> </>
)} )}
<span className="text-lg font-semibold"> {type !== 'music' && (
{intl.formatMessage(messages.genres)} <>
</span> <span className="text-lg font-semibold">
<GenreSelector {intl.formatMessage(messages.genres)}
type={type} </span>
defaultValue={currentFilters.genre} <GenreSelector
isMulti type={type}
onChange={(value) => { defaultValue={currentFilters.genre}
updateQueryParams('genre', value?.map((v) => v.value).join(',')); isMulti
}} onChange={(value) => {
/> updateQueryParams(
'genre',
value?.map((v) => v.value).join(',')
);
}}
/>
</>
)}
<span className="text-lg font-semibold"> <span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)} {intl.formatMessage(messages.keywords)}
</span> </span>
<KeywordSelector <KeywordSelector
defaultValue={currentFilters.keywords} defaultValue={currentFilters.keywords}
isMulti 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} type={type}
region={currentFilters.watchRegion} onChange={(value) => {
activeProviders={ updateQueryParams(
currentFilters.watchProviders?.split('|').map((v) => Number(v)) ?? 'keywords',
[] type === 'music'
} ? encodeURIComponent(value?.map((v) => v.label).join(',') ?? '')
onChange={(region, providers) => { : encodeURIComponent(value?.map((v) => v.value).join(',') ?? '')
if (providers.length) { );
batchUpdateQueryParams({
watchRegion: region,
watchProviders: providers.join('|'),
});
} else {
batchUpdateQueryParams({
watchRegion: undefined,
watchProviders: undefined,
});
}
}} }}
/> />
{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"> <div className="pt-4">
<Button <Button
className="w-full" className="w-full"

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

@ -1,19 +1,27 @@
import Slider from '@app/components/Slider'; import Slider from '@app/components/Slider';
import MusicTitleCard from '@app/components/TitleCard/MusicTitleCard';
import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import type { SecondaryType } from '@server/constants/media';
import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces'; import type { MediaResultsResponse } from '@server/interfaces/api/mediaInterfaces';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr'; import useSWR from 'swr';
const messages = defineMessages({ const messages = defineMessages({
recentlyAdded: 'Recently Added', recentlyAdded: 'Recently Added',
recentlyAddedMusic: 'Recently Added Music',
}); });
const RecentlyAddedSlider = () => { const RecentlyAddedSlider = ({
type = 'all',
}: {
type?: 'all' | 'movie' | 'tv' | 'music' | 'artist' | 'release';
}) => {
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
type = type ?? 'all';
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>( 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 } { revalidateOnMount: true }
); );
@ -26,26 +34,59 @@ const RecentlyAddedSlider = () => {
return null; 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 ( return (
<> <>
<div className="slider-header"> {videoMedias.length > 0 && (
<div className="slider-title"> <>
<span>{intl.formatMessage(messages.recentlyAdded)}</span> <div className="slider-header">
</div> <div className="slider-title">
</div> <span>{intl.formatMessage(messages.recentlyAdded)}</span>
<Slider </div>
sliderKey="media" </div>
isLoading={!media} <Slider
items={(media?.results ?? []).map((item) => ( sliderKey="media"
<TmdbTitleCard isLoading={!media}
key={`media-slider-item-${item.id}`} items={videoMedias.map((item) => (
id={item.id} <TmdbTitleCard
tmdbId={item.tmdbId} key={`media-slider-item-${item.id}`}
tvdbId={item.tvdbId} id={item.id}
type={item.mediaType} 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 globalMessages from '@app/i18n/globalMessages';
import { RadioGroup } from '@headlessui/react'; import { RadioGroup } from '@headlessui/react';
import { ArrowRightCircleIcon } from '@heroicons/react/24/solid'; import { ArrowRightCircleIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type Issue from '@server/entity/Issue'; import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
@ -47,8 +48,10 @@ const classNames = (...classes: string[]) => {
}; };
interface CreateIssueModalProps { interface CreateIssueModalProps {
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music';
tmdbId?: number; tmdbId?: number;
mbId?: string;
secondaryType?: SecondaryType;
onCancel?: () => void; onCancel?: () => void;
} }
@ -56,16 +59,18 @@ const CreateIssueModal = ({
onCancel, onCancel,
mediaType, mediaType,
tmdbId, tmdbId,
mbId,
secondaryType,
}: CreateIssueModalProps) => { }: CreateIssueModalProps) => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings(); const settings = useSettings();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const { addToast } = useToasts(); const { addToast } = useToasts();
const { data, error } = useSWR<MovieDetails | TvDetails>( 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; return null;
} }

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

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

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

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

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

@ -19,6 +19,7 @@ import {
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
@ -42,8 +43,32 @@ const messages = defineMessages({
unknowntitle: 'Unknown Title', unknowntitle: 'Unknown Title',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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 = () => { const RequestCardPlaceholder = () => {
@ -205,7 +230,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
interface RequestCardProps { interface RequestCardProps {
request: MediaRequest; request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; onTitleData?: (
requestId: number,
title: MovieDetails | TvDetails | ReleaseResult | ArtistResult
) => void;
} }
const RequestCard = ({ request, onTitleData }: RequestCardProps) => { const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
@ -220,11 +248,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const url = const url =
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/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>( const { data: title, error } = useSWR<
inView ? `${url}` : null MovieDetails | TvDetails | ReleaseResult | ArtistResult
); >(inView ? `${url}` : null);
const { const {
data: requestData, data: requestData,
error: requestError, 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" 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" data-testid="request-card"
> >
{title.backdropPath && ( {isMovie(title) || isTv(title)
<div className="absolute inset-0 z-0"> ? title.backdropPath && (
<CachedImage <div className="absolute inset-0 z-0">
alt="" <CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} alt=""
layout="fill" src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
objectFit="cover" layout="fill"
/> objectFit="cover"
<div />
className="absolute inset-0" <div
style={{ className="absolute inset-0"
backgroundImage: style={{
'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)', backgroundImage:
}} 'linear-gradient(135deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 75%)',
/> }}
</div> />
)} </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 <div
className="relative z-10 flex min-w-0 flex-1 flex-col pr-4" className="relative z-10 flex min-w-0 flex-1 flex-col pr-4"
data-testid="request-card-title" data-testid="request-card-title"
> >
<div className="hidden text-xs font-medium text-white sm:flex"> <div className="hidden text-xs font-medium text-white sm:flex">
{(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( {(isMovie(title)
0, ? title.releaseDate
4 : isTv(title)
)} ? title.firstAirDate
: isRelease(title)
? (title.date as string)
: isArtist(title)
? title.beginDate
: ''
)?.slice(0, 4)}
</div> </div>
<Link <Link
href={ href={
request.type === 'movie' request.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/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"> <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> </a>
</Link> </Link>
{hasPermission( {hasPermission(
@ -376,7 +433,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
</Link> </Link>
</div> </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"> <div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold "> <span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
@ -421,7 +478,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ]
} }
title={isMovie(title) ? title.title : title.name} title={
isMovie(title) || isRelease(title) ? title.title : title.name
}
inProgress={ inProgress={
( (
requestData.media[ requestData.media[
@ -570,19 +629,25 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
href={ href={
request.type === 'movie' request.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/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"> <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 <CachedImage
src={ src={
title.posterPath isMovie(title) || isTv(title)
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? 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' : '/images/overseerr_poster_not_found.png'
} }
alt="" alt=""
layout="responsive" layout="responsive"
width={600} width={isMovie(title) || isTv(title) ? 600 : 900}
height={900} height={900}
/> />
</a> </a>

@ -15,9 +15,11 @@ import {
TrashIcon, TrashIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MediaRequest } from '@server/entity/MediaRequest';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { ArtistResult, ReleaseResult } from '@server/models/Search';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
import axios from 'axios'; import axios from 'axios';
import Link from 'next/link'; import Link from 'next/link';
@ -40,11 +42,29 @@ const messages = defineMessages({
cancelRequest: 'Cancel Request', cancelRequest: 'Cancel Request',
tmdbid: 'TMDB ID', tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID', tvdbid: 'TheTVDB ID',
mbId: 'MusicBrainz ID',
unknowntitle: 'Unknown Title', unknowntitle: 'Unknown Title',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (
return (movie as MovieDetails).title !== undefined; 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 { interface RequestItemErrorProps {
@ -81,7 +101,9 @@ const RequestItemError = ({
requestData?.type requestData?.type
? requestData?.type === 'movie' ? requestData?.type === 'movie'
? globalMessages.movie ? globalMessages.movie
: globalMessages.tvshow : requestData?.type === 'tv'
? globalMessages.tvshow
: globalMessages.music
: globalMessages.request : globalMessages.request
), ),
})} })}
@ -90,10 +112,14 @@ const RequestItemError = ({
<> <>
<div className="card-field"> <div className="card-field">
<span className="card-field-name"> <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>
<span className="flex truncate text-sm text-gray-300"> <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> </span>
</div> </div>
{requestData.media.tvdbId && ( {requestData.media.tvdbId && (
@ -286,10 +312,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const url = const url =
request.type === 'movie' request.type === 'movie'
? `/api/v1/movie/${request.media.tmdbId}` ? `/api/v1/movie/${request.media.tmdbId}`
: `/api/v1/tv/${request.media.tmdbId}`; : request.type === 'tv'
const { data: title, error } = useSWR<MovieDetails | TvDetails>( ? `/api/v1/tv/${request.media.tmdbId}`
inView ? url : null : `/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>( const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`, `/api/v1/request/${request.id}`,
{ {
@ -303,7 +331,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
), ),
} }
); );
const [isRetrying, setRetrying] = useState(false); const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
@ -374,9 +401,10 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
revalidateList(); revalidateList();
setShowEditModal(false); 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"> <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"> <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage <CachedImage
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
@ -399,15 +427,18 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/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"> <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 <CachedImage
src={ src={
title.posterPath title.posterPath && (isMovie(title) || isTv(title))
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` ? `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="" alt=""
layout="responsive" 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"> <div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{(isMovie(title) {(isMovie(title)
? title.releaseDate ? title.releaseDate
: title.firstAirDate : isTv(title)
? title.firstAirDate
: isRelease(title)
? new Date(title.date as string).toDateString()
: title.beginDate
)?.slice(0, 4)} )?.slice(0, 4)}
</div> </div>
<Link <Link
href={ href={
requestData.type === 'movie' requestData.type === 'movie'
? `/movie/${requestData.media.tmdbId}` ? `/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"> <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> </a>
</Link> </Link>
{!isMovie(title) && request.seasons.length > 0 && ( {isTv(title) && request.seasons.length > 0 && (
<div className="card-field"> <div className="card-field">
<span className="card-field-name"> <span className="card-field-name">
{intl.formatMessage(messages.seasons, { {intl.formatMessage(messages.seasons, {
@ -484,7 +523,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' requestData.is4k ? 'downloadStatus4k' : 'downloadStatus'
] ]
} }
title={isMovie(title) ? title.title : title.name} title={
isMovie(title) || isRelease(title)
? title.title
: title.name
}
inProgress={ inProgress={
( (
requestData.media[ requestData.media[

@ -6,6 +6,7 @@ import globalMessages from '@app/i18n/globalMessages';
import { formatBytes } from '@app/utils/numberHelpers'; import { formatBytes } from '@app/utils/numberHelpers';
import { Listbox, Transition } from '@headlessui/react'; import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid'; import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import type { import type {
ServiceCommonServer, ServiceCommonServer,
ServiceCommonServerWithDetails, ServiceCommonServerWithDetails,
@ -42,14 +43,15 @@ export type RequestOverrides = {
server?: number; server?: number;
profile?: number; profile?: number;
folder?: string; folder?: string;
tags?: number[]; tags?: number[] | string[];
language?: number; language?: number;
user?: User; user?: User;
}; };
interface AdvancedRequesterProps { interface AdvancedRequesterProps {
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music';
is4k: boolean; secondaryType?: SecondaryType;
is4k?: boolean;
isAnime?: boolean; isAnime?: boolean;
defaultOverrides?: RequestOverrides; defaultOverrides?: RequestOverrides;
requestUser?: User; requestUser?: User;
@ -67,7 +69,9 @@ const AdvancedRequester = ({
const intl = useIntl(); const intl = useIntl();
const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const { data, error } = useSWR<ServiceCommonServer[]>( const { data, error } = useSWR<ServiceCommonServer[]>(
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`, `/api/v1/service/${
type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr'
}`,
{ {
refreshInterval: 0, refreshInterval: 0,
refreshWhenHidden: false, refreshWhenHidden: false,
@ -91,7 +95,7 @@ const AdvancedRequester = ({
defaultOverrides?.language ?? -1 defaultOverrides?.language ?? -1
); );
const [selectedTags, setSelectedTags] = useState<number[]>( const [selectedTags, setSelectedTags] = useState<number[] | string[]>(
defaultOverrides?.tags ?? [] defaultOverrides?.tags ?? []
); );
@ -99,7 +103,7 @@ const AdvancedRequester = ({
useSWR<ServiceCommonServerWithDetails>( useSWR<ServiceCommonServerWithDetails>(
selectedServer !== null selectedServer !== null
? `/api/v1/service/${ ? `/api/v1/service/${
type === 'movie' ? 'radarr' : 'sonarr' type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr'
}/${selectedServer}` }/${selectedServer}`
: null, : null,
{ {
@ -133,7 +137,9 @@ const AdvancedRequester = ({
Permission.REQUEST, Permission.REQUEST,
type === 'movie' type === 'movie'
? Permission.REQUEST_MOVIE ? Permission.REQUEST_MOVIE
: Permission.REQUEST_TV, : type === 'tv'
? Permission.REQUEST_TV
: Permission.REQUEST_MUSIC,
], ],
user.permissions, user.permissions,
{ type: 'or' } { 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 ProgressCircle from '@app/components/Common/ProgressCircle';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid'; import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid';
import type { SecondaryType } from '@server/constants/media';
import type { QuotaStatus } from '@server/interfaces/api/userInterfaces'; import type { QuotaStatus } from '@server/interfaces/api/userInterfaces';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
@ -29,7 +30,8 @@ const messages = defineMessages({
interface QuotaDisplayProps { interface QuotaDisplayProps {
quota?: QuotaStatus; quota?: QuotaStatus;
mediaType: 'movie' | 'tv'; mediaType: 'movie' | 'tv' | 'music';
secondaryType?: SecondaryType;
userOverride?: number | null; userOverride?: number | null;
remaining?: number; remaining?: number;
overLimit?: 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 CollectionRequestModal from '@app/components/RequestModal/CollectionRequestModal';
import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal'; import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal';
import ReleaseRequestModal from '@app/components/RequestModal/ReleaseRequestModal';
import TvRequestModal from '@app/components/RequestModal/TvRequestModal'; import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
import { Transition } from '@headlessui/react'; 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'; import type { MediaRequest } from '@server/entity/MediaRequest';
interface RequestModalProps { interface RequestModalProps {
show: boolean; show: boolean;
type: 'movie' | 'tv' | 'collection'; type: 'movie' | 'tv' | 'collection' | 'music';
tmdbId: number; secondaryType?: SecondaryType;
tmdbId?: number;
mbId?: string;
is4k?: boolean; is4k?: boolean;
editRequest?: MediaRequest; editRequest?: MediaRequest;
onComplete?: (newStatus: MediaStatus) => void; onComplete?: (newStatus: MediaStatus) => void;
@ -20,11 +24,13 @@ const RequestModal = ({
type, type,
show, show,
tmdbId, tmdbId,
mbId,
is4k, is4k,
editRequest, editRequest,
onComplete, onComplete,
onUpdating, onUpdating,
onCancel, onCancel,
secondaryType,
}: RequestModalProps) => { }: RequestModalProps) => {
return ( return (
<Transition <Transition
@ -41,7 +47,7 @@ const RequestModal = ({
<MovieRequestModal <MovieRequestModal
onComplete={onComplete} onComplete={onComplete}
onCancel={onCancel} onCancel={onCancel}
tmdbId={tmdbId} tmdbId={tmdbId as number}
onUpdating={onUpdating} onUpdating={onUpdating}
is4k={is4k} is4k={is4k}
editRequest={editRequest} editRequest={editRequest}
@ -50,20 +56,36 @@ const RequestModal = ({
<TvRequestModal <TvRequestModal
onComplete={onComplete} onComplete={onComplete}
onCancel={onCancel} onCancel={onCancel}
tmdbId={tmdbId} tmdbId={tmdbId as number}
onUpdating={onUpdating} onUpdating={onUpdating}
is4k={is4k} is4k={is4k}
editRequest={editRequest} editRequest={editRequest}
/> />
) : ( ) : type === 'collection' ? (
<CollectionRequestModal <CollectionRequestModal
onComplete={onComplete} onComplete={onComplete}
onCancel={onCancel} onCancel={onCancel}
tmdbId={tmdbId} tmdbId={tmdbId as number}
onUpdating={onUpdating} onUpdating={onUpdating}
is4k={is4k} 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> </Transition>
); );
}; };

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

@ -43,12 +43,14 @@ type SingleVal = {
type BaseSelectorMultiProps = { type BaseSelectorMultiProps = {
defaultValue?: string; defaultValue?: string;
isMulti: true; isMulti: true;
type?: 'movie' | 'tv' | 'music';
onChange: (value: MultiValue<SingleVal> | null) => void; onChange: (value: MultiValue<SingleVal> | null) => void;
}; };
type BaseSelectorSingleProps = { type BaseSelectorSingleProps = {
defaultValue?: string; defaultValue?: string;
isMulti?: false; isMulti?: false;
type?: 'movie' | 'tv' | 'music';
onChange: (value: SingleValue<SingleVal> | null) => void; onChange: (value: SingleValue<SingleVal> | null) => void;
}; };
@ -131,7 +133,7 @@ export const CompanySelector = ({
}; };
type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & { type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & {
type: 'movie' | 'tv'; type: 'movie' | 'tv' | 'music';
}; };
export const GenreSelector = ({ export const GenreSelector = ({
@ -206,6 +208,7 @@ export const GenreSelector = ({
export const KeywordSelector = ({ export const KeywordSelector = ({
isMulti, isMulti,
defaultValue, defaultValue,
type,
onChange, onChange,
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => { }: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
const intl = useIntl(); const intl = useIntl();
@ -219,41 +222,60 @@ export const KeywordSelector = ({
return; return;
} }
const keywords = await Promise.all( if (type !== 'music') {
defaultValue.split(',').map(async (keywordId) => { const keywords = await Promise.all(
const keyword = await axios.get<Keyword>( defaultValue.split(',').map(async (keywordId) => {
`/api/v1/keyword/${keywordId}` const keyword = await axios.get<Keyword>(
); `/api/v1/keyword/${keywordId}`
);
return keyword.data;
}) return keyword.data;
); })
);
setDefaultDataValue(
keywords.map((keyword) => ({ setDefaultDataValue(
label: keyword.name, keywords.map((keyword) => ({
value: keyword.id, label: keyword.name,
})) value: keyword.id,
); }))
);
} else {
setDefaultDataValue(
defaultValue.split(',').map((keyword, idx) => ({
label: keyword,
value: idx,
}))
);
}
}; };
loadDefaultKeywords(); loadDefaultKeywords();
}, [defaultValue]); }, [defaultValue, type]);
const loadKeywordOptions = async (inputValue: string) => { const loadKeywordOptions = async (inputValue: string) => {
const results = await axios.get<TmdbKeywordSearchResponse>( const results = await axios.get<TmdbKeywordSearchResponse | string[]>(
'/api/v1/search/keyword', '/api/v1/search/keyword',
{ {
params: { params: {
query: encodeURIExtraParams(inputValue), query: encodeURIExtraParams(inputValue),
type,
}, },
} }
); );
return results.data.results.map((result) => ({ if (type === 'music') {
label: result.name, return (results.data as string[]).map((result, idx) => ({
value: result.id, label: result,
})); value: idx,
}));
} else {
return (results.data as TmdbKeywordSearchResponse).results.map(
(result) => ({
label: result.name,
value: result.id,
})
);
}
}; };
return ( 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', 'availability-sync': 'Media Availability Sync',
'radarr-scan': 'Radarr Scan', 'radarr-scan': 'Radarr Scan',
'sonarr-scan': 'Sonarr Scan', 'sonarr-scan': 'Sonarr Scan',
'lidarr-scan': 'Lidarr Scan',
'download-sync': 'Download Sync', 'download-sync': 'Download Sync',
'download-sync-reset': 'Download Sync Reset', 'download-sync-reset': 'Download Sync Reset',
'image-cache-cleanup': 'Image Cache Cleanup', '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 RadarrLogo from '@app/assets/services/radarr.svg';
import SonarrLogo from '@app/assets/services/sonarr.svg'; import SonarrLogo from '@app/assets/services/sonarr.svg';
import Alert from '@app/components/Common/Alert'; 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 LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal'; import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle'; import PageTitle from '@app/components/Common/PageTitle';
import LidarrModal from '@app/components/Settings/LidarrModal';
import RadarrModal from '@app/components/Settings/RadarrModal'; import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal'; import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; 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 axios from 'axios';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -21,8 +27,11 @@ const messages = defineMessages({
services: 'Services', services: 'Services',
radarrsettings: 'Radarr Settings', radarrsettings: 'Radarr Settings',
sonarrsettings: 'Sonarr 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.', '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?', deleteserverconfirm: 'Are you sure you want to delete this server?',
ssl: 'SSL', ssl: 'SSL',
default: 'Default', default: 'Default',
@ -32,6 +41,7 @@ const messages = defineMessages({
activeProfile: 'Active Profile', activeProfile: 'Active Profile',
addradarr: 'Add Radarr Server', addradarr: 'Add Radarr Server',
addsonarr: 'Add Sonarr Server', addsonarr: 'Add Sonarr Server',
addlidarr: 'Add Lidarr Server',
noDefaultServer: noDefaultServer:
'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.', 'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.',
noDefaultNon4kServer: 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.', 'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.',
mediaTypeMovie: 'movie', mediaTypeMovie: 'movie',
mediaTypeSeries: 'series', mediaTypeSeries: 'series',
mediaTypeMusic: 'music',
deleteServer: 'Delete {serverType} Server', deleteServer: 'Delete {serverType} Server',
}); });
@ -53,6 +64,7 @@ interface ServerInstanceProps {
externalUrl?: string; externalUrl?: string;
profileName: string; profileName: string;
isSonarr?: boolean; isSonarr?: boolean;
isLidarr?: boolean;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
} }
@ -66,6 +78,7 @@ const ServerInstance = ({
isDefault = false, isDefault = false,
isSSL = false, isSSL = false,
isSonarr = false, isSonarr = false,
isLidarr = false,
externalUrl, externalUrl,
onEdit, onEdit,
onDelete, onDelete,
@ -127,6 +140,8 @@ const ServerInstance = ({
<a href={serviceUrl} className="opacity-50 hover:opacity-100"> <a href={serviceUrl} className="opacity-50 hover:opacity-100">
{isSonarr ? ( {isSonarr ? (
<SonarrLogo className="h-10 w-10 flex-shrink-0" /> <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" /> <RadarrLogo className="h-10 w-10 flex-shrink-0" />
)} )}
@ -170,6 +185,11 @@ const SettingsServices = () => {
error: sonarrError, error: sonarrError,
mutate: revalidateSonarr, mutate: revalidateSonarr,
} = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr'); } = useSWR<SonarrSettings[]>('/api/v1/settings/sonarr');
const {
data: lidarrData,
error: lidarrError,
mutate: revalidateLidarr,
} = useSWR<LidarrSettings[]>('/api/v1/settings/lidarr');
const [editRadarrModal, setEditRadarrModal] = useState<{ const [editRadarrModal, setEditRadarrModal] = useState<{
open: boolean; open: boolean;
radarr: RadarrSettings | null; radarr: RadarrSettings | null;
@ -184,9 +204,16 @@ const SettingsServices = () => {
open: false, open: false,
sonarr: null, sonarr: null,
}); });
const [editLidarrModal, setEditLidarrModal] = useState<{
open: boolean;
lidarr: LidarrSettings | null;
}>({
open: false,
lidarr: null,
});
const [deleteServerModal, setDeleteServerModal] = useState<{ const [deleteServerModal, setDeleteServerModal] = useState<{
open: boolean; open: boolean;
type: 'radarr' | 'sonarr'; type: 'radarr' | 'sonarr' | 'lidarr';
serverId: number | null; serverId: number | null;
}>({ }>({
open: false, open: false,
@ -217,7 +244,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.radarrsettings)} {intl.formatMessage(messages.radarrsettings)}
</h3> </h3>
<p className="description"> <p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, { {intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Radarr', serverType: 'Radarr',
})} })}
</p> </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 <Transition
as={Fragment} as={Fragment}
show={deleteServerModal.open} show={deleteServerModal.open}
@ -267,7 +305,11 @@ const SettingsServices = () => {
} }
title={intl.formatMessage(messages.deleteServer, { title={intl.formatMessage(messages.deleteServer, {
serverType: serverType:
deleteServerModal.type === 'radarr' ? 'Radarr' : 'Sonarr', deleteServerModal.type === 'radarr'
? 'Radarr'
: deleteServerModal.type === 'sonarr'
? 'Sonarr'
: 'Lidarr',
})} })}
> >
{intl.formatMessage(messages.deleteserverconfirm)} {intl.formatMessage(messages.deleteserverconfirm)}
@ -356,7 +398,7 @@ const SettingsServices = () => {
{intl.formatMessage(messages.sonarrsettings)} {intl.formatMessage(messages.sonarrsettings)}
</h3> </h3>
<p className="description"> <p className="description">
{intl.formatMessage(messages.serviceSettingsDescription, { {intl.formatMessage(messages.videoServiceSettingsDescription, {
serverType: 'Sonarr', serverType: 'Sonarr',
})} })}
</p> </p>
@ -439,6 +481,68 @@ const SettingsServices = () => {
</> </>
)} )}
</div> </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 useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages'; import globalMessages from '@app/i18n/globalMessages';
import type { SecondaryType } from '@server/constants/media';
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import type { DownloadingItem } from '@server/lib/downloadtracker'; import type { DownloadingItem } from '@server/lib/downloadtracker';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -26,7 +27,9 @@ interface StatusBadgeProps {
plexUrl?: string; plexUrl?: string;
serviceUrl?: string; serviceUrl?: string;
tmdbId?: number; tmdbId?: number;
mediaType?: 'movie' | 'tv'; secondaryType?: SecondaryType;
mbId?: string;
mediaType?: 'movie' | 'tv' | 'music';
title?: string | string[]; title?: string | string[];
} }
@ -38,6 +41,8 @@ const StatusBadge = ({
plexUrl, plexUrl,
serviceUrl, serviceUrl,
tmdbId, tmdbId,
mbId,
secondaryType,
mediaType, mediaType,
title, title,
}: StatusBadgeProps) => { }: StatusBadgeProps) => {
@ -52,50 +57,63 @@ const StatusBadge = ({
return Math.round(((media?.size - media?.sizeLeft) / media?.size) * 100); return Math.round(((media?.size - media?.sizeLeft) / media?.size) * 100);
}; };
if ( if (mediaType && plexUrl) {
mediaType && if (mediaType === 'music') {
plexUrl && mediaLink = plexUrl;
hasPermission( mediaLinkDescription = intl.formatMessage(messages.playonplex);
is4k } else if (
? [ hasPermission(
Permission.REQUEST_4K, is4k
mediaType === 'movie' ? [
? Permission.REQUEST_4K_MOVIE Permission.REQUEST_4K,
: Permission.REQUEST_4K_TV, mediaType === 'movie'
] ? Permission.REQUEST_4K_MOVIE
: [ : Permission.REQUEST_4K_TV,
Permission.REQUEST, ]
: [
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' mediaType === 'movie'
? Permission.REQUEST_MOVIE ? 'Radarr'
: Permission.REQUEST_TV, : mediaType === 'tv'
], ? 'Sonarr'
{ : 'Lidarr',
type: 'or', });
} }
) &&
(!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 = ( const tooltipContent = (
<ul> <ul>
{downloadItem.map((status, index) => ( {downloadItem.map((status, index) => (

@ -7,9 +7,10 @@ import { mutate } from 'swr';
interface ErrorCardProps { interface ErrorCardProps {
id: number; id: number;
tmdbId: number; tmdbId?: number;
tvdbId?: number; tvdbId?: number;
type: 'movie' | 'tv'; mbId?: string;
type: 'movie' | 'tv' | 'music';
canExpand?: boolean; canExpand?: boolean;
} }
@ -17,6 +18,7 @@ const messages = defineMessages({
mediaerror: '{mediaType} Not Found', mediaerror: '{mediaType} Not Found',
tmdbid: 'TMDB ID', tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID', tvdbid: 'TheTVDB ID',
mbId: 'MusicBrainz ID',
cleardata: 'Clear Data', cleardata: 'Clear Data',
}); });
@ -37,20 +39,27 @@ const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
<div <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" 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={{ 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 inset-0 h-full w-full overflow-hidden">
<div className="absolute left-0 right-0 flex items-center justify-between p-2"> <div className="absolute left-0 right-0 flex items-center justify-between p-2">
<div <div
className={`pointer-events-none z-40 rounded-full shadow ${ 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"> <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' {type === 'movie'
? intl.formatMessage(globalMessages.movie) ? intl.formatMessage(globalMessages.movie)
: intl.formatMessage(globalMessages.tvshow)} : type === 'tv'
? intl.formatMessage(globalMessages.tvshow)
: intl.formatMessage(globalMessages.music)}
</div> </div>
</div> </div>
<div className="pointer-events-none z-40"> <div className="pointer-events-none z-40">
@ -77,7 +86,9 @@ const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
mediaType: intl.formatMessage( mediaType: intl.formatMessage(
type === 'movie' type === 'movie'
? globalMessages.movie ? globalMessages.movie
: globalMessages.tvshow : type === 'tv'
? globalMessages.tvshow
: globalMessages.music
), ),
})} })}
</h1> </h1>
@ -93,7 +104,9 @@ const Error = ({ id, tmdbId, tvdbId, type, canExpand }: ErrorCardProps) => {
> >
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2 font-bold text-gray-400"> <span className="mr-2 font-bold text-gray-400">
{intl.formatMessage(messages.tmdbid)} {intl.formatMessage(
type === 'music' ? messages.mbId : messages.tmdbid
)}
</span> </span>
{tmdbId} {tmdbId}
</div> </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 { interface PlaceholderProps {
canExpand?: boolean; canExpand?: boolean;
type?: 'music' | 'movie' | 'tv';
} }
const Placeholder = ({ canExpand = false }: PlaceholderProps) => { const Placeholder = ({
canExpand = false,
type = 'movie',
}: PlaceholderProps) => {
return ( return (
<div <div
className={`relative animate-pulse rounded-xl bg-gray-700 ${ className={`relative animate-pulse rounded-xl bg-gray-700 ${
canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44' 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> </div>
); );
}; };

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

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

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

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

@ -198,7 +198,7 @@
"components.LanguageSelector.languageServerDefault": "Default ({language})", "components.LanguageSelector.languageServerDefault": "Default ({language})",
"components.LanguageSelector.originalLanguageDefault": "All Languages", "components.LanguageSelector.originalLanguageDefault": "All Languages",
"components.Layout.LanguagePicker.displaylanguage": "Display Language", "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.browsemovies": "Movies",
"components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.browsetv": "Series",
"components.Layout.Sidebar.dashboard": "Discover", "components.Layout.Sidebar.dashboard": "Discover",

@ -186,6 +186,7 @@ CoreApp.getInitialProps = async (initialProps) => {
locale: 'en', locale: 'en',
emailEnabled: false, emailEnabled: false,
newPlexLogin: true, newPlexLogin: true,
fallbackImage: '/images/overseerr_poster_not_found_logo_top.png',
}; };
if (ctx.res) { 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/*": ["*"] "@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"] "exclude": ["node_modules"]
} }

34129
yarn.lock

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