diff --git a/.gitignore b/.gitignore index 6e051447..87c04ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ tsconfig.tsbuildinfo # Config Cache Directory config/cache + diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 00000000..355eabeb Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/overseerr-api.yml b/overseerr-api.yml index c4c1e97b..aa0d9bad 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -26,6 +26,8 @@ tags: description: Endpoints related to retrieving movies and their details. - name: tv description: Endpoints related to retrieving TV series and their details. + - name: music + description: Endpoints related to retrieving music and details about artists,... - name: other description: Endpoints related to other TMDB data - name: person @@ -35,7 +37,7 @@ tags: - name: collection description: Endpoints related to retrieving collection details. - name: service - description: Endpoints related to getting service (Radarr/Sonarr) details. + description: Endpoints related to getting service (Radarr/Sonarr/Lidarr) details. servers: - url: '{server}/api/v1' variables: @@ -466,6 +468,61 @@ components: - is4k - enableSeasonFolders - isDefault + LidarrSettings: + type: object + properties: + id: + type: number + example: 0 + readOnly: true + name: + type: string + example: 'Lidarr Main' + hostname: + type: string + example: '127.0.0.1' + port: + type: number + example: 8989 + apiKey: + type: string + example: 'exampleapikey' + useSsl: + type: boolean + example: false + baseUrl: + type: string + activeProfileId: + type: number + example: 1 + activeProfileName: + type: string + example: 128kps + activeDirectory: + type: string + example: '/music/' + isDefault: + type: boolean + example: false + externalUrl: + type: string + example: http://lidarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false + required: + - name + - hostname + - port + - apiKey + - useSsl + - activeProfileId + - activeProfileName + - activeDirectory + - isDefault ServarrTag: type: object properties: @@ -593,6 +650,149 @@ components: oneOf: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' + MusicResult: + type: object + properties: + id: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + mediaType: + type: string + posterPath: + type: string + title: + type: string + example: Album Name + releaseDate: + type: string + example: 19923-12-03 + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + ArtistResult: + type: object + properties: + id: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + mediaType: + type: string + example: artist + posterPath: + type: string + title: + type: string + example: Album Name + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + name: + type: string + type: + type: string + enum: + - mbArtistType + releases: + type: array + items: + $ref: '#/components/schemas/ReleaseResult' + gender: + type: string + area: + type: string + beginDate: + type: string + endDate: + type: string + tags: + type: array + items: + type: string + RecordingResult: + type: object + properties: + id: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + mediaType: + type: string + example: recording + title: + type: string + artist: + type: array + items: + $ref: '#/components/schemas/ArtistResult' + length: + type: number + firstReleased: + type: string + format: date-time + tags: + type: array + items: + type: string + + ReleaseGroupResult: + type: object + properties: + id: + type: string + mediaType: + type: string + enum: ['release-group'] + type: + type: string + enum: ['Album', 'Single', 'EP', 'Broadcast', 'Other'] + posterPath: + type: string + nullable: true + title: + type: string + releases: + type: array + items: + $ref: '#/components/schemas/ReleaseResult' + artist: + type: array + items: + $ref: '#/components/schemas/ArtistResult' + tags: + type: array + items: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + ReleaseResult: + type: object + properties: + id: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + mediaType: + type: string + example: release + title: + type: string + artist: + type: array + items: + $ref: '#/components/schemas/ArtistResult' + posterPath: + type: string + date: + type: string + format: date + tracks: + type: array + items: + $ref: '#/components/schemas/RecordingResult' + tags: + type: array + items: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + releaseGroup: + $ref: '#/components/schemas/ReleaseGroupResult' Genre: type: object properties: @@ -1067,6 +1267,8 @@ components: type: string example: '2020-09-12T10:00:27.000Z' readOnly: true + secondaryType: + type: string Cast: type: object properties: @@ -2408,6 +2610,150 @@ paths: application/json: schema: $ref: '#/components/schemas/SonarrSettings' + /settings/lidarr: + get: + summary: Get Lidarr settings + description: Returns all Lidarr settings in a JSON array. + tags: + - settings + responses: + '200': + description: 'Values were returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LidarrSettings' + post: + summary: Create Lidarr instance + description: Creates a new Lidarr instance from the request body. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LidarrSettings' + responses: + '201': + description: 'New Lidarr instance created' + content: + application/json: + schema: + $ref: '#/components/schemas/LidarrSettings' + /settings/lidarr/test: + post: + summary: Test Lidarr configuration + description: Tests if the Lidarr configuration is valid. Returns profiles and root folders on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hostname: + type: string + example: '127.0.0.1' + port: + type: number + example: 7878 + apiKey: + type: string + example: yourapikey + useSsl: + type: boolean + example: false + baseUrl: + type: string + required: + - hostname + - port + - apiKey + - useSsl + responses: + '200': + description: Succesfully connected to Lidarr instance + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/lidarr/{lidarrId}: + put: + summary: Update Lidarr instance + description: Updates an existing Lidarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: lidarrId + required: true + schema: + type: integer + description: Lidarr instance ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LidarrSettings' + responses: + '200': + description: 'Lidarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/LidarrSettings' + delete: + summary: Delete Lidarr instance + description: Deletes an existing Lidarr instance based on the lidarrId parameter. + tags: + - settings + parameters: + - in: path + name: lidarrId + required: true + schema: + type: integer + description: Lidarr instance ID + responses: + '200': + description: 'Lidarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/LidarrSettings' + /settings/lidarr/{lidarrId}/profiles: + get: + summary: Get available Lidarr profiles + description: Returns a list of profiles available on the Lidarr server instance in a JSON array. + tags: + - settings + parameters: + - in: path + name: lidarrId + required: true + schema: + type: integer + description: Lidarr instance ID + responses: + '200': + description: Returned list of profiles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' /settings/public: get: summary: Get public settings @@ -4076,6 +4422,8 @@ paths: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' + - $ref: '#/components/schemas/ArtistResult' + - $ref: '#/components/schemas/ReleaseResult' /search/keyword: get: summary: Search for keywords @@ -4095,6 +4443,11 @@ paths: type: number example: 1 default: 1 + - in: query + name: type + schema: + type: string + enum: [movie,tv,music] responses: '200': description: Results @@ -4115,7 +4468,9 @@ paths: results: type: array items: - $ref: '#/components/schemas/Keyword' + oneOf: + - $ref: '#/components/schemas/Keyword' + - type: string /search/company: get: summary: Search for companies @@ -4734,6 +5089,57 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/musics: + get: + summary: Discover music + description: Returns a list of music in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/ReleaseResult' + - $ref: '#/components/schemas/ArtistResult' + '500': + description: An error occured while getting musics + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Unable to retrieve release groups. /discover/trending: get: summary: Trending movies and TV @@ -5005,10 +5411,12 @@ paths: properties: mediaType: type: string - enum: [movie, tv] + enum: [movie, tv, music] example: movie mediaId: - type: number + oneOf: + - type: number + - type: string example: 123 tvdbId: type: number @@ -5035,6 +5443,9 @@ paths: userId: type: number nullable: true + secondaryType: + type: string + enum: [release,artist] required: - mediaType - mediaId @@ -5119,7 +5530,7 @@ paths: properties: mediaType: type: string - enum: [movie, tv] + enum: [movie, tv, music] seasons: type: array items: @@ -5168,7 +5579,7 @@ paths: post: summary: Retry failed request description: | - Retries a request by resending requests to Sonarr or Radarr. + Retries a request by resending requests to Sonarr, Radarr or Lidarr. Requires the `MANAGE_REQUESTS` permission or `ADMIN`. tags: @@ -5676,6 +6087,82 @@ paths: $ref: '#/components/schemas/CreditCrew' id: type: number + /music/artist/{artistId}: + get: + summary: Get artist details + description: Returns artist details in a JSON object. + tags: + - music + parameters: + - in: path + name: artistId + required: true + schema: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + - in: query + name: full + schema: + type: boolean + example: false + default: false + - in: query + name: maxElements + schema: + type: number + example: 50 + default: 25 + - in: query + name: offset + schema: + type: number + example: 25 + default: 0 + responses: + '200': + description: Artist details + content: + application/json: + schema: + $ref: '#/components/schemas/ArtistResult' + /music/release/{releaseId}: + get: + summary: Get release details + description: Returns full release details in a JSON object. + tags: + - music + parameters: + - in: path + name: releaseId + required: true + schema: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + - in: query + name: full + schema: + type: boolean + example: false + default: false + - in: query + name: maxElements + schema: + type: number + example: 50 + default: 25 + - in: query + name: offset + schema: + type: number + example: 25 + default: 0 + responses: + '200': + description: Release details + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseResult' /media: get: summary: Get media @@ -5707,6 +6194,12 @@ paths: type: string enum: [added, modified, mediaAdded] default: added + - in: query + name: type + schema: + type: string + enum: [all,movie,tv,music,artist,release] + default: all responses: '200': description: Returned media @@ -5954,6 +6447,46 @@ paths: type: array items: $ref: '#/components/schemas/SonarrSeries' + /service/lidarr: + get: + summary: Get non-sensitive Lidarr server list + description: Returns a list of Lidarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LidarrSettings' + /service/lidarr/{lidarrId}: + get: + summary: Get Lidarr server quality profiles and root folders + description: Returns a Lidarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: lidarrId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/LidarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' /regions: get: summary: Regions supported by TMDB diff --git a/package.json b/package.json index 8b82e45d..908c8725 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts", + "dev": "tsc --noEmit --project server/tsconfig.json && nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts", "build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json", "build:next": "next build", "build": "yarn build:next && yarn build:server", @@ -43,7 +43,7 @@ "axios-rate-limit": "1.3.0", "bcrypt": "5.1.0", "bowser": "2.11.0", - "connect-typeorm": "1.1.4", + "connect-typeorm": "2.0.0", "cookie-parser": "1.4.6", "copy-to-clipboard": "3.3.3", "country-flag-icons": "1.5.5", @@ -62,17 +62,18 @@ "lodash": "4.17.21", "next": "12.3.4", "node-cache": "5.1.2", - "node-gyp": "9.3.1", + "node-gyp": "^8.0.0", "node-schedule": "2.1.1", + "nodebrainz": "^2.1.1", "nodemailer": "6.9.1", "openpgp": "5.7.0", "plex-api": "5.3.2", "pug": "3.0.2", - "react": "18.2.0", + "react": "^17.0.0", "react-ace": "10.1.0", "react-animate-height": "2.1.2", "react-aria": "3.23.0", - "react-dom": "18.2.0", + "react-dom": "^17.0.0", "react-intersection-observer": "9.4.3", "react-intl": "6.2.10", "react-markdown": "8.0.5", @@ -89,7 +90,7 @@ "sqlite3": "5.1.4", "swagger-ui-express": "4.6.2", "swr": "2.0.4", - "typeorm": "0.3.12", + "typeorm": "0.3.11", "web-push": "3.5.0", "winston": "3.8.2", "winston-daily-rotate-file": "4.7.1", @@ -100,6 +101,7 @@ }, "devDependencies": { "@babel/cli": "7.21.0", + "@babel/core": "^7.0.0", "@commitlint/cli": "17.4.4", "@commitlint/config-conventional": "17.4.4", "@semantic-release/changelog": "6.0.2", @@ -157,7 +159,7 @@ "prettier": "2.8.4", "prettier-plugin-organize-imports": "3.2.2", "prettier-plugin-tailwindcss": "0.2.3", - "semantic-release": "19.0.5", + "semantic-release": "18.0.0", "semantic-release-docker-buildx": "1.0.1", "tailwindcss": "3.2.7", "ts-node": "10.9.1", diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts new file mode 100644 index 00000000..2607e94f --- /dev/null +++ b/server/api/musicbrainz/index.ts @@ -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 => { + try { + return await new Promise((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((resolve) => resolve([])); + } + }; + + public searchRecordings = async ( + search: RecordingSearchOptions + ): Promise => { + try { + return await new Promise((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((resolve) => resolve([])); + } + }; + + public searchReleaseGroups = ( + search: ReleaseGroupSearchOptions + ): Promise => { + try { + return new Promise((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((resolve) => resolve([])); + } + }; + + public searchReleases = ( + search: ReleaseSearchOptions + ): Promise => { + try { + const processedSearchParams = processReleaseSearchParams(search); + return new Promise((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((resolve) => resolve([])); + } + }; + + public searchWorks = (search: WorkSearchOptions): Promise => { + try { + return new Promise((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((resolve) => resolve([])); + } + }; + + public searchTags = (query: string): Promise => { + try { + return new Promise((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((resolve) => resolve([])); + } + }; + + public getArtist = (artistId: string): Promise => { + try { + return new Promise((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((resolve) => resolve({} as mbArtist)); + } + }; + + public getFullArtist = ( + artistId: string, + maxElements = 25, + startOffset = 0 + ): Promise => { + try { + return new Promise((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((resolve) => resolve({} as mbArtist)); + } + }; + + public getRecordings = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { + try { + return new Promise((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((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((resolve) => resolve([])); + } + }; + + public getRecording = (recordingId: string): Promise => { + try { + return new Promise((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((resolve) => resolve({} as mbRecording)); + } + }; + + public getReleaseGroups = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { + try { + return new Promise((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((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((resolve) => resolve([])); + } + }; + + public getReleaseGroup = ( + releaseGroupId: string + ): Promise => { + try { + return new Promise((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((resolve) => + resolve({} as mbReleaseGroup) + ); + } + }; + + public getReleases = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { + try { + return new Promise((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((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((resolve) => resolve([])); + } + }; + + public getRelease = (releaseId: string): Promise => { + try { + return new Promise((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((resolve) => resolve({} as mbRelease)); + } + }; + + public getWorks = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { + try { + return new Promise((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((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((resolve) => resolve([])); + } + }; + + public getWork = (workId: string): Promise => { + try { + return new Promise((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((resolve) => resolve({} as mbWork)); + } + }; +} + +export default MusicBrainz; +export type { + SearchOptions, + ArtistSearchOptions, + RecordingSearchOptions, + ReleaseSearchOptions, + ReleaseGroupSearchOptions, + WorkSearchOptions, +}; diff --git a/server/api/musicbrainz/interfaces.ts b/server/api/musicbrainz/interfaces.ts new file mode 100644 index 00000000..78b005a8 --- /dev/null +++ b/server/api/musicbrainz/interfaces.ts @@ -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; +} diff --git a/server/api/musicbrainz/poster.ts b/server/api/musicbrainz/poster.ts new file mode 100644 index 00000000..45137391 --- /dev/null +++ b/server/api/musicbrainz/poster.ts @@ -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 { + 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 { + 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 = (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) && + cache.get(val); + }; + cached.cache = cache; + return cached; +}; + +const cachedFanartFromMB = memoize(getFanartFromMB); + +export default memoize(getPosterFromMB); +export { cachedFanartFromMB }; diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index f6b8f3cb..0ecea902 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -3,6 +3,12 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import NodePlexAPI from 'plex-api'; +const SEARCHTYPES = { + movie: 1, + show: 2, + artist: '8,9', +}; + export interface PlexLibraryItem { ratingKey: string; parentRatingKey?: string; @@ -16,7 +22,7 @@ export interface PlexLibraryItem { Guid?: { id: string; }[]; - type: 'movie' | 'show' | 'season' | 'episode'; + type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track'; Media: Media[]; } @@ -28,7 +34,7 @@ interface PlexLibraryResponse { } export interface PlexLibrary { - type: 'show' | 'movie'; + type: 'show' | 'movie' | 'artist'; key: string; title: string; agent: string; @@ -44,7 +50,7 @@ export interface PlexMetadata { ratingKey: string; parentRatingKey?: string; guid: string; - type: 'movie' | 'show' | 'season'; + type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album' | 'track'; title: string; Guid: { id: string; @@ -152,7 +158,10 @@ class PlexAPI { const newLibraries: Library[] = libraries // Remove libraries that are not movie or show .filter( - (library) => library.type === 'movie' || library.type === 'show' + (library) => + library.type === 'movie' || + library.type === 'show' || + library.type === 'artist' ) // Remove libraries that do not have a metadata agent set (usually personal video libraries) .filter((library) => library.agent !== 'com.plexapp.agents.none') @@ -201,6 +210,26 @@ class PlexAPI { }; } + public async getMusicLibraryContents( + id: string, + { offset = 0, size = 50 }: { offset?: number; size?: number } = {} + ): Promise<{ totalSize: number; items: PlexLibraryItem[] }> { + const response = await this.getLibraryContents(id, { offset, size }); + + const response2 = await this.plexClient.query({ + uri: `/library/sections/${id}/albums?includeGuids=1`, + extraHeaders: { + 'X-Plex-Container-Start': `${offset}`, + 'X-Plex-Container-Size': `${size}`, + }, + }); + + return { + totalSize: response.totalSize + response2.MediaContainer.totalSize, + items: response.items.concat(response2.MediaContainer.Metadata) ?? [], + }; + } + public async getMetadata( key: string, options: { includeChildren?: boolean } = {} @@ -227,18 +256,17 @@ class PlexAPI { options: { addedAt: number } = { addedAt: Date.now() - 1000 * 60 * 60, }, - mediaType: 'movie' | 'show' + mediaType: 'movie' | 'show' | 'artist' ): Promise { const response = await this.plexClient.query({ uri: `/library/sections/${id}/all?type=${ - mediaType === 'show' ? '4' : '1' + SEARCHTYPES[mediaType] }&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`, extraHeaders: { 'X-Plex-Container-Start': `0`, 'X-Plex-Container-Size': `500`, }, }); - return response.MediaContainer.Metadata; } } diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 70492689..c79aa953 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -110,10 +110,14 @@ interface MetadataResponse { MediaContainer: { Metadata: { ratingKey: string; - type: 'movie' | 'show'; + type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album'; title: string; Guid: { - id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; + id: + | `imdb://tt${number}` + | `tmdb://${number}` + | `tvdb://${number}` + | `mbid://${string}`; }[]; }[]; }; @@ -121,9 +125,10 @@ interface MetadataResponse { export interface PlexWatchlistItem { ratingKey: string; - tmdbId: number; + tmdbId?: number; tvdbId?: number; - type: 'movie' | 'show'; + musicBrainzId?: string; + type: 'movie' | 'show' | 'season' | 'episode' | 'artist' | 'album'; title: string; } @@ -299,6 +304,9 @@ class PlexTvAPI extends ExternalAPI { const tvdbString = metadata.Guid.find((guid) => guid.id.startsWith('tvdb') ); + const musicBrainzString = metadata.Guid.find((guid) => + guid.id.startsWith('mbid') + ); return { ratingKey: metadata.ratingKey, @@ -308,6 +316,9 @@ class PlexTvAPI extends ExternalAPI { tvdbId: tvdbString ? Number(tvdbString.id.split('//')[1]) : undefined, + musicBrainzId: musicBrainzString + ? musicBrainzString.id.split('//')[1] + : undefined, title: metadata.title, type: metadata.type, }; @@ -315,7 +326,11 @@ class PlexTvAPI extends ExternalAPI { ) ); - const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); + const filteredList = watchlistDetails.filter((detail) => + ['movie', 'show'].includes(detail.type) + ? detail.tmdbId + : detail.musicBrainzId + ); return { offset, diff --git a/server/api/servarr/base.ts b/server/api/servarr/base.ts index c004b474..a4dcd910 100644 --- a/server/api/servarr/base.ts +++ b/server/api/servarr/base.ts @@ -1,7 +1,7 @@ import ExternalAPI from '@server/api/externalapi'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; -import type { DVRSettings } from '@server/lib/settings'; +import type { ArrSettings } from '@server/lib/settings'; export interface SystemStatus { version: string; @@ -79,7 +79,7 @@ interface QueueResponse { } class ServarrBase extends ExternalAPI { - static buildUrl(settings: DVRSettings, path?: string): string { + static buildUrl(settings: ArrSettings, path?: string): string { return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ settings.port }${settings.baseUrl ?? ''}${path}`; diff --git a/server/api/servarr/lidarr.ts b/server/api/servarr/lidarr.ts new file mode 100644 index 00000000..3bfcb444 --- /dev/null +++ b/server/api/servarr/lidarr.ts @@ -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('/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 => { + 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 { + try { + const response = await this.axios.get('/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 => { + try { + const response = await this.axios.get('/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 { + try { + const response = await this.axios.get('/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 { + try { + const response = await this.axios.get('/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 => { + 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(`/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 => { + 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('/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; diff --git a/server/constants/issue.ts b/server/constants/issue.ts index 2c9dcb69..5843c20e 100644 --- a/server/constants/issue.ts +++ b/server/constants/issue.ts @@ -1,8 +1,9 @@ export enum IssueType { VIDEO = 1, AUDIO = 2, - SUBTITLES = 3, - OTHER = 4, + MUSIC = 3, + SUBTITLES = 4, + OTHER = 5, } export enum IssueStatus { @@ -13,6 +14,7 @@ export enum IssueStatus { export const IssueTypeName = { [IssueType.AUDIO]: 'Audio', [IssueType.VIDEO]: 'Video', + [IssueType.MUSIC]: 'Music', [IssueType.SUBTITLES]: 'Subtitle', [IssueType.OTHER]: 'Other', }; diff --git a/server/constants/media.ts b/server/constants/media.ts index de2bf834..6f56a314 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -8,6 +8,15 @@ export enum MediaRequestStatus { export enum MediaType { MOVIE = 'movie', TV = 'tv', + MUSIC = 'music', +} + +export enum SecondaryType { + ARTIST = 'artist', + RELEASE_GROUP = 'release-group', + RELEASE = 'release', + RECORDING = 'recording', + WORK = 'work', } export enum MediaStatus { diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 2d169172..b0c209b0 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -1,3 +1,4 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaStatus, MediaType } from '@server/constants/media'; @@ -24,20 +25,31 @@ import Season from './Season'; @Entity() class Media { public static async getRelatedMedia( - tmdbIds: number | number[] + tmdbIds: number | number[] = [], + mbIds: string | string[] = [] ): Promise { const mediaRepository = getRepository(Media); try { - let finalIds: number[]; + let finalTmdbIds: number[] = []; if (!Array.isArray(tmdbIds)) { - finalIds = [tmdbIds]; + finalTmdbIds = [tmdbIds]; } else { - finalIds = tmdbIds; + finalTmdbIds = tmdbIds; + } + + let finalMusicBrainzIds: string[] = []; + if (!Array.isArray(mbIds)) { + finalMusicBrainzIds = [mbIds]; + } else { + finalMusicBrainzIds = mbIds; } const media = await mediaRepository.find({ - where: { tmdbId: In(finalIds) }, + where: [ + { tmdbId: In(finalTmdbIds) }, + { mbId: In(finalMusicBrainzIds) }, + ], }); return media; @@ -48,18 +60,43 @@ class Media { } public static async getMedia( - id: number, + id: number | string, mediaType: MediaType ): Promise { const mediaRepository = getRepository(Media); try { - const media = await mediaRepository.findOne({ - where: { tmdbId: id, mediaType }, + let media: Media | null = null; + if (mediaType === MediaType.MOVIE || mediaType === MediaType.TV) { + media = await mediaRepository.findOne({ + where: { tmdbId: Number(id), mediaType }, + relations: { requests: true, issues: true }, + }); + } else if (mediaType === MediaType.MUSIC) { + media = await mediaRepository.findOne({ + where: { mbId: String(id), mediaType }, + relations: { requests: true, issues: true }, + }); + } + return media ?? undefined; + } catch (e) { + logger.error(e.message); + return undefined; + } + } + + public static async getChildMedia( + parentId: number + ): Promise { + const mediaRepository = getRepository(Media); + + try { + const media = await mediaRepository.find({ + where: { parentRatingKey: parentId }, relations: { requests: true, issues: true }, }); - return media ?? undefined; + return media; } catch (e) { logger.error(e.message); return undefined; @@ -72,14 +109,25 @@ class Media { @Column({ type: 'varchar' }) public mediaType: MediaType; - @Column() + @Column({ type: 'varchar', nullable: true }) + public secondaryType?: string; + + @Column({ nullable: true }) @Index() - public tmdbId: number; + public tmdbId?: number; - @Column({ unique: true, nullable: true }) + @Column({ nullable: true }) + @Index() + public mbId?: string; + + @Column({ nullable: true }) @Index() public tvdbId?: number; + @Column({ nullable: true }) + @Index() + public musicdbId?: number; + @Column({ nullable: true }) @Index() public imdbId?: string; @@ -138,6 +186,12 @@ class Media { @Column({ nullable: true, type: 'varchar' }) public ratingKey4k?: string | null; + @Column({ nullable: true, type: 'varchar' }) + public title?: string; + + @Column({ nullable: true, type: 'varchar' }) + public parentRatingKey?: number; + public serviceUrl?: string; public serviceUrl4k?: string; public downloadStatus?: DownloadingItem[] = []; @@ -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() @@ -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 + ); + } + } } } diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba67ab7b..6dd0945f 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,3 +1,10 @@ +import MusicBrainz from '@server/api/musicbrainz'; +import type { mbRelease } from '@server/api/musicbrainz/interfaces'; +import type { + LidarrAlbumOptions, + LidarrArtistOptions, +} from '@server/api/servarr/lidarr'; +import LidarrAPI from '@server/api/servarr/lidarr'; import type { RadarrMovieOptions } from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr'; import type { @@ -7,13 +14,19 @@ import type { import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus, MediaType, + SecondaryType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; -import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; +import type { + MusicRequestBody, + TvRequestBody, + VideoRequestBody, +} from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; @@ -48,11 +61,12 @@ type MediaRequestOptions = { @Entity() export class MediaRequest { public static async request( - requestBody: MediaRequestBody, + requestBody: VideoRequestBody | TvRequestBody | MusicRequestBody, user: User, options: MediaRequestOptions = {} ): Promise { const tmdb = new TheMovieDb(); + const musicbrainz = new MusicBrainz(); const mediaRepository = getRepository(Media); const requestRepository = getRepository(MediaRequest); const userRepository = getRepository(User); @@ -111,6 +125,18 @@ export class MediaRequest { requestBody.is4k ? '4K ' : '' }series requests.` ); + } else if ( + requestBody.mediaType === MediaType.MUSIC && + !requestUser.hasPermission( + [Permission.REQUEST, Permission.REQUEST_MUSIC], + { + type: 'or', + } + ) + ) { + throw new RequestPermissionError( + `You do not have permission to make music requests.` + ); } const quotas = await requestUser.getQuota(); @@ -119,49 +145,118 @@ export class MediaRequest { throw new QuotaRestrictedError('Movie Quota exceeded.'); } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { throw new QuotaRestrictedError('Series Quota exceeded.'); + } else if ( + requestBody.mediaType === MediaType.MUSIC && + quotas.music.restricted + ) { + throw new QuotaRestrictedError('Music Quota exceeded.'); } - const tmdbMedia = + const metaMedia = requestBody.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: requestBody.mediaId }) + : requestBody.mediaType === MediaType.MUSIC + ? requestBody.secondaryType === SecondaryType.RELEASE + ? await musicbrainz.getRelease(requestBody.mediaId) + : await musicbrainz.getArtist(requestBody.mediaId) : await tmdb.getTvShow({ tvId: requestBody.mediaId }); - let media = await mediaRepository.findOne({ - where: { - tmdbId: requestBody.mediaId, - mediaType: requestBody.mediaType, - }, - relations: ['requests'], - }); + let media = + requestBody.mediaType === MediaType.MUSIC + ? await mediaRepository.findOne({ + where: { + mbId: requestBody.mediaId, + mediaType: MediaType.MUSIC, + secondaryType: requestBody.secondaryType, + }, + relations: ['requests'], + }) + : await mediaRepository.findOne({ + where: { + tmdbId: Number(metaMedia.id), // Convert tmdbId to number + mediaType: requestBody.mediaType, + }, + relations: ['requests'], + }); if (!media) { - media = new Media({ - tmdbId: tmdbMedia.id, - tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, - status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - mediaType: requestBody.mediaType, - }); - } else { - if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { - media.status = MediaStatus.PENDING; + if (requestBody.mediaType === MediaType.MUSIC) { + media = new Media({ + mbId: requestBody.mediaId, + status: MediaStatus.PENDING, + mediaType: MediaType.MUSIC, + secondaryType: requestBody.secondaryType, + title: (metaMedia as mbRelease).title, + }); + } else if (requestBody.mediaType === MediaType.MOVIE) { + media = new Media({ + tmdbId: requestBody.mediaId, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k + ? MediaStatus.PENDING + : MediaStatus.UNKNOWN, + mediaType: requestBody.mediaType, + }); + } else { + let tvdbId: number | undefined; + if (requestBody.mediaType === MediaType.TV) { + const tvMedia = metaMedia as TmdbTvDetails; + tvdbId = tvMedia.external_ids?.tvdb_id; + } + + media = new Media({ + tmdbId: requestBody.mediaId, + tvdbId: (requestBody as TvRequestBody).tvdbId ?? tvdbId, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k + ? MediaStatus.PENDING + : MediaStatus.UNKNOWN, + mediaType: requestBody.mediaType, + }); } + } else { + if (media.mediaType !== MediaType.MUSIC) { + if ( + media.status === MediaStatus.UNKNOWN && + !(requestBody as VideoRequestBody | TvRequestBody).is4k + ) { + media.status = MediaStatus.PENDING; + } - if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { - media.status4k = MediaStatus.PENDING; + if ( + media.status4k === MediaStatus.UNKNOWN && + (requestBody as VideoRequestBody | TvRequestBody).is4k + ) { + media.status4k = MediaStatus.PENDING; + } + } else { + if (media.status === MediaStatus.UNKNOWN) { + media.status = MediaStatus.PENDING; + } } } - const existing = await requestRepository - .createQueryBuilder('request') - .leftJoin('request.media', 'media') - .leftJoinAndSelect('request.requestedBy', 'user') - .where('request.is4k = :is4k', { is4k: requestBody.is4k }) - .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) - .andWhere('media.mediaType = :mediaType', { - mediaType: requestBody.mediaType, - }) - .getMany(); + const existing = + requestBody.mediaType !== MediaType.MUSIC + ? await requestRepository + .createQueryBuilder('request') + .leftJoin('request.media', 'media') + .leftJoinAndSelect('request.requestedBy', 'user') + .where('request.is4k = :is4k', { is4k: requestBody.is4k }) + .andWhere('media.tmdbId = :tmdbId', { tmdbId: metaMedia.id }) + .andWhere('media.mediaType = :mediaType', { + mediaType: requestBody.mediaType, + }) + .getMany() + : await requestRepository + .createQueryBuilder('request') + .leftJoin('request.media', 'media') + .leftJoinAndSelect('request.requestedBy', 'user') + .where('media.mbId = :mbId', { mbId: requestBody.mediaId }) + .andWhere('media.mediaType = :mediaType', { + mediaType: requestBody.mediaType, + }) + .getMany(); if (existing && existing.length > 0) { // If there is an existing movie request that isn't declined, don't allow a new one. @@ -170,7 +265,7 @@ export class MediaRequest { existing[0].status !== MediaRequestStatus.DECLINED ) { logger.warn('Duplicate request for media blocked', { - tmdbId: tmdbMedia.id, + tmdbId: metaMedia.id, mediaType: requestBody.mediaType, is4k: requestBody.is4k, label: 'Media Request', @@ -240,16 +335,17 @@ export class MediaRequest { await requestRepository.save(request); return request; - } else { - const tmdbMediaShow = tmdbMedia as Awaited< + } else if (requestBody.mediaType === MediaType.TV) { + const metaMediaShow = metaMedia as Awaited< ReturnType >; + const requestedSeasons = - requestBody.seasons === 'all' - ? tmdbMediaShow.seasons + (requestBody as TvRequestBody).seasons === 'all' + ? metaMediaShow.seasons .map((season) => season.season_number) .filter((sn) => sn > 0) - : (requestBody.seasons as number[]); + : ((requestBody as TvRequestBody).seasons as number[]); let existingSeasons: number[] = []; // We need to check existing requests on this title to make sure we don't double up on seasons that were @@ -362,6 +458,44 @@ export class MediaRequest { isAutoRequest: options.isAutoRequest ?? false, }); + await requestRepository.save(request); + return request; + } else { + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.MUSIC, + secondaryType: (requestBody as MusicRequestBody).secondaryType, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + tags: requestBody.tags, + isAutoRequest: options.isAutoRequest ?? false, + }); + await requestRepository.save(request); return request; } @@ -402,6 +536,9 @@ export class MediaRequest { @Column({ type: 'varchar' }) public type: MediaType; + @Column({ type: 'varchar', nullable: true }) + public secondaryType?: SecondaryType; + @RelationCount((request: MediaRequest) => request.seasons) public seasonCount: number; @@ -467,7 +604,11 @@ export class MediaRequest { @AfterUpdate() @AfterInsert() public async sendMedia(): Promise { - await Promise.all([this.sendToRadarr(), this.sendToSonarr()]); + await Promise.all([ + this.sendToRadarr(), + this.sendToSonarr(), + this.sendToLidarr(), + ]); } @AfterInsert() @@ -749,7 +890,9 @@ export class MediaRequest { apiKey: radarrSettings.apiKey, url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); - const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); + const movie = await tmdb.getMovie({ + movieId: Number(this.media.tmdbId), + }); const media = await mediaRepository.findOne({ where: { id: this.media.id }, @@ -966,7 +1109,7 @@ export class MediaRequest { apiKey: sonarrSettings.apiKey, url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); - const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const series = await tmdb.getTvShow({ tvId: Number(media.tmdbId) }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; if (!tvdbId) { @@ -1160,9 +1303,287 @@ export class MediaRequest { } } + public async sendToLidarr(): Promise { + 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) { const tmdb = new TheMovieDb(); - + const musicbrainz = new MusicBrainz(); try { const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; @@ -1197,9 +1618,8 @@ export class MediaRequest { event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`; break; } - if (this.type === MediaType.MOVIE) { - const movie = await tmdb.getMovie({ movieId: media.tmdbId }); + const movie = await tmdb.getMovie({ movieId: media.tmdbId as number }); notificationManager.sendNotification(type, { media, request: this, @@ -1218,7 +1638,7 @@ export class MediaRequest { image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, }); } else if (this.type === MediaType.TV) { - const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tv = await tmdb.getTvShow({ tvId: media.tmdbId as number }); notificationManager.sendNotification(type, { media, request: this, @@ -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) { logger.error('Something went wrong sending media notification(s)', { diff --git a/server/entity/User.ts b/server/entity/User.ts index 7dbdb31b..0cb43e0c 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -103,6 +103,12 @@ export class User { @Column({ nullable: true }) public tvQuotaDays?: number; + @Column({ nullable: true }) + public musicQuotaLimit?: number; + + @Column({ nullable: true }) + public musicQuotaDays?: number; + @OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, eager: true, @@ -306,6 +312,27 @@ export class User { ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0) : 0; + const musicQuotaLimit = !canBypass + ? this.musicQuotaLimit ?? defaultQuotas.music.quotaLimit + : 0; + const musicQuotaDays = this.musicQuotaDays ?? defaultQuotas.music.quotaDays; + const musicDate = new Date(); + if (musicQuotaDays) { + musicDate.setDate(musicDate.getDate() - musicQuotaDays); + } + const musicQuotaUsed = musicQuotaLimit + ? await requestRepository.count({ + where: { + requestedBy: { + id: this.id, + }, + createdAt: AfterDate(musicDate), + type: MediaType.MUSIC, + status: Not(MediaRequestStatus.DECLINED), + }, + }) + : 0; + return { movie: { days: movieQuotaDays, @@ -329,6 +356,18 @@ export class User { restricted: tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false, }, + music: { + days: musicQuotaDays, + limit: musicQuotaLimit, + used: musicQuotaUsed, + remaining: musicQuotaLimit + ? Math.max(0, musicQuotaLimit - musicQuotaUsed) + : undefined, + restricted: + musicQuotaLimit && musicQuotaLimit - musicQuotaUsed <= 0 + ? true + : false, + }, }; } } diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 89cb7426..cfe24f23 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -6,8 +6,9 @@ export interface GenreSliderItem { export interface WatchlistItem { ratingKey: string; - tmdbId: number; - mediaType: 'movie' | 'tv'; + tmdbId?: number; + musicBrainzId?: string; + mediaType: 'movie' | 'tv' | 'music'; title: string; } diff --git a/server/interfaces/api/requestInterfaces.ts b/server/interfaces/api/requestInterfaces.ts index 89863cb0..e1f158c4 100644 --- a/server/interfaces/api/requestInterfaces.ts +++ b/server/interfaces/api/requestInterfaces.ts @@ -1,4 +1,4 @@ -import type { MediaType } from '@server/constants/media'; +import type { MediaType, SecondaryType } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { PaginatedResponse } from './common'; @@ -6,16 +6,31 @@ export interface RequestResultsResponse extends PaginatedResponse { results: MediaRequest[]; } -export type MediaRequestBody = { +interface MediaRequestBody { mediaType: MediaType; - mediaId: number; - tvdbId?: number; - seasons?: number[] | 'all'; - is4k?: boolean; + mediaId: number | string; serverId?: number; profileId?: number; rootFolder?: string; languageProfileId?: number; userId?: number; tags?: number[]; -}; +} + +export interface VideoRequestBody extends MediaRequestBody { + mediaType: MediaType.MOVIE | MediaType.TV; + mediaId: number; + seasons?: number[] | 'all'; + is4k?: boolean; + tvdbId?: number; +} + +export interface TvRequestBody extends VideoRequestBody { + mediaType: MediaType.TV; +} + +export interface MusicRequestBody extends MediaRequestBody { + secondaryType: SecondaryType; + mediaType: MediaType.MUSIC; + mediaId: string; +} diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index 3b430b0b..4f56a6b1 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -4,7 +4,7 @@ import type { LanguageProfile } from '@server/api/servarr/sonarr'; export interface ServiceCommonServer { id: number; name: string; - is4k: boolean; + is4k?: boolean; isDefault: boolean; activeProfileId: number; activeDirectory: string; @@ -12,7 +12,7 @@ export interface ServiceCommonServer { activeAnimeProfileId?: number; activeAnimeDirectory?: string; activeAnimeLanguageProfileId?: number; - activeTags: number[]; + activeTags: number[] | string[]; activeAnimeTags?: number[]; } diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 0cd2f171..d102b9fc 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -33,6 +33,7 @@ export interface PublicSettingsResponse { partialRequestsEnabled: boolean; cacheImages: boolean; vapidPublic: string; + fallbackImage: string; enablePushRegistration: boolean; locale: string; emailEnabled: boolean; diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index 2ac75c5e..ff003650 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -22,6 +22,7 @@ export interface QuotaStatus { export interface QuotaResponse { movie: QuotaStatus; tv: QuotaStatus; + music: QuotaStatus; } export interface UserWatchDataResponse { diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 932d6107..6a766467 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,6 +1,7 @@ import availabilitySync from '@server/lib/availabilitySync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; +import { lidarrScanner } from '@server/lib/scanners/lidarr'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { radarrScanner } from '@server/lib/scanners/radarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr'; @@ -116,7 +117,22 @@ export const startJobs = (): void => { cancelFn: () => sonarrScanner.cancel(), }); - // Checks if media is still available in plex/sonarr/radarr libs + // Run full lidarr scan every 24 hours + scheduledJobs.push({ + id: 'lidarr-scan', + name: 'Lidarr Scan', + type: 'process', + interval: 'hours', + cronSchedule: jobs['lidarr-scan'].schedule, + job: schedule.scheduleJob(jobs['lidarr-scan'].schedule, () => { + logger.info('Starting scheduled job: Lidarr Scan', { label: 'Jobs' }); + lidarrScanner.run(); + }), + running: () => lidarrScanner.status().running, + cancelFn: () => lidarrScanner.cancel(), + }); + + // Checks if media is still available in plex/sonarr/radarr/lidarr libs scheduledJobs.push({ id: 'availability-sync', name: 'Media Availability Sync', diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 0a16302c..8dbf10be 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -286,16 +286,20 @@ class AvailabilitySync { id: media.id, }) .andWhere( - `(request.is4k = :is4k AND media.${ - is4k ? 'status4k' : 'status' - } IN (:...mediaStatus))`, - { - mediaStatus: [ - MediaStatus.AVAILABLE, - MediaStatus.PARTIALLY_AVAILABLE, - ], - is4k: is4k, - } + ['show', 'movie'].includes(media.mediaType) + ? `(request.is4k = :is4k AND media.${ + is4k ? 'status4k' : 'status' + } IN (:...mediaStatus))` + : '', + ['show', 'movie'].includes(media.mediaType) + ? { + mediaStatus: [ + MediaStatus.AVAILABLE, + MediaStatus.PARTIALLY_AVAILABLE, + ], + is4k: is4k, + } + : {} ) .getMany(); diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 011205e7..7637de58 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -2,13 +2,15 @@ import NodeCache from 'node-cache'; export type AvailableCacheIds = | 'tmdb' + | 'musicbrainz' | 'radarr' | 'sonarr' | 'rt' | 'imdb' | 'github' | 'plexguid' - | 'plextv'; + | 'plextv' + | 'lidarr'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -46,8 +48,13 @@ class CacheManager { stdTtl: 21600, checkPeriod: 60 * 30, }), + musicbrainz: new Cache('musicbrainz', 'MusicBrainz API', { + stdTtl: 21600, + checkPeriod: 60 * 30, + }), radarr: new Cache('radarr', 'Radarr API'), sonarr: new Cache('sonarr', 'Sonarr API'), + lidarr: new Cache('lidarr', 'Lidarr API'), rt: new Cache('rt', 'Rotten Tomatoes API', { stdTtl: 43200, checkPeriod: 60 * 30, diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts index cf29313e..69a2ccc0 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -1,3 +1,4 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import { MediaType } from '@server/constants/media'; @@ -26,6 +27,7 @@ export interface DownloadingItem { class DownloadTracker { private radarrServers: Record = {}; private sonarrServers: Record = {}; + private lidarrServers: Record = {}; public getMovieProgress( serverId: number, @@ -53,6 +55,19 @@ class DownloadTracker { ); } + public getMusicProgress( + serverId: number, + externalServiceId: number + ): DownloadingItem[] { + if (!this.lidarrServers[serverId]) { + return []; + } + + return this.lidarrServers[serverId].filter( + (item) => item.externalId === externalServiceId + ); + } + public async resetDownloadTracker() { this.radarrServers = {}; } @@ -60,6 +75,7 @@ class DownloadTracker { public updateDownloads() { this.updateRadarrDownloads(); this.updateSonarrDownloads(); + this.updateLidarrDownloads(); } private async updateRadarrDownloads() { @@ -214,6 +230,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(); diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 98c81a49..d9bbd49c 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -8,24 +8,27 @@ export enum Permission { AUTO_APPROVE = 128, AUTO_APPROVE_MOVIE = 256, AUTO_APPROVE_TV = 512, - REQUEST_4K = 1024, - REQUEST_4K_MOVIE = 2048, - REQUEST_4K_TV = 4096, - REQUEST_ADVANCED = 8192, - REQUEST_VIEW = 16384, - AUTO_APPROVE_4K = 32768, - AUTO_APPROVE_4K_MOVIE = 65536, - AUTO_APPROVE_4K_TV = 131072, - REQUEST_MOVIE = 262144, - REQUEST_TV = 524288, - MANAGE_ISSUES = 1048576, - VIEW_ISSUES = 2097152, - CREATE_ISSUES = 4194304, - AUTO_REQUEST = 8388608, - AUTO_REQUEST_MOVIE = 16777216, - AUTO_REQUEST_TV = 33554432, - RECENT_VIEW = 67108864, - WATCHLIST_VIEW = 134217728, + AUTO_APPROVE_MUSIC = 268_435_456, + REQUEST_4K = 1_024, + REQUEST_4K_MOVIE = 2_048, + REQUEST_4K_TV = 4_096, + REQUEST_ADVANCED = 8_192, + REQUEST_VIEW = 16_384, + AUTO_APPROVE_4K = 32_768, + AUTO_APPROVE_4K_MOVIE = 65_536, + AUTO_APPROVE_4K_TV = 131_072, + REQUEST_MOVIE = 262_144, + REQUEST_TV = 524_288, + REQUEST_MUSIC = 536_870_912, + MANAGE_ISSUES = 1_048_576, + VIEW_ISSUES = 2_097_152, + CREATE_ISSUES = 4_194_304, + AUTO_REQUEST = 8_388_608, + AUTO_REQUEST_MOVIE = 16_777_216, + AUTO_REQUEST_TV = 33_554_432, + AUTO_REQUEST_MUSIC = 1_073_741_824, + RECENT_VIEW = 67_108_864, + WATCHLIST_VIEW = 134_217_728, } export interface PermissionCheckOptions { diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f0f3db7e..6f29b49d 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -1,5 +1,6 @@ +import type { LidarrRelease } from '@server/api/servarr/lidarr'; import TheMovieDb from '@server/api/themoviedb'; -import { MediaStatus, MediaType } from '@server/constants/media'; +import { MediaStatus, MediaType, SecondaryType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import Season from '@server/entity/Season'; @@ -24,7 +25,8 @@ export interface RunnableScanner { } export interface MediaIds { - tmdbId: number; + tmdbId?: number; + mbId?: string; imdbId?: string; tvdbId?: number; isHama?: boolean; @@ -39,6 +41,11 @@ interface ProcessOptions { externalServiceSlug?: string; title?: string; processing?: boolean; + parentRatingKey?: string; +} + +interface ProcessGroupOptions extends ProcessOptions { + releases?: LidarrRelease[]; } export interface ProcessableSeason { @@ -79,13 +86,19 @@ class BaseScanner { this.updateRate = updateRate ?? UPDATE_RATE; } - private async getExisting(tmdbId: number, mediaType: MediaType) { + private async getExisting(id: number | string, mediaType: MediaType) { const mediaRepository = getRepository(Media); - const existing = await mediaRepository.findOne({ - where: { tmdbId: tmdbId, mediaType }, - }); - + let existing: Media | null; + if (mediaType === MediaType.MOVIE || mediaType === MediaType.TV) { + existing = await mediaRepository.findOne({ + where: { tmdbId: id as number, mediaType }, + }); + } else { + existing = await mediaRepository.findOne({ + where: { mbId: id as string, mediaType }, + }); + } return existing; } @@ -110,8 +123,8 @@ class BaseScanner { if (existing) { let changedExisting = false; - if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) { - existing[is4k ? 'status4k' : 'status'] = processing + if (existing['status'] !== MediaStatus.AVAILABLE) { + existing['status'] = processing ? MediaStatus.PROCESSING : MediaStatus.AVAILABLE; if (mediaAddedAt) { @@ -125,29 +138,21 @@ class BaseScanner { changedExisting = true; } - if ( - ratingKey && - existing[is4k ? 'ratingKey4k' : 'ratingKey'] !== ratingKey - ) { - existing[is4k ? 'ratingKey4k' : 'ratingKey'] = ratingKey; + if (ratingKey && existing['ratingKey'] !== ratingKey) { + existing['ratingKey'] = ratingKey; changedExisting = true; } - if ( - serviceId !== undefined && - existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId - ) { - existing[is4k ? 'serviceId4k' : 'serviceId'] = serviceId; + if (serviceId !== undefined && existing['serviceId'] !== serviceId) { + existing['serviceId'] = serviceId; changedExisting = true; } if ( externalServiceId !== undefined && - existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] !== - externalServiceId + existing['externalServiceId'] !== externalServiceId ) { - existing[is4k ? 'externalServiceId4k' : 'externalServiceId'] = - externalServiceId; + existing['externalServiceId'] = externalServiceId; changedExisting = true; } @@ -384,12 +389,11 @@ class BaseScanner { } if (serviceId !== undefined) { - media[is4k ? 'serviceId4k' : 'serviceId'] = serviceId; + media['serviceId'] = serviceId; } if (externalServiceId !== undefined) { - media[is4k ? 'externalServiceId4k' : 'externalServiceId'] = - externalServiceId; + media['externalServiceId'] = externalServiceId; } if (externalServiceSlug !== undefined) { @@ -505,6 +509,289 @@ class BaseScanner { }); } + protected async processArtist( + mbId: string, + { + mediaAddedAt, + ratingKey, + serviceId, + externalServiceId, + processing = false, + title = 'Unknown Title', + }: ProcessOptions = {} + ): Promise { + 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 { + 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 { + const mediaRepository = getRepository(Media); + + await this.asyncLock.dispatch(mbId, async () => { + const existings = ( + await Promise.all( + releases.map((release) => + this.getExisting(release.foreignReleaseId, MediaType.MUSIC) + ) + ) + ).filter((existing) => existing !== null) as Media[]; + + if (existings.length > 0) { + for (const existing of existings) { + let changedExisting = false; + + if (existing['status'] !== MediaStatus.AVAILABLE) { + existing['status'] = processing + ? MediaStatus.PROCESSING + : MediaStatus.AVAILABLE; + if (mediaAddedAt) { + existing.mediaAddedAt = mediaAddedAt; + } + changedExisting = true; + } + + if (!changedExisting && !existing.mediaAddedAt && mediaAddedAt) { + existing.mediaAddedAt = mediaAddedAt; + changedExisting = true; + } + + if (ratingKey && existing['ratingKey'] !== ratingKey) { + existing['ratingKey'] = ratingKey; + changedExisting = true; + } + + if (serviceId !== undefined && existing['serviceId'] !== serviceId) { + existing['serviceId'] = serviceId; + changedExisting = true; + } + + if ( + externalServiceId !== undefined && + existing['externalServiceId'] !== externalServiceId + ) { + existing['externalServiceId'] = externalServiceId; + changedExisting = true; + } + + if (changedExisting) { + await mediaRepository.save(existing); + this.log( + `Media for ${title} exists. Changes were detected and the title will be updated.`, + 'info' + ); + } else { + this.log( + `Title already exists and no changes detected for ${title}` + ); + } + } + } else { + const newMedia = new Media(); + newMedia.mbId = mbId; + newMedia.title = title; + newMedia.parentRatingKey = parentRatingKey + ? Number(parentRatingKey.match(/(\d+)/)?.[0]) + : undefined; + newMedia.secondaryType = SecondaryType.RELEASE; + newMedia.status = !processing + ? MediaStatus.AVAILABLE + : processing + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN; + newMedia.mediaType = MediaType.MUSIC; + newMedia.serviceId = serviceId; + newMedia.externalServiceId = externalServiceId; + + if (mediaAddedAt) { + newMedia.mediaAddedAt = mediaAddedAt; + } + + if (ratingKey) { + newMedia.ratingKey = ratingKey; + } + await mediaRepository.save(newMedia); + this.log(`Saved new media: ${title}`); + } + }); + } + /** * Call startRun from child class whenever a run is starting to * ensure required values are set diff --git a/server/lib/scanners/lidarr/index.ts b/server/lib/scanners/lidarr/index.ts new file mode 100644 index 00000000..9a832122 --- /dev/null +++ b/server/lib/scanners/lidarr/index.ts @@ -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 + implements RunnableScanner +{ + 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 { + 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 { + 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(); diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f074872b..13a24eb1 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -19,6 +19,7 @@ import { uniqWith } from 'lodash'; const imdbRegex = new RegExp(/imdb:\/\/(tt[0-9]+)/); const tmdbRegex = new RegExp(/tmdb:\/\/([0-9]+)/); const tvdbRegex = new RegExp(/tvdb:\/\/([0-9]+)/); +const mbRegex = new RegExp(/mbid:\/\/([0-9a-f-]+)/); const tmdbShowRegex = new RegExp(/themoviedb:\/\/([0-9]+)/); const plexRegex = new RegExp(/plex:\/\//); // Hama agent uses ASS naming, see details here: @@ -135,7 +136,13 @@ class PlexScanner for (const library of this.libraries) { this.currentLibrary = library; this.log(`Beginning to process library: ${library.name}`, 'info'); - await this.paginateLibrary(library, { sessionId }); + try { + await this.paginateLibrary(library, { sessionId }); + } catch (e) { + this.log('Failed to paginate library', 'error', { + errorMessage: e.message, + }); + } } } this.log( @@ -164,12 +171,16 @@ class PlexScanner if (this.sessionId !== sessionId) { throw new Error('New session was started. Old session aborted.'); } - - const response = await this.plexClient.getLibraryContents(library.id, { - size: this.protectedBundleSize, - offset: start, - }); - + const response = + library.type === 'artist' + ? await this.plexClient.getMusicLibraryContents(library.id, { + size: this.protectedBundleSize, + offset: start, + }) + : await this.plexClient.getLibraryContents(library.id, { + size: this.protectedBundleSize, + offset: start, + }); this.progress = start; this.totalSize = response.totalSize; @@ -209,6 +220,10 @@ class PlexScanner plexitem.type === 'season' ) { await this.processPlexShow(plexitem); + } else if (plexitem.type === 'artist') { + await this.processPlexArtist(plexitem); + } else if (plexitem.type === 'album') { + await this.processPlexAlbum(plexitem); } } catch (e) { this.log('Failed to process Plex media', 'error', { @@ -224,13 +239,18 @@ class PlexScanner const has4k = plexitem.Media.some( (media) => media.videoResolution === '4k' ); - - await this.processMovie(mediaIds.tmdbId, { - is4k: has4k && this.enable4kMovie, - mediaAddedAt: new Date(plexitem.addedAt * 1000), - ratingKey: plexitem.ratingKey, - title: plexitem.title, - }); + if (mediaIds.tmdbId) { + await this.processMovie(mediaIds.tmdbId, { + is4k: has4k && this.enable4kMovie, + mediaAddedAt: new Date(plexitem.addedAt * 1000), + ratingKey: plexitem.ratingKey, + title: plexitem.title, + }); + } else { + this.log('No TMDB ID found for movie', 'warn', { + title: plexitem.title, + }); + } } private async processPlexMovieByTmdbId( @@ -273,7 +293,9 @@ class PlexScanner await this.processHamaSpecials(metadata, mediaIds.tvdbId); } - const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId }); + const tvShow = await this.tmdb.getTvShow({ + tvId: mediaIds.tmdbId as number, + }); const seasons = tvShow.seasons; const processableSeasons: ProcessableSeason[] = []; @@ -322,7 +344,7 @@ class PlexScanner if (mediaIds.tvdbId) { await this.processShow( - mediaIds.tmdbId, + mediaIds.tmdbId as number, mediaIds.tvdbId ?? tvShow.external_ids.tvdb_id, processableSeasons, { @@ -334,6 +356,37 @@ class PlexScanner } } + private async processPlexArtist(plexitem: PlexLibraryItem) { + const mediaIds = await this.getMediaIds(plexitem); + if (mediaIds.mbId) { + await this.processArtist(mediaIds.mbId, { + mediaAddedAt: new Date(plexitem.addedAt * 1000), + ratingKey: plexitem.ratingKey, + title: plexitem.title, + }); + } else { + this.log('No MusicBrainz ID found for artist', 'warn', { + title: plexitem.title, + }); + } + } + + private async processPlexAlbum(plexitem: PlexLibraryItem) { + const mediaIds = await this.getMediaIds(plexitem); + if (mediaIds.mbId) { + await this.processAlbum(mediaIds.mbId, { + mediaAddedAt: new Date(plexitem.addedAt * 1000), + ratingKey: plexitem.ratingKey, + title: plexitem.title, + parentRatingKey: plexitem.parentRatingKey, + }); + } else { + this.log('No MusicBrainz ID found for album', 'warn', { + title: plexitem.title, + }); + } + } + private async getMediaIds(plexitem: PlexLibraryItem): Promise { let mediaIds: Partial = {}; // Check if item is using new plex movie/tv agent @@ -372,6 +425,8 @@ class PlexScanner } else if (ref.id.match(tvdbRegex)) { const tvdbMatch = ref.id.match(tvdbRegex)?.[1]; mediaIds.tvdbId = Number(tvdbMatch); + } else if (ref.id.match(mbRegex)) { + mediaIds.mbId = ref.id.match(mbRegex)?.[1] ?? undefined; } }); @@ -487,10 +542,16 @@ class PlexScanner } } } + // Check for MusicBrainz + } else if (plexitem.guid.match(mbRegex)) { + const mbMatch = plexitem.guid.match(mbRegex); + if (mbMatch) { + mediaIds.mbId = mbMatch[1]; + } } - if (!mediaIds.tmdbId) { - throw new Error('Unable to find TMDB ID'); + if (!mediaIds.tmdbId && !mediaIds.mbId) { + throw new Error('Unable to find either a TMDB ID or a MB ID'); } // We check above if we have the TMDB ID, so we can safely assert the type below diff --git a/server/lib/search.ts b/server/lib/search.ts index be9ee3ae..20e0825e 100644 --- a/server/lib/search.ts +++ b/server/lib/search.ts @@ -1,3 +1,4 @@ +import MusicBrainz from '@server/api/musicbrainz'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbMovieDetails, @@ -10,6 +11,7 @@ import type { TmdbTvDetails, TmdbTvResult, } from '@server/api/themoviedb/interfaces'; +import type { MbSearchMultiResponse } from '@server/models/Search'; import { mapMovieDetailsToResult, mapPersonDetailsToResult, @@ -31,7 +33,7 @@ interface SearchProvider { id: string; language?: string; query?: string; - }) => Promise; + }) => Promise; } 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, + }; + }, +}); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 10213a04..55734982 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -9,7 +9,7 @@ export interface Library { id: string; name: string; enabled: boolean; - type: 'show' | 'movie'; + type: 'show' | 'movie' | 'artist'; lastScan?: number; } @@ -44,7 +44,7 @@ export interface TautulliSettings { externalUrl?: string; } -export interface DVRSettings { +export interface ArrSettings { id: number; name: string; hostname: string; @@ -55,13 +55,17 @@ export interface DVRSettings { activeProfileId: number; activeProfileName: string; activeDirectory: string; - tags: number[]; - is4k: boolean; isDefault: boolean; externalUrl?: string; syncEnabled: boolean; preventSearch: boolean; tagRequests: boolean; + tags: string[] | number[]; +} + +export interface DVRSettings extends ArrSettings { + is4k: boolean; + tags: number[]; } export interface RadarrSettings extends DVRSettings { @@ -79,6 +83,7 @@ export interface SonarrSettings extends DVRSettings { animeTags?: number[]; enableSeasonFolders: boolean; } +export type LidarrSettings = ArrSettings; interface Quota { quotaLimit?: number; @@ -86,6 +91,7 @@ interface Quota { } export interface MainSettings { + fallbackImage: string; apiKey: string; applicationTitle: string; applicationUrl: string; @@ -95,6 +101,7 @@ export interface MainSettings { defaultQuotas: { movie: Quota; tv: Quota; + music: Quota; }; hideAvailable: boolean; localLogin: boolean; @@ -122,6 +129,7 @@ interface FullPublicSettings extends PublicSettings { partialRequestsEnabled: boolean; cacheImages: boolean; vapidPublic: string; + fallbackImage: string; enablePushRegistration: boolean; locale: string; emailEnabled: boolean; @@ -250,6 +258,7 @@ export type JobId = | 'plex-watchlist-sync' | 'radarr-scan' | 'sonarr-scan' + | 'lidarr-scan' | 'download-sync' | 'download-sync-reset' | 'image-cache-cleanup' @@ -264,6 +273,7 @@ interface AllSettings { tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; + lidarr: ArrSettings[]; public: PublicSettings; notifications: NotificationSettings; jobs: Record; @@ -291,6 +301,7 @@ class Settings { defaultQuotas: { movie: {}, tv: {}, + music: {}, }, hideAvailable: false, localLogin: true, @@ -300,6 +311,7 @@ class Settings { trustProxy: false, partialRequestsEnabled: true, locale: 'en', + fallbackImage: '/images/overseerr_poster_not_found_logo_top.png', }, plex: { name: '', @@ -311,6 +323,7 @@ class Settings { tautulli: {}, radarr: [], sonarr: [], + lidarr: [], public: { initialized: false, }, @@ -415,6 +428,9 @@ class Settings { 'sonarr-scan': { schedule: '0 30 4 * * *', }, + 'lidarr-scan': { + schedule: '0 0 5 * * *', + }, 'availability-sync': { schedule: '0 0 5 * * *', }, @@ -478,6 +494,14 @@ class Settings { this.data.sonarr = data; } + get lidarr(): ArrSettings[] { + return this.data.lidarr; + } + + set lidarr(data: ArrSettings[]) { + this.data.lidarr = data; + } + get public(): PublicSettings { return this.data.public; } @@ -508,6 +532,9 @@ class Settings { locale: this.data.main.locale, emailEnabled: this.data.notifications.agents.email.enabled, newPlexLogin: this.data.main.newPlexLogin, + fallbackImage: + this.data.main.fallbackImage ?? + '/images/overseerr_poster_not_found_logo_top.png', }; } diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 37592399..4b6d1127 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -10,6 +10,11 @@ import { RequestPermissionError, } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; +import type { + MusicRequestBody, + TvRequestBody, + VideoRequestBody, +} from '@server/interfaces/api/requestInterfaces'; import logger from '@server/logger'; import { Permission } from './permissions'; @@ -45,6 +50,7 @@ class WatchlistSync { Permission.AUTO_REQUEST, Permission.AUTO_REQUEST_MOVIE, Permission.AUTO_APPROVE_TV, + Permission.AUTO_REQUEST_MUSIC, ], { type: 'or' } ) @@ -65,7 +71,8 @@ class WatchlistSync { const response = await plexTvApi.getWatchlist({ size: 200 }); const mediaItems = await Media.getRelatedMedia( - response.items.map((i) => i.tmdbId) + response.items.map((i) => i.tmdbId) as number[], + response.items.map((i) => i.musicBrainzId) as string[] ); const unavailableItems = response.items.filter( @@ -74,7 +81,8 @@ class WatchlistSync { !mediaItems.find( (m) => m.tmdbId === i.tmdbId && - ((m.status !== MediaStatus.UNKNOWN && m.mediaType === 'movie') || + ((m.status !== MediaStatus.UNKNOWN && + (m.mediaType === 'movie' || m.mediaType === 'music')) || (m.mediaType === 'tv' && m.status === MediaStatus.AVAILABLE)) ) ); @@ -112,13 +120,17 @@ class WatchlistSync { await MediaRequest.request( { - mediaId: mediaItem.tmdbId, + mediaId: mediaItem.tmdbId ?? mediaItem.musicBrainzId, mediaType: - mediaItem.type === 'show' ? MediaType.TV : MediaType.MOVIE, + mediaItem.type === 'show' + ? MediaType.TV + : mediaItem.type === 'movie' + ? MediaType.MOVIE + : MediaType.MUSIC, seasons: mediaItem.type === 'show' ? 'all' : undefined, - tvdbId: mediaItem.tvdbId, - is4k: false, - }, + tvdbId: mediaItem.tvdbId ?? undefined, + is4k: ['movie', 'show'].includes(mediaItem.type) ? false : false, + } as MusicRequestBody | TvRequestBody | VideoRequestBody, user, { isAutoRequest: true } ); diff --git a/server/models/Collection.ts b/server/models/Collection.ts index 20a3c715..81cedef1 100644 --- a/server/models/Collection.ts +++ b/server/models/Collection.ts @@ -14,20 +14,22 @@ export interface Collection { parts: MovieResult[]; } -export const mapCollection = ( +export const mapCollection = async ( collection: TmdbCollection, media: Media[] -): Collection => ({ +): Promise => ({ id: collection.id, name: collection.name, overview: collection.overview, posterPath: collection.poster_path, backdropPath: collection.backdrop_path, - parts: sortBy(collection.parts, 'release_date').map((part) => - mapMovieResult( - part, - media?.find( - (req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE + parts: await Promise.all( + sortBy(collection.parts, 'release_date').map((part) => + mapMovieResult( + part, + media?.find( + (req) => req.tmdbId === part.id && req.mediaType === MediaType.MOVIE + ) ) ) ), diff --git a/server/models/Release.ts b/server/models/Release.ts new file mode 100644 index 00000000..36824cac --- /dev/null +++ b/server/models/Release.ts @@ -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[]; +} diff --git a/server/models/Search.ts b/server/models/Search.ts index 2193bbe1..274eb30f 100644 --- a/server/models/Search.ts +++ b/server/models/Search.ts @@ -1,3 +1,15 @@ +import type { + mbArtist, + mbArtistType, + mbRecording, + mbRelease, + mbReleaseGroup, + mbReleaseGroupType, + mbWork, +} from '@server/api/musicbrainz/interfaces'; +import getPosterFromMB, { + cachedFanartFromMB, +} from '@server/api/musicbrainz/poster'; import type { TmdbCollectionResult, TmdbMovieDetails, @@ -7,10 +19,19 @@ import type { TmdbTvDetails, TmdbTvResult, } from '@server/api/themoviedb/interfaces'; -import { MediaType as MainMediaType } from '@server/constants/media'; import type Media from '@server/entity/Media'; -export type MediaType = 'tv' | 'movie' | 'person' | 'collection'; +export type MediaType = + | 'tv' + | 'movie' + | 'music' + | 'person' + | 'collection' + | 'release-group' + | 'release' + | 'recording' + | 'work' + | 'artist'; interface SearchResult { id: number; @@ -44,6 +65,14 @@ export interface TvResult extends SearchResult { firstAirDate: string; } +export interface MusicResult extends SearchResult { + mediaType: 'music'; + title: string; + originalTitle: string; + releaseDate: string; + mediaInfo?: Media; +} + export interface CollectionResult { id: number; mediaType: 'collection'; @@ -54,6 +83,7 @@ export interface CollectionResult { backdropPath?: string; overview: string; originalLanguage: string; + mediaInfo?: Media; } export interface PersonResult { @@ -64,14 +94,107 @@ export interface PersonResult { adult: boolean; mediaType: 'person'; knownFor: (MovieResult | TvResult)[]; + mediaInfo?: Media; +} + +export interface ReleaseGroupResult { + id: string; + mediaType: 'release-group'; + type: mbReleaseGroupType; + posterPath?: string; + title: string; + releases: ReleaseResult[]; + artist: ArtistResult[]; + tags: string[]; + mediaInfo?: Media; +} + +export interface ReleaseResult { + id: string; + mediaType: 'release'; + title: string; + artist: ArtistResult[]; + posterPath?: string; + date?: Date | string; + tracks?: RecordingResult[]; + tags: string[]; + mediaInfo?: Media; + releaseGroup?: ReleaseGroupResult; +} + +export interface RecordingResult { + id: string; + mediaType: 'recording'; + title: string; + artist: ArtistResult[]; + length: number; + firstReleased?: Date; + tags: string[]; + mediaInfo?: Media; +} + +export interface WorkResult { + id: string; + mediaType: 'work'; + title: string; + artist: ArtistResult[]; + tags: string[]; + mediaInfo?: Media; +} + +export interface ArtistResult { + id: string; + mediaType: 'artist'; + name: string; + type: mbArtistType; + releases: ReleaseResult[]; + gender?: string; + area?: string; + beginDate?: string; + endDate?: string; + tags: string[]; + mediaInfo?: Media; + posterPath?: string; + fanartPath?: string; } -export type Results = MovieResult | TvResult | PersonResult | CollectionResult; +export type Results = + | MovieResult + | TvResult + | MusicResult + | PersonResult + | CollectionResult + | ReleaseGroupResult + | ReleaseResult + | RecordingResult + | WorkResult + | ArtistResult; + +export type MbSearchMultiResponse = { + page: number; + total_pages: number; + total_results: number; + results: (mbRelease | mbArtist)[]; +}; + +export type MixedSearchResponse = { + page: number; + total_pages: number; + total_results: number; + results: ( + | mbArtist + | mbRelease + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + )[]; +}; -export const mapMovieResult = ( +export const mapMovieResult = async ( movieResult: TmdbMovieResult, media?: Media -): MovieResult => ({ +): Promise => ({ id: movieResult.id, mediaType: 'movie', adult: movieResult.adult, @@ -90,10 +213,10 @@ export const mapMovieResult = ( mediaInfo: media, }); -export const mapTvResult = ( +export const mapTvResult = async ( tvResult: TmdbTvResult, media?: Media -): TvResult => ({ +): Promise => ({ id: tvResult.id, firstAirDate: tvResult.first_air_date, genreIds: tvResult.genre_ids, @@ -112,9 +235,11 @@ export const mapTvResult = ( mediaInfo: media, }); -export const mapCollectionResult = ( - collectionResult: TmdbCollectionResult -): CollectionResult => ({ +export const mapCollectionResult = async ( + collectionResult: TmdbCollectionResult, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _media?: Media +): Promise => ({ id: collectionResult.id, mediaType: collectionResult.media_type || 'collection', adult: collectionResult.adult, @@ -126,22 +251,122 @@ export const mapCollectionResult = ( posterPath: collectionResult.poster_path, }); -export const mapPersonResult = ( - personResult: TmdbPersonResult -): PersonResult => ({ +export const mapPersonResult = async ( + personResult: TmdbPersonResult, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _media?: Media +): Promise => ({ id: personResult.id, name: personResult.name, popularity: personResult.popularity, adult: personResult.adult, mediaType: personResult.media_type, profilePath: personResult.profile_path, - knownFor: personResult.known_for.map((result) => { - if (result.media_type === 'movie') { - return mapMovieResult(result); - } + knownFor: await Promise.all( + personResult.known_for.map((result) => { + if (result.media_type === 'movie') { + return mapMovieResult(result); + } + + return mapTvResult(result); + }) + ), +}); - return mapTvResult(result); - }), +export const mapReleaseGroupResult = async ( + releaseGroupResult: mbReleaseGroup, + media?: Media +): Promise => { + 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 => ({ + 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 => ({ + 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 => ({ + 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 => ({ + id: work.id, + mediaType: work.media_type, + title: work.title, + artist: await Promise.all( + work.artist.map((artist) => mapArtistResult(artist)) + ), + tags: work.tags, }); export const mapSearchResults = ( @@ -150,34 +375,60 @@ export const mapSearchResults = ( | TmdbTvResult | TmdbPersonResult | TmdbCollectionResult + | mbArtist + | mbRecording + | mbRelease + | mbReleaseGroup + | mbWork )[], media?: Media[] -): Results[] => - results.map((result) => { - switch (result.media_type) { - case 'movie': - return mapMovieResult( - result, - media?.find( - (req) => - req.tmdbId === result.id && req.mediaType === MainMediaType.MOVIE - ) - ); - case 'tv': - return mapTvResult( - result, - media?.find( - (req) => - req.tmdbId === result.id && req.mediaType === MainMediaType.TV - ) - ); - case 'collection': - return mapCollectionResult(result); - default: - return mapPersonResult(result); +): Promise => { + const mediaLookup = new Map(); + if (media) { + media.forEach((item) => { + mediaLookup.set(item.tmdbId || item.mbId, item); + }); + } + + const mapFunctions = { + movie: mapMovieResult, + tv: mapTvResult, + collection: mapCollectionResult, + person: mapPersonResult, + 'release-group': mapReleaseGroupResult, + release: mapReleaseResult, + recording: mapRecordingResult, + work: mapWorkResult, + artist: mapArtistResult, + }; + + const transformResults = ( + result: + | TmdbMovieResult + | TmdbTvResult + | TmdbPersonResult + | TmdbCollectionResult + | mbArtist + | mbRecording + | mbRelease + | mbReleaseGroup + | mbWork + ) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapFunction: (result: any, media?: Media) => Promise = + mapFunctions[result.media_type]; + if (mapFunction) { + const mediaItem = mediaLookup.get(result.id); + return mapFunction(result, mediaItem); } - }); + }; + + const out = Promise.all( + results.map((result) => transformResults(result)).filter((result) => result) + ); + return out; +}; export const mapMovieDetailsToResult = ( movieDetails: TmdbMovieDetails ): TmdbMovieResult => ({ diff --git a/server/routes/collection.ts b/server/routes/collection.ts index 3cece5e3..f0eaf31a 100644 --- a/server/routes/collection.ts +++ b/server/routes/collection.ts @@ -19,7 +19,7 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => { collection.parts.map((part) => part.id) ); - return res.status(200).json(mapCollection(collection, media)); + return res.status(200).json(await mapCollection(collection, media)); } catch (e) { logger.debug('Something went wrong retrieving collection', { label: 'API', diff --git a/server/routes/discover.ts b/server/routes/discover.ts index b3530644..7f2d86ff 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,3 +1,4 @@ +import MusicBrainz from '@server/api/musicbrainz'; import PlexTvAPI from '@server/api/plextv'; import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; @@ -14,9 +15,11 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapProductionCompany } from '@server/models/Movie'; import { + mapArtistResult, mapCollectionResult, mapMovieResult, mapPersonResult, + mapReleaseResult, mapTvResult, } from '@server/models/Search'; import { mapNetwork } from '@server/models/Tv'; @@ -845,9 +848,68 @@ discoverRoutes.get, WatchlistResponse>( title: item.title, mediaType: item.type === 'show' ? 'tv' : 'movie', tmdbId: item.tmdbId, + musicBrainzId: item.musicBrainzId, })), }); } ); +discoverRoutes.get('/musics', async (req, res, next) => { + const mb = new MusicBrainz(); + try { + const query = QueryFilterOptions.parse(req.query); + const results = await mb.searchMulti({ + query: '', + tags: query.keywords ? decodeURIComponent(query.keywords).split(',') : [], + limit: 20, + page: Number(query.page), + }); + const mbIds = results.releaseResults + .map((result) => result.id) + .concat(results.artistResults.map((result) => result.id)); + const media = await Media.getRelatedMedia([], mbIds); + const resultsWithMedia = [ + ...(await Promise.all( + results.artistResults.map((result) => { + return mapArtistResult( + result, + media.find( + (med) => + med.mbId === result.id && + med.mediaType === MediaType.MUSIC && + med.secondaryType === 'artist' + ) + ); + }) + )), + ...(await Promise.all( + results.releaseResults.map((result) => { + return mapReleaseResult( + result, + media.find( + (med) => + med.mbId === result.id && + med.mediaType === MediaType.MUSIC && + med.secondaryType === 'release' + ) + ); + }) + )), + ]; + return res.status(200).json({ + page: query.page, + results: resultsWithMedia, + }); + } catch (e) { + logger.debug('Something went wrong retrieving release groups', { + label: 'API', + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve release groups.', + }); + } +}); + export default discoverRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index 4ce2507c..499f8ec7 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -28,6 +28,7 @@ import issueRoutes from './issue'; import issueCommentRoutes from './issueComment'; import mediaRoutes from './media'; import movieRoutes from './movie'; +import musicRoutes from './music'; import personRoutes from './person'; import requestRoutes from './request'; import searchRoutes from './search'; @@ -143,6 +144,7 @@ router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); router.use('/movie', isAuthenticated(), movieRoutes); +router.use('/music', isAuthenticated(), musicRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); router.use('/person', isAuthenticated(), personRoutes); diff --git a/server/routes/media.ts b/server/routes/media.ts index 8f93116c..981db13b 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,5 +1,5 @@ import TautulliAPI from '@server/api/tautulli'; -import { MediaStatus, MediaType } from '@server/constants/media'; +import { MediaStatus, MediaType, SecondaryType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; @@ -64,12 +64,47 @@ mediaRoutes.get('/', async (req, res, next) => { }; } + let typeFilter: FindOneOptions['where'] = undefined; + + switch (req.query.type) { + case 'movie': + typeFilter = { + mediaType: MediaType.MOVIE, + }; + break; + case 'tv': + typeFilter = { + mediaType: MediaType.TV, + }; + break; + case 'music': + typeFilter = { + mediaType: MediaType.MUSIC, + }; + break; + case 'artist': + typeFilter = { + mediaType: MediaType.MUSIC, + secondaryType: SecondaryType.ARTIST, + }; + break; + case 'release': + typeFilter = { + mediaType: MediaType.MUSIC, + secondaryType: SecondaryType.RELEASE, + }; + break; + } + try { const [media, mediaCount] = await mediaRepository.findAndCount({ order: sortFilter, where: statusFilter && { - status: statusFilter, - }, + status: statusFilter, + } && + typeFilter && { + ...typeFilter, + }, take: pageSize, skip, }); diff --git a/server/routes/music.ts b/server/routes/music.ts new file mode 100644 index 00000000..f9afbdbb --- /dev/null +++ b/server/routes/music.ts @@ -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: releaseMedia.mbId, + media_type: SecondaryType.RELEASE, + title: 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; diff --git a/server/routes/request.ts b/server/routes/request.ts index 83c05b48..58f27fa6 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -15,8 +15,10 @@ import { import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import type { - MediaRequestBody, + MusicRequestBody, RequestResultsResponse, + TvRequestBody, + VideoRequestBody, } from '@server/interfaces/api/requestInterfaces'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; @@ -158,38 +160,41 @@ requestRoutes.get, RequestResultsResponse>( } ); -requestRoutes.post( - '/', - async (req, res, next) => { - try { - if (!req.user) { +requestRoutes.post< + never, + MediaRequest, + MusicRequestBody | VideoRequestBody | TvRequestBody +>('/', async (req, res, next) => { + try { + if (!req.user) { + return next({ + status: 401, + message: 'You must be logged in to request media.', + }); + } + const request = await MediaRequest.request(req.body, req.user); + return res.status(201).json(request); + } catch (error) { + if (!(error instanceof Error)) { + return; + } + + switch (error.constructor) { + case RequestPermissionError: + case QuotaRestrictedError: + return next({ status: 403, message: error.message }); + case DuplicateMediaRequestError: + return next({ status: 409, message: error.message }); + case NoSeasonsAvailableError: + return next({ status: 202, message: error.message }); + default: return next({ - status: 401, - message: 'You must be logged in to request media.', + status: 500, + message: error.message, }); - } - const request = await MediaRequest.request(req.body, req.user); - - return res.status(201).json(request); - } catch (error) { - if (!(error instanceof Error)) { - return; - } - - switch (error.constructor) { - case RequestPermissionError: - case QuotaRestrictedError: - return next({ status: 403, message: error.message }); - case DuplicateMediaRequestError: - return next({ status: 409, message: error.message }); - case NoSeasonsAvailableError: - return next({ status: 202, message: error.message }); - default: - return next({ status: 500, message: error.message }); - } } } -); +}); requestRoutes.get('/count', async (_req, res, next) => { const requestRepository = getRepository(MediaRequest); diff --git a/server/routes/search.ts b/server/routes/search.ts index 55a8aa6e..3f595413 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -1,8 +1,13 @@ +import MusicBrainz from '@server/api/musicbrainz'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces'; import Media from '@server/entity/Media'; import { findSearchProvider } from '@server/lib/search'; import logger from '@server/logger'; +import type { + MbSearchMultiResponse, + MixedSearchResponse, +} from '@server/models/Search'; import { mapSearchResults } from '@server/models/Search'; import { Router } from 'express'; @@ -11,7 +16,10 @@ const searchRoutes = Router(); searchRoutes.get('/', async (req, res, next) => { const queryString = req.query.query as string; const searchProvider = findSearchProvider(queryString.toLowerCase()); - let results: TmdbSearchMultiResponse; + let results: + | MixedSearchResponse + | TmdbSearchMultiResponse + | MbSearchMultiResponse; try { if (searchProvider) { @@ -25,23 +33,40 @@ searchRoutes.get('/', async (req, res, next) => { }); } else { const tmdb = new TheMovieDb(); - results = await tmdb.searchMulti({ query: queryString, page: Number(req.query.page), language: (req.query.language as string) ?? req.locale, }); + const mb = new MusicBrainz(); + const mbResults = await mb.searchMulti({ + query: queryString, + page: Number(req.query.page), + }); + const releaseResults = mbResults.releaseResults; + const artistResults = mbResults.artistResults; + results = { + ...results, + results: [...results.results, ...artistResults, ...releaseResults], + }; } + const mbIds = results.results + .filter((result) => typeof result.id === 'string') + .map((result) => result.id as string); + + const tmdbIds = results.results + .filter((result) => typeof result.id === 'number') + .map((result) => result.id as number); - const media = await Media.getRelatedMedia( - results.results.map((result) => result.id) - ); + const media = await Media.getRelatedMedia(tmdbIds, mbIds); + + const mappedResults = await mapSearchResults(results.results, media); return res.status(200).json({ page: results.page, totalPages: results.total_pages, totalResults: results.total_results, - results: mapSearchResults(results.results, media), + results: mappedResults, }); } catch (e) { logger.debug('Something went wrong retrieving search results', { @@ -57,15 +82,22 @@ searchRoutes.get('/', async (req, res, next) => { }); searchRoutes.get('/keyword', async (req, res, next) => { - const tmdb = new TheMovieDb(); - try { - const results = await tmdb.searchKeyword({ - query: req.query.query as string, - page: Number(req.query.page), - }); + if (!req.query.type || req.query.type !== 'music') { + const tmdb = new TheMovieDb(); + const results = await tmdb.searchKeyword({ + query: req.query.query as string, + page: Number(req.query.page), + }); - return res.status(200).json(results); + return res.status(200).json(results); + } else { + const mb = new MusicBrainz(); + + const results = await mb.searchTags(req.query.query as string); + + return res.status(200).json(results); + } } catch (e) { logger.debug('Something went wrong retrieving keyword search results', { label: 'API', diff --git a/server/routes/service.ts b/server/routes/service.ts index 083e1eb5..05957a0e 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -1,3 +1,4 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; @@ -209,4 +210,69 @@ serviceRoutes.get<{ tmdbId: string }>( } ); +serviceRoutes.get('/lidarr', async (req, res) => { + const settings = getSettings(); + const filteredLidarrServers: ServiceCommonServer[] = settings.lidarr.map( + (lidarr) => ({ + id: lidarr.id, + name: lidarr.name, + isDefault: lidarr.isDefault, + activeDirectory: lidarr.activeDirectory, + activeProfileId: lidarr.activeProfileId, + activeTags: lidarr.tags ?? [], + }) + ); + + return res.status(200).json(filteredLidarrServers); +}); + +serviceRoutes.get<{ lidarrId: string }>( + '/lidarr/:lidarrId', + async (req, res, next) => { + const settings = getSettings(); + + const lidarrSettings = settings.lidarr.find( + (lidarr) => lidarr.id === Number(req.params.lidarrId) + ); + + if (!lidarrSettings) { + return next({ + status: 404, + message: 'Lidarr server with provided ID does not exist.', + }); + } + + const lidarr = new LidarrAPI({ + apiKey: lidarrSettings.apiKey, + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), + }); + + const profiles = await lidarr.getProfiles(); + const rootFolders = await lidarr.getRootFolders(); + const tags = await lidarr.getTags(); + + return res.status(200).json({ + server: { + id: lidarrSettings.id, + name: lidarrSettings.name, + isDefault: lidarrSettings.isDefault, + activeDirectory: lidarrSettings.activeDirectory, + activeProfileId: lidarrSettings.activeProfileId, + activeTags: lidarrSettings.tags, + }, + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })), + rootFolders: rootFolders.map((folder) => ({ + id: folder.id, + freeSpace: folder.freeSpace, + path: folder.path, + totalSpace: folder.totalSpace, + })), + tags, + } as ServiceCommonServerWithDetails); + } +); + export default serviceRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 98fe0f77..4883aeed 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -35,12 +35,14 @@ import { URL } from 'url'; import notificationRoutes from './notifications'; import radarrRoutes from './radarr'; import sonarrRoutes from './sonarr'; +import lidarrRoutes from './lidarr'; const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); settingsRoutes.use('/radarr', radarrRoutes); settingsRoutes.use('/sonarr', sonarrRoutes); +settingsRoutes.use('/lidarr', lidarrRoutes) settingsRoutes.use('/discover', discoverSettingRoutes); const filteredMainSettings = ( diff --git a/server/routes/settings/lidarr.ts b/server/routes/settings/lidarr.ts new file mode 100644 index 00000000..e9e1f81d --- /dev/null +++ b/server/routes/settings/lidarr.ts @@ -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, + 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; diff --git a/server/subscriber/IssueCommentSubscriber.ts b/server/subscriber/IssueCommentSubscriber.ts index 71db981d..42eac029 100644 --- a/server/subscriber/IssueCommentSubscriber.ts +++ b/server/subscriber/IssueCommentSubscriber.ts @@ -42,14 +42,14 @@ export class IssueCommentSubscriber }); if (media.mediaType === MediaType.MOVIE) { - const movie = await tmdb.getMovie({ movieId: media.tmdbId }); + const movie = await tmdb.getMovie({ movieId: Number(media.tmdbId) }); title = `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; } else { - const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvshow = await tmdb.getTvShow({ tvId: Number(media.tmdbId) }); title = `${tvshow.name}${ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' diff --git a/server/subscriber/IssueSubscriber.ts b/server/subscriber/IssueSubscriber.ts index d54523cf..9830152a 100644 --- a/server/subscriber/IssueSubscriber.ts +++ b/server/subscriber/IssueSubscriber.ts @@ -26,14 +26,18 @@ export class IssueSubscriber implements EntitySubscriberInterface { try { if (entity.media.mediaType === MediaType.MOVIE) { - const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); + const movie = await tmdb.getMovie({ + movieId: Number(entity.media.tmdbId), + }); title = `${movie.title}${ movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' }`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; } else { - const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); + const tvshow = await tmdb.getTvShow({ + tvId: Number(entity.media.tmdbId), + }); title = `${tvshow.name}${ tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : '' diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index eecfe6f3..8b36bcc6 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -41,7 +41,9 @@ export class MediaSubscriber implements EntitySubscriberInterface { const tmdb = new TheMovieDb(); try { - const movie = await tmdb.getMovie({ movieId: entity.tmdbId }); + const movie = await tmdb.getMovie({ + movieId: Number(entity.tmdbId), + }); relatedRequests.forEach((request) => { notificationManager.sendNotification( @@ -136,7 +138,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { ); try { - const tv = await tmdb.getTvShow({ tvId: entity.tmdbId }); + const tv = await tmdb.getTvShow({ tvId: Number(entity.tmdbId) }); notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { event: `${is4k ? '4K ' : ''}Series Request Now Available`, subject: `${tv.name}${ diff --git a/server/tsconfig.json b/server/tsconfig.json index ec4b9004..8604092c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -4,11 +4,14 @@ "target": "ES2020", "module": "commonjs", "outDir": "../dist", + "strict": true, "noEmit": false, "baseUrl": ".", "paths": { "@server/*": ["*"] - } + }, + "typeRoots": ["../node_modules/*", "./types"], + }, - "include": ["**/*.ts"] + "include": ["**/*.ts"], } diff --git a/server/types/nodebrainz.d.ts b/server/types/nodebrainz.d.ts new file mode 100644 index 00000000..b2434aa3 --- /dev/null +++ b/server/types/nodebrainz.d.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; + recording( + recordingId: string, + { inc }: { inc: string }, + callback: (err: Error, data: Recording) => void + ): Promise; + release( + releaseId: string, + { inc }: { inc: string }, + callback: (err: Error, data: Release) => void + ): Promise; + releaseGroup( + releaseGroupId: string, + { inc }: { inc: string }, + callback: (err: Error, data: Group) => void + ): Promise; + work( + workId: string, + { inc }: { inc: string }, + callback: (err: Error, data: Work) => void + ): Promise; + search( + type: string, + search: SearchOptions | { tag: string }, + callback: ( + err: Error, + data: + | ArtistSearchResponse + | ReleaseSearchResponse + | RecordingSearchResponse + | ReleaseGroupSearchResponse + | WorkSearchResponse + | TagSearchResponse + ) => void + ): Promise; + 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; + luceneSearch( + type: string, + search: luceneSearchOptions, + callback: ( + err: Error, + data: + | ArtistSearchResponse + | ReleaseSearchResponse + | RecordingSearchResponse + | ReleaseGroupSearchResponse + | WorkSearchResponse + | TagSearchResponse + ) => void + ): Promise; + } +} diff --git a/src/assets/services/lidarr.svg b/src/assets/services/lidarr.svg new file mode 100644 index 00000000..e2e83b38 --- /dev/null +++ b/src/assets/services/lidarr.svg @@ -0,0 +1 @@ + diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index 6dfb8ee7..8f88fcd1 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -1,6 +1,7 @@ import useSettings from '@app/hooks/useSettings'; import type { ImageLoader, ImageProps } from 'next/image'; import Image from 'next/image'; +import { useState } from 'react'; const imageLoader: ImageLoader = ({ src }) => src; @@ -10,18 +11,29 @@ const imageLoader: ImageLoader = ({ src }) => src; **/ const CachedImage = ({ src, ...props }: ImageProps) => { const { currentSettings } = useSettings(); + const [imageUrl, setImageUrl] = useState(src as string); - let imageUrl = src; + const handleError = () => { + setImageUrl(currentSettings?.fallbackImage); + }; if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { const parsedUrl = new URL(imageUrl); if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) { - imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); + setImageUrl(imageUrl.replace('https://image.tmdb.org', '/imageproxy')); } } - return ; + return ( + + ); }; export default CachedImage; diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index b4608686..ab81ef2c 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -5,29 +5,50 @@ import useVerticalScroll from '@app/hooks/useVerticalScroll'; import globalMessages from '@app/i18n/globalMessages'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { + ArtistResult, CollectionResult, MovieResult, + MusicResult, PersonResult, + RecordingResult, + ReleaseGroupResult, + ReleaseResult, TvResult, + WorkResult, } from '@server/models/Search'; import { useIntl } from 'react-intl'; type ListViewProps = { - items?: (TvResult | MovieResult | PersonResult | CollectionResult)[]; + items?: ( + | TvResult + | MovieResult + | PersonResult + | CollectionResult + | MusicResult + | ArtistResult + | ReleaseResult + | ReleaseGroupResult + | WorkResult + | RecordingResult + )[]; + jsxItems?: React.ReactNode[]; plexItems?: WatchlistItem[]; isEmpty?: boolean; isLoading?: boolean; isReachingEnd?: boolean; onScrollBottom: () => void; + force_big?: boolean; }; const ListView = ({ items, + jsxItems, isEmpty, isLoading, onScrollBottom, isReachingEnd, plexItems, + force_big = false, }: ListViewProps) => { const intl = useIntl(); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); @@ -43,9 +64,9 @@ const ListView = ({ return (
  • @@ -113,10 +134,66 @@ const ListView = ({ /> ); break; + case 'artist': + titleCard = ( + + ); + break; + case 'release': + titleCard = ( + + ); + break; + case 'release-group': + titleCard = ( + + ); + break; + case 'work': + titleCard = ( + + ); + break; + case 'recording': + titleCard = ( + + ); + break; } return
  • {titleCard}
  • ; })} + {jsxItems} {isLoading && !isReachingEnd && [...Array(20)].map((_item, i) => ( diff --git a/src/components/Discover/DiscoverMusics/index.tsx b/src/components/Discover/DiscoverMusics/index.tsx new file mode 100644 index 00000000..cc6ef565 --- /dev/null +++ b/src/components/Discover/DiscoverMusics/index.tsx @@ -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( + '/api/v1/discover/musics', + preparedFilters + ); + const [showFilters, setShowFilters] = useState(false); + + if (error || !titles) { + return ; + } + + const title = intl.formatMessage(messages.discovermusics); + + return ( + <> + +
    +
    {title}
    +
    + setShowFilters(false)} + show={showFilters} + /> +
    + +
    +
    +
    + {Object.keys(preparedFilters).length === 0 && ( + + )} +
    +
    + {intl.formatMessage(messages.discovermoremusics)} +
    +
    + 0) + } + isReachingEnd={isReachingEnd} + onScrollBottom={fetchMore} + /> + + ); +}; + +export default DiscoverMusics; diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 83d5a2e4..8f7cbfde 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -44,7 +44,7 @@ const messages = defineMessages({ type FilterSlideoverProps = { show: boolean; onClose: () => void; - type: 'movie' | 'tv'; + type: 'movie' | 'tv' | 'music'; currentFilters: FilterOptions; }; @@ -74,57 +74,59 @@ const FilterSlideover = ({ onClose={() => onClose()} >
    -
    -
    - {intl.formatMessage( - type === 'movie' ? messages.releaseDate : messages.firstAirDate - )} -
    -
    -
    -
    {intl.formatMessage(messages.from)}
    - { - updateQueryParams( - dateGte, - value?.startDate ? (value.startDate as string) : undefined - ); - }} - inputName="fromdate" - useRange={false} - asSingle - containerClassName="datepicker-wrapper" - inputClassName="pr-1 sm:pr-4 text-base leading-5" - /> + {type !== 'music' && ( +
    +
    + {intl.formatMessage( + type === 'movie' ? messages.releaseDate : messages.firstAirDate + )}
    -
    -
    {intl.formatMessage(messages.to)}
    - { - 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" - /> +
    +
    +
    {intl.formatMessage(messages.from)}
    + { + 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" + /> +
    +
    +
    {intl.formatMessage(messages.to)}
    + { + 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" + /> +
    -
    + )} {type === 'movie' && ( <> @@ -138,179 +140,199 @@ const FilterSlideover = ({ /> )} - - {intl.formatMessage(messages.genres)} - - { - updateQueryParams('genre', value?.map((v) => v.value).join(',')); - }} - /> + {type !== 'music' && ( + <> + + {intl.formatMessage(messages.genres)} + + { + updateQueryParams( + 'genre', + value?.map((v) => v.value).join(',') + ); + }} + /> + + )} {intl.formatMessage(messages.keywords)} { - updateQueryParams('keywords', value?.map((v) => v.value).join(',')); - }} - /> - - {intl.formatMessage(messages.originalLanguage)} - - { - updateQueryParams('language', value); - }} - /> - - {intl.formatMessage(messages.runtime)} - -
    - { - 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, - })} - /> -
    - - {intl.formatMessage(messages.tmdbuserscore)} - -
    - { - 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, - })} - /> -
    - - {intl.formatMessage(messages.tmdbuservotecount)} - -
    - { - 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, - })} - /> -
    - - {intl.formatMessage(messages.streamingservices)} - - Number(v)) ?? - [] - } - onChange={(region, providers) => { - if (providers.length) { - batchUpdateQueryParams({ - watchRegion: region, - watchProviders: providers.join('|'), - }); - } else { - batchUpdateQueryParams({ - watchRegion: undefined, - watchProviders: undefined, - }); - } + onChange={(value) => { + updateQueryParams( + 'keywords', + type === 'music' + ? encodeURIComponent(value?.map((v) => v.label).join(',') ?? '') + : encodeURIComponent(value?.map((v) => v.value).join(',') ?? '') + ); }} /> + {type !== 'music' && ( + <> + + {intl.formatMessage(messages.originalLanguage)} + + { + updateQueryParams('language', value); + }} + /> + + {intl.formatMessage(messages.runtime)} + +
    + { + 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, + })} + /> +
    + + {intl.formatMessage(messages.tmdbuserscore)} + +
    + { + 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, + })} + /> +
    + + {intl.formatMessage(messages.tmdbuservotecount)} + +
    + { + 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, + })} + /> +
    + + {intl.formatMessage(messages.streamingservices)} + + {type in ['movie', 'tv'] ? ( + Number(v)) ?? [] + } + onChange={(region, providers) => { + if (providers.length) { + batchUpdateQueryParams({ + watchRegion: region, + watchProviders: providers.join('|'), + }); + } else { + batchUpdateQueryParams({ + watchRegion: undefined, + watchProviders: undefined, + }); + } + }} + /> + ) : null} + + )}
    + + )} +
    +
    +
    + {tags.length > 0 && ( +
    + {tags.map((keyword, idx) => ( + + + {keyword} + + + ))} +
    + )} + {availableReleases.length > 0 && ( + <> +
    +
    + {intl.formatMessage(messages.available)} +
    +
    + { + return ( + + ); + })} + /> + + )} +
    +
    +
    +
    + {intl.formatMessage(messages.releases)} +
    +
    + { + return ( + + ); + })} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onScrollBottom={() => {}} + /> +
    +
    + ); +}; + +export default ArtistDetails; diff --git a/src/components/MusicDetails/ReleaseDetails.tsx b/src/components/MusicDetails/ReleaseDetails.tsx new file mode 100644 index 00000000..aed9f9bb --- /dev/null +++ b/src/components/MusicDetails/ReleaseDetails.tsx @@ -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( + `/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: , + }); + } + + 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 ; + } + + const title = data.title; + + const tags: string[] = data.tags ?? []; + + return ( +
    +
    +
    +
    + + setShowIssueModal(false)} + show={showIssueModal} + mediaType="music" + mbId={data.id} + secondaryType={SecondaryType.RELEASE} + /> +
    +
    + +
    +
    +
    + 0} + tmdbId={data.mediaInfo?.tmdbId} + mediaType="music" + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl} + secondaryType={SecondaryType.RELEASE} + /> +
    +

    + {title}{' '} + {mainDateDisplay !== '' && ( + ({mainDateDisplay}) + )} +

    + + By  + {data.artist.map((artist, index) => ( +
    + {' '} + + {artist.name} + + {index < data.artist.length - 1 ? ', ' : ''} +
    + ))} +
    +
    +
    + + {}} + /> + {data.mediaInfo?.status === MediaStatus.AVAILABLE && + hasPermission( + [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], + { + type: 'or', + } + ) && ( + + + + )} +
    +
    +
    + {tags.length > 0 && ( +
    + {tags.map((keyword, idx) => ( + + + {keyword} + + + ))} +
    + )} + + {tracks.map((track, index) => ( +
    +
    +
    + {track.title} +
    +
    + + {lengthToTime(track.length)} + +
    +
    + + {track.artist.map((artist, index) => ( + {artist.name} + ))} + +
    +
    +
    + ))} +
    +
    +
    + ); +}; + +export default ReleaseDetails; diff --git a/src/components/MusicDetails/ReleaseGroupDetails.tsx b/src/components/MusicDetails/ReleaseGroupDetails.tsx new file mode 100644 index 00000000..bda45aab --- /dev/null +++ b/src/components/MusicDetails/ReleaseGroupDetails.tsx @@ -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: , + }); + } + + const releases = data.releases; + + if (!data) { + return ; + } + + /* + 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 ( +
    +
    +
    +
    + + setShowIssueModal(false)} + show={showIssueModal} + mediaType="music" + mbId={data.id} + secondaryType={SecondaryType.RELEASE_GROUP} + /> +
    +
    + +
    +
    +
    + 0} + tmdbId={data.mediaInfo?.tmdbId} + mediaType="music" + plexUrl={plexUrl} + serviceUrl={data.mediaInfo?.serviceUrl} + secondaryType={SecondaryType.RELEASE_GROUP} + /> +
    +

    {title}

    +

    {data.type}

    + + {tags.map((t, k) => ( + {t} + ))} + +
    +
    + + {}} + /> + {data.mediaInfo?.status === MediaStatus.AVAILABLE && + hasPermission( + [Permission.CREATE_ISSUES, Permission.MANAGE_ISSUES], + { + type: 'or', + } + ) && ( + + + + )} +
    +
    + {releases?.length > 0 && ( + <> +
    +
    + {intl.formatMessage(messages.releases)} +
    +
    + ( + + ))} + // eslint-disable-next-line @typescript-eslint/no-empty-function + onScrollBottom={() => {}} + /> + + )} +
    + ); +}; + +export default ReleaseGroupDetails; diff --git a/src/components/MusicDetails/index.tsx b/src/components/MusicDetails/index.tsx new file mode 100644 index 00000000..04a07c8a --- /dev/null +++ b/src/components/MusicDetails/index.tsx @@ -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 ; + case SecondaryType.RELEASE_GROUP: + return ( + + ); + case SecondaryType.RELEASE: + return ; + default: + return ; + } +}; + +export default MusicDetails; diff --git a/src/components/QuotaSelector/index.tsx b/src/components/QuotaSelector/index.tsx index 7240dbc2..b092d563 100644 --- a/src/components/QuotaSelector/index.tsx +++ b/src/components/QuotaSelector/index.tsx @@ -13,7 +13,7 @@ const messages = defineMessages({ }); interface QuotaSelectorProps { - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'music'; defaultDays?: number; defaultLimit?: number; dayOverride?: number; diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 56e91810..2f8568c0 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -9,6 +9,7 @@ import { InformationCircleIcon, XMarkIcon, } from '@heroicons/react/24/solid'; +import type { SecondaryType } from '@server/constants/media'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type Media from '@server/entity/Media'; import type { MediaRequest } from '@server/entity/MediaRequest'; @@ -43,10 +44,12 @@ interface ButtonOption { } interface RequestButtonProps { - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'music'; onUpdate: () => void; - tmdbId: number; + tmdbId?: number; media?: Media; + mbId?: string; + secondaryType?: SecondaryType; isShowComplete?: boolean; is4kShowComplete?: boolean; } @@ -56,6 +59,8 @@ const RequestButton = ({ onUpdate, media, mediaType, + mbId, + secondaryType, isShowComplete = false, is4kShowComplete = false, }: RequestButtonProps) => { @@ -270,6 +275,8 @@ const RequestButton = ({ Permission.REQUEST, mediaType === 'movie' ? Permission.REQUEST_MOVIE + : mediaType === 'music' + ? Permission.REQUEST_MUSIC : Permission.REQUEST_TV, ], { type: 'or' } @@ -361,6 +368,8 @@ const RequestButton = ({ <> setShowRequestModal(false)} /> - { - onUpdate(); - setShowRequest4kModal(false); - }} - onCancel={() => setShowRequest4kModal(false)} - /> + {mediaType !== 'music' && ( + { + onUpdate(); + setShowRequest4kModal(false); + }} + onCancel={() => setShowRequest4kModal(false)} + /> + )} diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 44abd555..7ceaaae3 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -19,6 +19,7 @@ import { import { MediaRequestStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MovieDetails } from '@server/models/Movie'; +import type { ArtistResult, ReleaseResult } from '@server/models/Search'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -42,8 +43,32 @@ const messages = defineMessages({ unknowntitle: 'Unknown Title', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + movie: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): movie is MovieDetails => { + // Check if the object doesn't have a mediaType property and does have a title property then it's a movie + return !('mediaType' in movie) && 'title' in movie; +}; + +const isTv = ( + tv: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): tv is TvDetails => { + // Check if the object doesn't have a mediaType property and does have a name property then it's a tv show + return !('mediaType' in tv) && 'name' in tv; +}; + +const isRelease = ( + release: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): release is ReleaseResult => { + // Check if the object has a mediaType property and does have a title property then it's a release + return 'mediaType' in release && 'title' in release; +}; + +const isArtist = ( + artist: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): artist is ArtistResult => { + // Check if the object has a mediaType property and does have a name property then it's an artist + return 'mediaType' in artist && 'name' in artist; }; const RequestCardPlaceholder = () => { @@ -205,7 +230,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { interface RequestCardProps { request: MediaRequest; - onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void; + onTitleData?: ( + requestId: number, + title: MovieDetails | TvDetails | ReleaseResult | ArtistResult + ) => void; } const RequestCard = ({ request, onTitleData }: RequestCardProps) => { @@ -220,11 +248,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` - : `/api/v1/tv/${request.media.tmdbId}`; + : request.type === 'tv' + ? `/api/v1/tv/${request.media.tmdbId}` + : `/api/v1/music/${request.media.secondaryType}/${request.media.mbId}`; - const { data: title, error } = useSWR( - inView ? `${url}` : null - ); + const { data: title, error } = useSWR< + MovieDetails | TvDetails | ReleaseResult | ArtistResult + >(inView ? `${url}` : null); const { data: requestData, error: requestError, @@ -319,42 +349,69 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { className="relative flex w-72 overflow-hidden rounded-xl bg-gray-800 bg-cover bg-center p-4 text-gray-400 shadow ring-1 ring-gray-700 sm:w-96" data-testid="request-card" > - {title.backdropPath && ( -
    - -
    -
    - )} + {isMovie(title) || isTv(title) + ? title.backdropPath && ( +
    + +
    +
    + ) + : isArtist(title) && + title.fanartPath && ( +
    + +
    +
    + )}
    - {(isMovie(title) ? title.releaseDate : title.firstAirDate)?.slice( - 0, - 4 - )} + {(isMovie(title) + ? title.releaseDate + : isTv(title) + ? title.firstAirDate + : isRelease(title) + ? (title.date as string) + : isArtist(title) + ? title.beginDate + : '' + )?.slice(0, 4)}
    - {isMovie(title) ? title.title : title.name} + {isMovie(title) || isRelease(title) ? title.title : title.name} {hasPermission( @@ -376,7 +433,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
    )} - {!isMovie(title) && request.seasons.length > 0 && ( + {isTv(title) && request.seasons.length > 0 && (
    {intl.formatMessage(messages.seasons, { @@ -421,7 +478,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' ] } - title={isMovie(title) ? title.title : title.name} + title={ + isMovie(title) || isRelease(title) ? title.title : title.name + } inProgress={ ( requestData.media[ @@ -570,19 +629,25 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { href={ request.type === 'movie' ? `/movie/${requestData.media.tmdbId}` - : `/tv/${requestData.media.tmdbId}` + : request.type === 'tv' + ? `/tv/${requestData.media.tmdbId}` + : `/music/${requestData.media.secondaryType}/${requestData.media.mbId}` } > diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index a42483ab..caba283d 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -15,9 +15,11 @@ import { TrashIcon, XMarkIcon, } from '@heroicons/react/24/solid'; +import type { SecondaryType } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import type { MovieDetails } from '@server/models/Movie'; +import type { ArtistResult, ReleaseResult } from '@server/models/Search'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; import Link from 'next/link'; @@ -40,11 +42,29 @@ const messages = defineMessages({ cancelRequest: 'Cancel Request', tmdbid: 'TMDB ID', tvdbid: 'TheTVDB ID', + mbId: 'MusicBrainz ID', unknowntitle: 'Unknown Title', }); -const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { - return (movie as MovieDetails).title !== undefined; +const isMovie = ( + movie: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): movie is MovieDetails => { + // Check if the object doesn't have a mediaType property and does have a title property then it's a movie + return !('mediaType' in movie) && 'title' in movie; +}; + +const isTv = ( + tv: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): tv is TvDetails => { + // Check if the object doesn't have a mediaType property and does have a name property then it's a tv show + return !('mediaType' in tv) && 'name' in tv; +}; + +const isRelease = ( + release: MovieDetails | TvDetails | ReleaseResult | ArtistResult +): release is ReleaseResult => { + // Check if the object has a mediaType property and does have a title property then it's a release + return 'mediaType' in release && 'title' in release; }; interface RequestItemErrorProps { @@ -81,7 +101,9 @@ const RequestItemError = ({ requestData?.type ? requestData?.type === 'movie' ? globalMessages.movie - : globalMessages.tvshow + : requestData?.type === 'tv' + ? globalMessages.tvshow + : globalMessages.music : globalMessages.request ), })} @@ -90,10 +112,14 @@ const RequestItemError = ({ <>
    - {intl.formatMessage(messages.tmdbid)} + {requestData?.type === 'movie' || requestData?.type === 'tv' + ? intl.formatMessage(messages.tmdbid) + : intl.formatMessage(messages.mbId)} - {requestData.media.tmdbId} + {requestData?.type === 'movie' || requestData?.type === 'tv' + ? requestData?.media.tmdbId + : requestData?.media.mbId}
    {requestData.media.tvdbId && ( @@ -286,10 +312,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` - : `/api/v1/tv/${request.media.tmdbId}`; - const { data: title, error } = useSWR( - inView ? url : null - ); + : request.type === 'tv' + ? `/api/v1/tv/${request.media.tmdbId}` + : `/api/v1/music/${request.secondaryType}/${request.media.mbId}`; + const { data: title, error } = useSWR< + MovieDetails | TvDetails | ReleaseResult | ArtistResult + >(inView ? url : null); const { data: requestData, mutate: revalidate } = useSWR( `/api/v1/request/${request.id}`, { @@ -303,7 +331,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { ), } ); - const [isRetrying, setRetrying] = useState(false); const modifyRequest = async (type: 'approve' | 'decline') => { @@ -374,9 +401,10 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { revalidateList(); setShowEditModal(false); }} + secondaryType={request.secondaryType as SecondaryType} />
    - {title.backdropPath && ( + {(isMovie(title) || isTv(title)) && title.backdropPath && (
    { href={ requestData.type === 'movie' ? `/movie/${requestData.media.tmdbId}` - : `/tv/${requestData.media.tmdbId}` + : requestData.type === 'tv' + ? `/tv/${requestData.media.tmdbId}` + : `/music/${requestData.secondaryType}/${requestData.media.mbId}` } > {
    {(isMovie(title) ? title.releaseDate - : title.firstAirDate + : isTv(title) + ? title.firstAirDate + : isRelease(title) + ? new Date(title.date as string).toDateString() + : title.beginDate )?.slice(0, 4)}
    - {isMovie(title) ? title.title : title.name} + {isMovie(title) || isRelease(title) + ? title.title + : title.name} - {!isMovie(title) && request.seasons.length > 0 && ( + {isTv(title) && request.seasons.length > 0 && (
    {intl.formatMessage(messages.seasons, { @@ -484,7 +523,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' ] } - title={isMovie(title) ? title.title : title.name} + title={ + isMovie(title) || isRelease(title) + ? title.title + : title.name + } inProgress={ ( requestData.media[ diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index 4f5bb9ac..38f0d920 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -6,6 +6,7 @@ import globalMessages from '@app/i18n/globalMessages'; import { formatBytes } from '@app/utils/numberHelpers'; import { Listbox, Transition } from '@headlessui/react'; import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/solid'; +import type { SecondaryType } from '@server/constants/media'; import type { ServiceCommonServer, ServiceCommonServerWithDetails, @@ -42,14 +43,15 @@ export type RequestOverrides = { server?: number; profile?: number; folder?: string; - tags?: number[]; + tags?: number[] | string[]; language?: number; user?: User; }; interface AdvancedRequesterProps { - type: 'movie' | 'tv'; - is4k: boolean; + type: 'movie' | 'tv' | 'music'; + secondaryType?: SecondaryType; + is4k?: boolean; isAnime?: boolean; defaultOverrides?: RequestOverrides; requestUser?: User; @@ -67,7 +69,9 @@ const AdvancedRequester = ({ const intl = useIntl(); const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const { data, error } = useSWR( - `/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`, + `/api/v1/service/${ + type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr' + }`, { refreshInterval: 0, refreshWhenHidden: false, @@ -91,7 +95,7 @@ const AdvancedRequester = ({ defaultOverrides?.language ?? -1 ); - const [selectedTags, setSelectedTags] = useState( + const [selectedTags, setSelectedTags] = useState( defaultOverrides?.tags ?? [] ); @@ -99,7 +103,7 @@ const AdvancedRequester = ({ useSWR( selectedServer !== null ? `/api/v1/service/${ - type === 'movie' ? 'radarr' : 'sonarr' + type === 'movie' ? 'radarr' : type === 'music' ? 'lidarr' : 'sonarr' }/${selectedServer}` : null, { @@ -133,7 +137,9 @@ const AdvancedRequester = ({ Permission.REQUEST, type === 'movie' ? Permission.REQUEST_MOVIE - : Permission.REQUEST_TV, + : type === 'tv' + ? Permission.REQUEST_TV + : Permission.REQUEST_MUSIC, ], user.permissions, { type: 'or' } diff --git a/src/components/RequestModal/ArtistRequestModal.tsx b/src/components/RequestModal/ArtistRequestModal.tsx new file mode 100644 index 00000000..111a2c1e --- /dev/null +++ b/src/components/RequestModal/ArtistRequestModal.tsx @@ -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: '{title} requested successfully!', + requestCancel: 'Request for {title} 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 {title} edited successfully!', + requestApproved: 'Request for {title} approved!', + requesterror: 'Something went wrong while submitting the request.', + pendingapproval: 'Your request is pending approval.', +}); + +interface RequestModalProps extends React.HTMLAttributes { + 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(null); + const { addToast } = useToasts(); + const { data, error } = useSWR(`/api/v1/music/artist/${mbId}`, { + revalidateOnMount: true, + }); + const intl = useIntl(); + const { user, hasPermission } = useUser(); + const { data: quota } = useSWR( + 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('/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( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { 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( + `/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( + + {intl.formatMessage(messages.requestCancel, { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { 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( + + {intl.formatMessage( + alsoApproveRequest + ? messages.requestApproved + : messages.requestedited, + { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + } + )} + , + { + appearance: 'success', + autoDismiss: true, + } + ); + + if (onComplete) { + onComplete(MediaStatus.PENDING); + } + } catch (e) { + addToast({intl.formatMessage(messages.errorediting)}, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + } + }; + + if (editRequest) { + const isOwner = editRequest.requestedBy.id === user?.id; + + return ( + + 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)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} + + ); + } + + const hasAutoApprove = hasPermission( + [ + Permission.MANAGE_REQUESTS, + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + ], + { type: 'or' } + ); + + return ( + + {hasAutoApprove && !quota?.music.restricted && ( +
    + +
    + )} + {(quota?.music.limit ?? 0) > 0 && ( + + )} + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} +
    + ); +}; + +export default ArtistRequestModal; diff --git a/src/components/RequestModal/QuotaDisplay/index.tsx b/src/components/RequestModal/QuotaDisplay/index.tsx index 391902ff..4ffad419 100644 --- a/src/components/RequestModal/QuotaDisplay/index.tsx +++ b/src/components/RequestModal/QuotaDisplay/index.tsx @@ -1,5 +1,6 @@ import ProgressCircle from '@app/components/Common/ProgressCircle'; import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid'; +import type { SecondaryType } from '@server/constants/media'; import type { QuotaStatus } from '@server/interfaces/api/userInterfaces'; import Link from 'next/link'; import { useState } from 'react'; @@ -29,7 +30,8 @@ const messages = defineMessages({ interface QuotaDisplayProps { quota?: QuotaStatus; - mediaType: 'movie' | 'tv'; + mediaType: 'movie' | 'tv' | 'music'; + secondaryType?: SecondaryType; userOverride?: number | null; remaining?: number; overLimit?: number; diff --git a/src/components/RequestModal/ReleaseRequestModal.tsx b/src/components/RequestModal/ReleaseRequestModal.tsx new file mode 100644 index 00000000..36b4f8c4 --- /dev/null +++ b/src/components/RequestModal/ReleaseRequestModal.tsx @@ -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: '{title} requested successfully!', + requestCancel: 'Request for {title} 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 {title} edited successfully!', + requestApproved: 'Request for {title} approved!', + requesterror: 'Something went wrong while submitting the request.', + pendingapproval: 'Your request is pending approval.', +}); + +interface RequestModalProps extends React.HTMLAttributes { + 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(null); + const { addToast } = useToasts(); + const { data, error } = useSWR( + `/api/v1/music/release/${mbId}`, + { + revalidateOnMount: true, + } + ); + const intl = useIntl(); + const { user, hasPermission } = useUser(); + const { data: quota } = useSWR( + 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('/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( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { 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( + `/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( + + {intl.formatMessage(messages.requestCancel, { + title: data?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { 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( + + {intl.formatMessage( + alsoApproveRequest + ? messages.requestApproved + : messages.requestedited, + { + title: data?.title, + strong: (msg: React.ReactNode) => {msg}, + } + )} + , + { + appearance: 'success', + autoDismiss: true, + } + ); + + if (onComplete) { + onComplete(MediaStatus.PENDING); + } + } catch (e) { + addToast({intl.formatMessage(messages.errorediting)}, { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + } + }; + + if (editRequest) { + const isOwner = editRequest.requestedBy.id === user?.id; + + return ( + + 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)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} + + ); + } + + const hasAutoApprove = hasPermission( + [ + Permission.MANAGE_REQUESTS, + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + ], + { type: 'or' } + ); + + return ( + + {hasAutoApprove && !quota?.music.restricted && ( +
    + +
    + )} + {(quota?.music.limit ?? 0) > 0 && ( + + )} + {(hasPermission(Permission.REQUEST_ADVANCED) || + hasPermission(Permission.MANAGE_REQUESTS)) && ( + { + setRequestOverrides(overrides); + }} + /> + )} +
    + ); +}; + +export default ReleaseRequestModal; diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index 9ef6b405..7874a2c3 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -1,14 +1,18 @@ +import ArtistRequestModal from '@app/components/RequestModal/ArtistRequestModal'; import CollectionRequestModal from '@app/components/RequestModal/CollectionRequestModal'; import MovieRequestModal from '@app/components/RequestModal/MovieRequestModal'; +import ReleaseRequestModal from '@app/components/RequestModal/ReleaseRequestModal'; import TvRequestModal from '@app/components/RequestModal/TvRequestModal'; import { Transition } from '@headlessui/react'; -import type { MediaStatus } from '@server/constants/media'; +import type { MediaStatus, SecondaryType } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; interface RequestModalProps { show: boolean; - type: 'movie' | 'tv' | 'collection'; - tmdbId: number; + type: 'movie' | 'tv' | 'collection' | 'music'; + secondaryType?: SecondaryType; + tmdbId?: number; + mbId?: string; is4k?: boolean; editRequest?: MediaRequest; onComplete?: (newStatus: MediaStatus) => void; @@ -20,11 +24,13 @@ const RequestModal = ({ type, show, tmdbId, + mbId, is4k, editRequest, onComplete, onUpdating, onCancel, + secondaryType, }: RequestModalProps) => { return ( - ) : ( + ) : type === 'collection' ? ( - )} + ) : type === 'music' && secondaryType === 'release' ? ( + + ) : type === 'music' && secondaryType === 'artist' ? ( + + ) : null} ); }; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 22f143f2..05bb29fd 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -4,8 +4,10 @@ import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; import Error from '@app/pages/_error'; import type { + ArtistResult, MovieResult, PersonResult, + ReleaseResult, TvResult, } from '@server/models/Search'; import { useRouter } from 'next/router'; @@ -28,7 +30,9 @@ const Search = () => { titles, fetchMore, error, - } = useDiscover( + } = useDiscover< + MovieResult | TvResult | PersonResult | ArtistResult | ReleaseResult + >( `/api/v1/search`, { query: router.query.query, @@ -54,6 +58,7 @@ const Search = () => { } isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} + force_big={true} /> ); diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 7b216587..240f6c29 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -43,12 +43,14 @@ type SingleVal = { type BaseSelectorMultiProps = { defaultValue?: string; isMulti: true; + type?: 'movie' | 'tv' | 'music'; onChange: (value: MultiValue | null) => void; }; type BaseSelectorSingleProps = { defaultValue?: string; isMulti?: false; + type?: 'movie' | 'tv' | 'music'; onChange: (value: SingleValue | null) => void; }; @@ -131,7 +133,7 @@ export const CompanySelector = ({ }; type GenreSelectorProps = (BaseSelectorMultiProps | BaseSelectorSingleProps) & { - type: 'movie' | 'tv'; + type: 'movie' | 'tv' | 'music'; }; export const GenreSelector = ({ @@ -206,6 +208,7 @@ export const GenreSelector = ({ export const KeywordSelector = ({ isMulti, defaultValue, + type, onChange, }: BaseSelectorMultiProps | BaseSelectorSingleProps) => { const intl = useIntl(); @@ -219,41 +222,60 @@ export const KeywordSelector = ({ return; } - const keywords = await Promise.all( - defaultValue.split(',').map(async (keywordId) => { - const keyword = await axios.get( - `/api/v1/keyword/${keywordId}` - ); - - return keyword.data; - }) - ); - - setDefaultDataValue( - keywords.map((keyword) => ({ - label: keyword.name, - value: keyword.id, - })) - ); + if (type !== 'music') { + const keywords = await Promise.all( + defaultValue.split(',').map(async (keywordId) => { + const keyword = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + + return keyword.data; + }) + ); + + setDefaultDataValue( + keywords.map((keyword) => ({ + label: keyword.name, + value: keyword.id, + })) + ); + } else { + setDefaultDataValue( + defaultValue.split(',').map((keyword, idx) => ({ + label: keyword, + value: idx, + })) + ); + } }; loadDefaultKeywords(); - }, [defaultValue]); + }, [defaultValue, type]); const loadKeywordOptions = async (inputValue: string) => { - const results = await axios.get( + const results = await axios.get( '/api/v1/search/keyword', { params: { query: encodeURIExtraParams(inputValue), + type, }, } ); - return results.data.results.map((result) => ({ - label: result.name, - value: result.id, - })); + if (type === 'music') { + return (results.data as string[]).map((result, idx) => ({ + label: result, + value: idx, + })); + } else { + return (results.data as TmdbKeywordSearchResponse).results.map( + (result) => ({ + label: result.name, + value: result.id, + }) + ); + } }; return ( diff --git a/src/components/Settings/LidarrModal/index.tsx b/src/components/Settings/LidarrModal/index.tsx new file mode 100644 index 00000000..24d59d23 --- /dev/null +++ b/src/components/Settings/LidarrModal/index.tsx @@ -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({ + 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( + '/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 ( + + { + 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 ( + { + 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) + } + > +
    +
    + +
    + +
    +
    +
    + +
    +
    + ) => { + setIsValidated(false); + setFieldValue('name', e.target.value); + }} + /> +
    + {errors.name && + touched.name && + typeof errors.name === 'string' && ( +
    {errors.name}
    + )} +
    +
    +
    + +
    +
    + + {values.ssl ? 'https://' : 'http://'} + + ) => { + setIsValidated(false); + setFieldValue('hostname', e.target.value); + }} + className="rounded-r-only" + /> +
    + {errors.hostname && + touched.hostname && + typeof errors.hostname === 'string' && ( +
    {errors.hostname}
    + )} +
    +
    +
    + +
    + ) => { + setIsValidated(false); + setFieldValue('port', e.target.value); + }} + /> + {errors.port && + touched.port && + typeof errors.port === 'string' && ( +
    {errors.port}
    + )} +
    +
    +
    + +
    + { + setIsValidated(false); + setFieldValue('ssl', !values.ssl); + }} + /> +
    +
    +
    + +
    +
    + ) => { + setIsValidated(false); + setFieldValue('apiKey', e.target.value); + }} + /> +
    + {errors.apiKey && + touched.apiKey && + typeof errors.apiKey === 'string' && ( +
    {errors.apiKey}
    + )} +
    +
    +
    + +
    +
    + ) => { + setIsValidated(false); + setFieldValue('baseUrl', e.target.value); + }} + /> +
    + {errors.baseUrl && + touched.baseUrl && + typeof errors.baseUrl === 'string' && ( +
    {errors.baseUrl}
    + )} +
    +
    +
    + +
    +
    + + + {testResponse.profiles.length > 0 && + testResponse.profiles.map((profile) => ( + + ))} + +
    + {errors.activeProfileId && + touched.activeProfileId && + typeof errors.activeProfileId === 'string' && ( +
    {errors.activeProfileId}
    + )} +
    +
    +
    + +
    +
    + + + {testResponse.rootFolders.length > 0 && + testResponse.rootFolders.map((folder) => ( + + ))} + +
    + {errors.rootFolder && + touched.rootFolder && + typeof errors.rootFolder === 'string' && ( +
    {errors.rootFolder}
    + )} +
    +
    +
    + +
    + + 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) => { + setFieldValue( + 'tags', + value.map((option) => option.value) + ); + }} + noOptionsMessage={() => + intl.formatMessage(messages.notagoptions) + } + /> +
    +
    +
    + +
    +
    + +
    + {errors.externalUrl && + touched.externalUrl && + typeof errors.externalUrl === 'string' && ( +
    {errors.externalUrl}
    + )} +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + ); + }} +
    +
    + ); +}; + +export default LidarrModal; diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 72bed7c4..5d406bfd 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -56,6 +56,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages({ 'availability-sync': 'Media Availability Sync', 'radarr-scan': 'Radarr Scan', 'sonarr-scan': 'Sonarr Scan', + 'lidarr-scan': 'Lidarr Scan', 'download-sync': 'Download Sync', 'download-sync-reset': 'Download Sync Reset', 'image-cache-cleanup': 'Image Cache Cleanup', diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 92810749..f9fa305a 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -1,3 +1,4 @@ +import LidarrLogo from '@app/assets/services/lidarr.svg'; import RadarrLogo from '@app/assets/services/radarr.svg'; import SonarrLogo from '@app/assets/services/sonarr.svg'; import Alert from '@app/components/Common/Alert'; @@ -6,12 +7,17 @@ import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Modal from '@app/components/Common/Modal'; import PageTitle from '@app/components/Common/PageTitle'; +import LidarrModal from '@app/components/Settings/LidarrModal'; import RadarrModal from '@app/components/Settings/RadarrModal'; import SonarrModal from '@app/components/Settings/SonarrModal'; import globalMessages from '@app/i18n/globalMessages'; import { Transition } from '@headlessui/react'; import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; -import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import type { + LidarrSettings, + RadarrSettings, + SonarrSettings, +} from '@server/lib/settings'; import axios from 'axios'; import { Fragment, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -21,8 +27,11 @@ const messages = defineMessages({ services: 'Services', radarrsettings: 'Radarr Settings', sonarrsettings: 'Sonarr Settings', - serviceSettingsDescription: + lidarrsettings: 'Lidarr Settings', + videoServiceSettingsDescription: 'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.', + musicServiceSettingsDescription: + 'Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only one of them can be marked as default. Administrators are able to override the server used to process new requests prior to approval.', deleteserverconfirm: 'Are you sure you want to delete this server?', ssl: 'SSL', default: 'Default', @@ -32,6 +41,7 @@ const messages = defineMessages({ activeProfile: 'Active Profile', addradarr: 'Add Radarr Server', addsonarr: 'Add Sonarr Server', + addlidarr: 'Add Lidarr Server', noDefaultServer: 'At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.', noDefaultNon4kServer: @@ -40,6 +50,7 @@ const messages = defineMessages({ 'A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.', mediaTypeMovie: 'movie', mediaTypeSeries: 'series', + mediaTypeMusic: 'music', deleteServer: 'Delete {serverType} Server', }); @@ -53,6 +64,7 @@ interface ServerInstanceProps { externalUrl?: string; profileName: string; isSonarr?: boolean; + isLidarr?: boolean; onEdit: () => void; onDelete: () => void; } @@ -66,6 +78,7 @@ const ServerInstance = ({ isDefault = false, isSSL = false, isSonarr = false, + isLidarr = false, externalUrl, onEdit, onDelete, @@ -127,6 +140,8 @@ const ServerInstance = ({ {isSonarr ? ( + ) : isLidarr ? ( + ) : ( )} @@ -170,6 +185,11 @@ const SettingsServices = () => { error: sonarrError, mutate: revalidateSonarr, } = useSWR('/api/v1/settings/sonarr'); + const { + data: lidarrData, + error: lidarrError, + mutate: revalidateLidarr, + } = useSWR('/api/v1/settings/lidarr'); const [editRadarrModal, setEditRadarrModal] = useState<{ open: boolean; radarr: RadarrSettings | null; @@ -184,9 +204,16 @@ const SettingsServices = () => { open: false, sonarr: null, }); + const [editLidarrModal, setEditLidarrModal] = useState<{ + open: boolean; + lidarr: LidarrSettings | null; + }>({ + open: false, + lidarr: null, + }); const [deleteServerModal, setDeleteServerModal] = useState<{ open: boolean; - type: 'radarr' | 'sonarr'; + type: 'radarr' | 'sonarr' | 'lidarr'; serverId: number | null; }>({ open: false, @@ -217,7 +244,7 @@ const SettingsServices = () => { {intl.formatMessage(messages.radarrsettings)}

    - {intl.formatMessage(messages.serviceSettingsDescription, { + {intl.formatMessage(messages.videoServiceSettingsDescription, { serverType: 'Radarr', })}

    @@ -244,6 +271,17 @@ const SettingsServices = () => { }} /> )} + {editLidarrModal.open && ( + setEditLidarrModal({ open: false, lidarr: null })} + onSave={() => { + revalidateLidarr(); + mutate('/api/v1/settings/public'); + setEditLidarrModal({ open: false, lidarr: null }); + }} + /> + )} { } title={intl.formatMessage(messages.deleteServer, { serverType: - deleteServerModal.type === 'radarr' ? 'Radarr' : 'Sonarr', + deleteServerModal.type === 'radarr' + ? 'Radarr' + : deleteServerModal.type === 'sonarr' + ? 'Sonarr' + : 'Lidarr', })} > {intl.formatMessage(messages.deleteserverconfirm)} @@ -356,7 +398,7 @@ const SettingsServices = () => { {intl.formatMessage(messages.sonarrsettings)}

    - {intl.formatMessage(messages.serviceSettingsDescription, { + {intl.formatMessage(messages.videoServiceSettingsDescription, { serverType: 'Sonarr', })}

    @@ -439,6 +481,68 @@ const SettingsServices = () => { )}
    +
    +

    + {intl.formatMessage(messages.lidarrsettings)} +

    +

    + {intl.formatMessage(messages.musicServiceSettingsDescription, { + serverType: 'Lidarr', + })} +

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