diff --git a/.gitignore b/.gitignore index 6306144f..87c04ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,3 @@ tsconfig.tsbuildinfo # Config Cache Directory config/cache -# Yarn -yarn.lock -.yarn/* 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 6e8f5896..b79490f9 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 @@ -4070,6 +4416,8 @@ paths: - $ref: '#/components/schemas/MovieResult' - $ref: '#/components/schemas/TvResult' - $ref: '#/components/schemas/PersonResult' + - $ref: '#/components/schemas/ArtistResult' + - $ref: '#/components/schemas/ReleaseResult' /search/keyword: get: summary: Search for keywords @@ -4089,6 +4437,11 @@ paths: type: number example: 1 default: 1 + - in: query + name: type + schema: + type: string + enum: [movie,tv,music] responses: '200': description: Results @@ -4109,7 +4462,9 @@ paths: results: type: array items: - $ref: '#/components/schemas/Keyword' + oneOf: + - $ref: '#/components/schemas/Keyword' + - type: string /search/company: get: summary: Search for companies @@ -4728,6 +5083,57 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /discover/musics: + get: + summary: Discover music + description: Returns a list of music in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: number + example: 1 + totalPages: + type: number + example: 20 + totalResults: + type: number + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/ReleaseResult' + - $ref: '#/components/schemas/ArtistResult' + '500': + description: An error occured while getting musics + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Unable to retrieve release groups. /discover/trending: get: summary: Trending movies and TV @@ -4999,10 +5405,12 @@ paths: properties: mediaType: type: string - enum: [movie, tv] + enum: [movie, tv, music] example: movie mediaId: - type: number + oneOf: + - type: number + - type: string example: 123 tvdbId: type: number @@ -5029,6 +5437,9 @@ paths: userId: type: number nullable: true + secondaryType: + type: string + enum: [release,artist] required: - mediaType - mediaId @@ -5113,7 +5524,7 @@ paths: properties: mediaType: type: string - enum: [movie, tv] + enum: [movie, tv, music] seasons: type: array items: @@ -5162,7 +5573,7 @@ paths: post: summary: Retry failed request description: | - Retries a request by resending requests to Sonarr or Radarr. + Retries a request by resending requests to Sonarr, Radarr or Lidarr. Requires the `MANAGE_REQUESTS` permission or `ADMIN`. tags: @@ -5670,6 +6081,82 @@ paths: $ref: '#/components/schemas/CreditCrew' id: type: number + /music/artist/{artistId}: + get: + summary: Get artist details + description: Returns artist details in a JSON object. + tags: + - music + parameters: + - in: path + name: artistId + required: true + schema: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + - in: query + name: full + schema: + type: boolean + example: false + default: false + - in: query + name: maxElements + schema: + type: number + example: 50 + default: 25 + - in: query + name: offset + schema: + type: number + example: 25 + default: 0 + responses: + '200': + description: Artist details + content: + application/json: + schema: + $ref: '#/components/schemas/ArtistResult' + /music/release/{releaseId}: + get: + summary: Get release details + description: Returns full release details in a JSON object. + tags: + - music + parameters: + - in: path + name: releaseId + required: true + schema: + type: string + example: 87f17f8a-c0e2-406c-a149-8c8e311bf330 + - in: query + name: full + schema: + type: boolean + example: false + default: false + - in: query + name: maxElements + schema: + type: number + example: 50 + default: 25 + - in: query + name: offset + schema: + type: number + example: 25 + default: 0 + responses: + '200': + description: Release details + content: + application/json: + schema: + $ref: '#/components/schemas/ReleaseResult' /media: get: summary: Get media @@ -5701,6 +6188,12 @@ paths: type: string enum: [added, modified, mediaAdded] default: added + - in: query + name: type + schema: + type: string + enum: [all,movie,tv,music,artist,release] + default: all responses: '200': description: Returned media @@ -5948,6 +6441,46 @@ paths: type: array items: $ref: '#/components/schemas/SonarrSeries' + /service/lidarr: + get: + summary: Get non-sensitive Lidarr server list + description: Returns a list of Lidarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LidarrSettings' + /service/lidarr/{lidarrId}: + get: + summary: Get Lidarr server quality profiles and root folders + description: Returns a Lidarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: lidarrId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/LidarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' /regions: get: summary: Regions supported by TMDB diff --git a/package.json b/package.json index dac91568..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", @@ -159,7 +159,7 @@ "prettier": "2.8.4", "prettier-plugin-organize-imports": "3.2.2", "prettier-plugin-tailwindcss": "0.2.3", - "semantic-release": "^18.0.0", + "semantic-release": "18.0.0", "semantic-release-docker-buildx": "1.0.1", "tailwindcss": "3.2.7", "ts-node": "10.9.1", diff --git a/server/api/musicbrainz/demo.txt b/server/api/musicbrainz/demo.txt deleted file mode 100644 index a664d1a5..00000000 --- a/server/api/musicbrainz/demo.txt +++ /dev/null @@ -1 +0,0 @@ -https://www.typescriptlang.org/play?#code/MYewdgzgLgBATgQwO4BEFQTAvDA3gKBhgCIAbASwDMBTAWggAcExiAuPGasAE2u-coJSEagBoYAI2oBzcmHbEAjAE4A7ACZaigAxbVxcV34wwAV1KkYAX1GEY3chAQBbCeWmn05cAoCqAZXgEBgZqOAM7YiMAfQQ4agQ2E3NSWyJiaSMw2nJuJOIAZgA2bgLS7QRaAA4C5W5aAr5KquAJAtp1ZSkAFgKq7upuhGUIoigAT1CFAAUwiHBRmCQQOABrCHYAbQBdNJJ40gSRWmk4EFMGCCTNuyICIgeYKHIoQ4UASTBKORfqRcfiAw4ORnHFxrQJqEcnkFJQAKydCTdVRFWhFahFTQFdTdSi0CQVSqKSiNAplAqKAqUYD-B4OJyudyeZ4+Ei09IiUA8MEQyZ0XJXLa7W4PQHA0FwcGQv4KACCpAkpmc7JInPA3B50sFMB2e0eGGkGx1IseuBMLmoCl4UDOLxgAAtyAxaPaQAwDDBQKYwFB2AVrHrHhwwBaFI6GA63R6vT72DoAyaHmaY76YIpxCHnJaSOHIxHEO6E0Gk+as2GnS6o+IU36i8WODWYOoM6H9sFiHXi2bM9niN7eHBTuceA6nXno8PU4prInhcWDkdqEbdYncgpVIo4VI4dRVA0iqo9wU4cBdAhapUEBISdptJRKF0EIoVcRvnBoLQFwhjhqoDKSCoyioooihaOoxAijYIr3EGYogpqfLQvk8KIsiqLopiDQ4niBIIESJLUGSpSUtSKrPK8vYACr2tQMD+BQzh0faCDcOMMAADLTCq9IuG4HheKyxAvmq3KSryUICtcc5Bgay6JncnqTnGLZliQEggCAjESO2nZBt2rbEKAWasUE7rVkpaa6aaineqmzalr2zFwNwoDxKOEauoWUH1g2Fn2T2YZxC5Ky0QWHbefW+mqcQuaeROtlxqoVklo2-kGbFbqmeFgbWQFOYVnF5kJZZEVdjZsZNipjkrGccCuX8yUKalVUKM4uRIEusBhY1vnFemDkKN1pV6QNbYeWOnnRAW7l5lNwTROGlYMHNZnlVOPXJhZ-V5cQ00ZctBYLQVUYbaNu3BDNk2HXtE1uitR3OoVa1xqdO0ddAinfl17ZFRV05WLOOWqtQXIamJWpSUDcESlKfL5PKirKlDb4fl+P7oP+SjKNjtDaJo6gFCqa5qbu6hwrw3S0N0RRkg0wBVJUygINo7SUOowC9Mo6gSOi2h5EDaNLkKkGBjB+oIIaWzyb1f0tSQQh-nAIbPAAbrR+3ZdLm19XLxASKQphLprPlRb21q2rAi1PY2cKvelQX1ZdVbPTARR29FTnBW5YW-XZ7uObdq2peop2Nt0uv7VlocWQUEfHV5QMKTtVtugA9D7Lv-YnMt2XHj13SnCday7aUezVKz1cbkUu7HZ1tdw73fUXJsl7r9eN2EYBR8NuUGQwmUZzb-sKFAbrdNo8V-TOxbSQCIlg7DoTaiuxbQwhoTwwqSpE8Yet3sMij07j2Oon0lCgVe-S0KoajwlU3CqBz1ASC+KNQJ+1CHN+dC-pj6i3robQcIOjgQFp-Rcy5Z6iiBPBcGiFcjIQRF0NCaIMRYmwviQkWgCJEQpFSGkQNyJvBINRWiABZOIEBmIWBgBQqANF3wcS4kDHijJ+IsnkGyKG89178m4MvbYItoKJlYXxZk3hOFCW4SDdUvDoQCMIS8YhxBSEwAAKJtTANQRi-hXRIBfDAmG4k+GINQiiVBmFsS4kwXhbBpJyQkQIauXe1BlBwiqDubglNuCKG6FUBoG5QISCkHQOEcJhiqAkO47E4SVSC0gcjcg7536C1oL-fI-88a42AeofQUNDG8M3ojYSMjRKLyNkKQhEs5It0bNtAycIADUSwQDABoiwaOxVa47XNiAO0hcq5lR2uA4ANpwDkAIZnYeJARljLABMgA3DAXi5AuBQCWeGTySyABWCAABeezJ7rR7iWYZYBpAUCoUcl6Jyk722co7DWvt2Ah1ucGe5XtQo-RLp0v6qg87jl+VOAFnlu7Z1NuWfOQctpAuUmdXZ9p4gQDaXEKAEAmx40JtM4g7dOqdyys8mA-o3na1lmdEA95yB7NWRYWiKK4BosGSNOput+4UGeMAIQTtm7Vx2v3fM3y6mwsqmdIa4LM662mswbg3KmW92ijdDyzshUkolaKi6+0VrLJHJqgZwr6kKuCOnAAZC-aeQZBGPGGmLaB4o5EINhEgpE5iMLoOsbhfC9jiL4LIko3saiwCV2kaDe1-DIYiMcCs9hEiFBkWqVLFuPTqBjP6fHa5rt9W62kMwQ0GA5WnIMtm850BMCDxhaq5ODsQqysJVnYulaHnVrLcVV54rk6B3TYoVt9b0qBwJT81VjZukGVxdAfFYqe3RX5em7tiaDLNqnoOrakqLrStlRtaWRAWWbvVRGNduq+1KwkNqmVGpA3qyEKQLV+7A73Ujkek99hmDAAvRYCCPkK3ztXTq29115p-r3T+pV0K+rYoA4+zVUqgOzQXccwGiZiA8LgUvcNq8CnIcxgjbeQNiaFAZofBA1B-FeKqLoAoCBuiUxqOErQXQ7y7iKFUKolJX5JNRuA7+aSMYZNvJTECYE4kcZEJAoRpoENvw-l-dGf4ePaGUNkrQcId4KASIobgt5VDtCiVUU+cJujAJ09oPcbMabqG4HCAoBJTyCak0LHUUCxjxuNLUiy4czq9NTVC-NTU-IRyrW5DW2KnkuzdkunWZ1C5guLiyiL5c6ohW8+86Ko6m4zuFaXXs-KosufCztehphpDd3g6vJD5SFEIfQ+Uop2GI0MjEQJSRJSQ0Yfkah2ClXjFIUdWY9CaCsLuqwcSL1eDSKKIogoAASp-YIIh30PGtRVu1GHqtI0TLJBNvKDIectmmwlSVP0e38+rDttbM0RaO9l6udTtAgpOgdgOwH+0qrbX3Aegry3iqHSuryxXYKleMeV4suGEBwiAUUYAlnr4FDvFTZQkOJA6FCeoSgB574aAkB41jyTJOLi4zJhQ-8dC42AlUGzEDKmLdgeUrrJAULIJdX1qxOFBs4IcT6sbyipugDVpKbika2HiMEk12RLXJLCytaLZxCgEeUBcsoYAwBqiMd0JRjUtArxXloHCZQfBOVFAkOoFihMwG2YSeJtjKShM-24wT7QlItCgSAQYpbVW5Rb1WyV0pC8AdtfFpLZzV2Y6622+5JaiXSXAvO42gLHb9UGoe4C+7CgsuwZeel774Vftzy96GwHQZRFMga7GjnVEaK0ModQywdCGHos4k2Z3VPOsOtp06lBrr+vM9sUNwibPSKiaTGtv1k3qAq3ICrIQDejHShp6+VvDPLEYI9XYnv3rRsIf+9PsX9mWH8-qxw4vlOp9wzd8U4NIuyu+4eOtgPZVGxVFuzyoZr2BUgcXVnh48SKdA93sAakmIMSgSMbg5Uzg71AUYWZojaBVBqBATUyUAbhY7sa2Z45-x257hdqKZzZEALae7NbU5b4rz5676F775cKD7jYkAADSLg5Aqw+yfw+SLuTeMILePWFibqnenqK+I2Tiq8EmqS6Stuig-iUBDQz4OGu8pGZm-+dARQJ47Q1MRQqIVQlAyIVMfilG2IigCARQzMZO38ZuaGTB0oK2wuZSPuX+MkTmNwOWFU-yEWseYWZKfKb2P2M8-edwa21hk6vYTSLSbSXAna2KXAFyjg9oQRThqY9+DhwG4esWcAtUQaA6L2U6rhERn2Qeu6meM8Z+5hEMlhAIHWJhJ+NWfBFuOOnGghJA-8-8uMoEigeSUuakwA6gDRwARQ3iSIChFIoEXQmmtA1IOh3AZMHMCAwASmJu5O2+h+oapi9OvWC+A2XerOq+vBMkQ+JAZCpgEAEyTwIAMAAAQuQqYM5GEHwIceMGYd7pvmGgUXSMQdGkLh4XgOvjnhhsvMQAAMKaQMDkCkANbEAOZPDeG2G5zubJoWyh5xRnYNqfLrqRHsBubtqxHYopbjrvYtrmqPBAlrzLYlEe6wT8FW4oGyZ8aaBdrKYkD3z0zACHgSAdC1CnzUCUD+IqHjG0DMkvpSC3hPhO6TEGF3FjAbHEBqKT6zHdbzHsEd42JcG4KOJXG57XDEDcDDEICtHUCO6nhkYaDyYICqDMxpIYjaAY4DBRI+KAk751YkExpsjPE2rpBFHH4kBYYEnZ54EWE6hfE-F-EAlAnX42GB65YNLNLLABEdIIkwBIkGQSBuIvpUh4hlBAJUxEZ7g65260DABqA8y6HnxlDdBxE7RGR8BsSp5NgwkGSzJnDzLABLIrJrIbJOhbIwC7IHJxExZnKhFXLYpFq5qJCnZJ45gXbBbPbRZbRRkeyOEZHFTjkJ6goTpzoTleb9kpG9hgD7GZBgBuRyCUAgDpE+EKAUrfDUqfyHCehOSMrLmjnTmsppHCr2E7TzmbaGqAYyrDkfZXnOEGSjwMDjzZEWoSEKDaHCFcxVB0BqbYhYRszq6I4cnEgohEb670wvyJLY4CE27VG3jAIYF4z6HCaClPDCmfEnEYByAwCfGXrsCqIAASLwVwjBjeNxcxzqCxHBMpy+cp7Orx7pNxHxKphu6pmpt4ASnQ6u+pugvARQxp-Qz85Mz4QJBejxjWdpTRxAh4LR1MQlcIRQROyIkOVQ8Fx83ADM3Q6gnQ+ujR84VuhhhJ5RaF+OGFpluMIhExXh-uAZt+y6Z0iggAyATKD+HtIFnRmdQwAUp7kLm9hGS-H-EcJhVPlmzbJ7EgDCCxVP7RQhGXLhGXmgnsD3m9ookDnECHlUo0qnn0oXlTIFUMDRV-g+idQQAAB0vAKVzKnlLhL+f52JuR1xfIHx3xzgUVvp9FR+G8+JCpoutxnpfFapigGpuMWpwlupYlhpklJpMl5p8lDxgujWQ14prBkp7eTObF3eHFa+xYRCvYRFDKCApF5FNC4EylRhDF8CLBs+bBB1i+LOw28pJek2yAMAxqMAvggapgUAfOVpilB+uB5+zBy80sypqpAlc1QlBQOpolBpElUlppslKo6QfMyIqh+pDQNAwCZQRQupmZ9Q5mShLRjJVQ14WBDwfpIJgZn50UIeeqEZw6i5ie7+HIbxF+WwXp-VPpMV4gxAKAAAUrQG1AAB4WkzF4nOnu64V2aEGPDA6UBtAQ4Py0bUgNA+JBI4j0nUwSC-6WYgwsSgLm6oXElVHECZLAQTwPXtbGHPVMVt6M4fXLFfWcVnXCmS0wBUVhD7FTZaLZq8DqIOCi1cXQ08VKmmU6HqAMz1CnhVBYgcw4SKA8xpLdCKBKFwg6CRJMby3FgKVbWQ1WFuX7kkA9klpPbvnZUirImJ4rnJ5pGErEpTms29jbFtmtUGQqzSDKhYmM1dW8IfH+DDjcA2hjGrAl0u1PUjVK2n7W1IG4522ZLyZE65KUmGSy53iHygTUgeJUy-6gR02E3GW7g3wF0sl9mJif72bO2PBl1F5kFQ15HPUfFFCy7IgDCaD67uJiFZJ1ANFpI-2qDPyqAagVATx+nCmqKyjnJhDl4hiZBZg+gwCUTnBwAcRj4MEK34EvV07MVSmHVL7HW95rHq27zjz67DFCVkw6YNB1CaBAQIB4ibhqktHg7-zQMq3WUAhEnIEb2YVimK3EAuljUC2ensT4Pz1+41JxVWgQl9I7ZLkVVd2R7N3QkAzuES7CJnXWE4kb49VKmS3S3kBy04mOlL2SPK0oVr2VHoX2124TwAVqR6nkbwGUxATcB4gmWqC6CkYg7VByZRISDQOuIakCP4W4lEPu3z6sUUMrE8G+oUHEBA2CwyproTZxAEMf3dUSQTWbDEB42qAE2VBUjUAk3cBk3q4U1pJaW8O0300bXg3l22kGNiZ+0ZOqK6IsRsTTBnDbIgyg07XjWJMsXSkpM+2nV-b80w1KnlOVNE01P631NjF1BNPU0ExAR02UAKP3EdNv1SKEPGKmFj3vHmNS2y1HOOZuU4nCPr0uOE6O74yk4eNKA-0pl1MDGuKK6cx4hMZmbXzCHaDjFn1BNFCxNP3dMD5lE20iOvN27G4qU7i-6gUskZnmbG33j1Dy6Ubq7GluJbjKAswg6wtq22qL2YYOOuWCOiimMoaC0WN3PtNRqdNnOFOKmC0rPIhVPE0bPk3bNU0tP7NtMTMJMSmkPvVLGylUPpPKL9PMQmTDMgCjOjIM04EL3DUmIyse2LGcHsWKs-UkADMmSfH-EQA7HAB0Ux2f3FMfH8uE3VO1ObONNis00SuHMcsC6nPiOu7L2lGV2Mt83cVmOsu3NWP3O062W20ou+IO4gK70qHGmYhZ1a6SUq7aDUB0DKBqaK4xnaAmXjEg5drIUP1WXi7zaS6PV6uXMOtFMVKelkJWMYChCpz+A2h5ug1M2PNfONCkY8xw4cmESgTdAK5nhhKUxyFyatCTslBdBUtPPxvIv2WuP278adBKu9gHEgBQCjyMQUpPBl77uHuHCBvMFTNkNe0KurHSNLOC2bjABZ3EjAJGWjtkiqDAJPjcD0kamkZlsameKxuv2kFCTP00sNujVXMyOlNtsy0dvUBds9vJqxv+kQr5SxGEqvJAm4bPzkaYgnhUwkgKE4jKEFt4jaAdG8D6WdDUCtEruOOW7ruoFgNGZ1G7sKAULviV6Pr0K0ST3mCHFnCCfhBSvXsGtJMzOfXcHfW1acsBtwdPuekvtvvwhpLQHtDfu-tqYAfgvAebh8CAlQfhux2RvTFGNuUR5wo6N3Y2Ou12P+ByAXIFM2VIsvMbuAToHknuMqX9CnhJ1MxhPwHqHaXVCHztD6Uql1Aag3wIH8l4VWfrEZMS3bGwAoDgCACYBLAAAOL4PLIwAABipgwAc9knjFVoklfQ+mmgoF1AZGKO-iHDetZmriT4fAjXMZYNSnEHAA6mogceorVVdXIBg11Fbo+3HTW9gXWwsxG065fkQOBzaZI5QIrKCPQiN4rCRWABN-AFN2a8QOlx9Fl2ALlzAAV2rEV6V+V1e1VyQHUyzP0AiNUHm014xurveIru10zDNbwCS7vc-BiC5N0PULuORg0CoamT4vSXUzUMoL0IbleC5Yi049JpjIBP4kTixkl6rSY4s-kSl4oxtqlY5EOSdpox+WCfZ4-iNMnHFkkSqrzSQLY5jC5+cpe5BHYNJPEK5A4MWmTy8aXZtcp4mIcOcvQn6HnbnbHCpRqMoCjlzAZt0BjlTCWy1xqAoZA+eFAQXbUBZal8otl2oIceAPMrRADVa+ME1UDKPrwCAAIEICIFUlXY3fHtLhpFpDpAVZ7I8lTyOeFYFNHl8q-scq3dh+OHtuWdzXOb75HzFLttT8HzmEzwlllSzbTyOu1MFUNLOGZwRRk6b0lPu2AJb-9WRaQLb+56TyTw8A79QE7zAIIMIGIBLyEdL2mGTAApaX12t0wAyiFZQDAAADrEBHE7luSCcwBTb-GXC0TqDj8wBssxtfMgxq-y4YEMyg5UzI7NAzUAeEu8CUC3geJ5CF-nUKAl-m-l+rKV828R0AAUwABsx6fALwAAlCqI3836367wy3wqrchcQMSXtIC765IdAt4L5luCASWY4QeIbGNwHky50r4GOZQP4jJhI8cQBQAYLUCdrwtPC3+BQBwzCQ6ZCaQCIysmV3D4gkeiuXAS5FqAJA2g+lXrv6wg4qgwBEA7oOS2gGJhf+zvNvm7zDZF8TelEP4pYHeBkUkq8-EQDACf7+AX0IYYECAG-6F9cMOuJASyWi6y56gAwbQqJWKDVACQlmQ8Mxh8SfNFO7AtbpwM772gXkRmGoDdn4G5Am+gggAdZxEFX8SA2XcQTQikHfELAM2WiKnBgAAA5EAEgAYTkJmA2rebvqGFJP88uIAGVO8AgCf8q+CQeZGAAADkMAAAPIg1aEbEK1iABEDjNAB9fIgAIJb4u92+xYLgfYKJQeJe+VgvfDYK+Y0ctKkAvcAiCUIgEhg+IMmpUBvhAJtCLJFoufDiGGMiCJzDgaALsHsA06emIoOvztyTt9KiubGCDCpgng6AepBAWiAKCtBJKhuNXo0G44kAn+cgRWAbF4Df97ergv-nUOEGzddI9pGABoMpDswEchtICFTEib7DMKWuNmHwHJjMkaAhzPvtYJAEd8peTQ0ytrjhCPDHe7g+oaG3wreDiA1w0bncOoBqDCBIvIMI0IcHZtnBovOYR0IC53hrwrQZoP912FSBjBMOe+JJUCY0dhgaLXpsok2CUB4gnUcYIcG2BxoB2Lg1EbULb7qDd46mGar4n+EtBkBGvHQrQCV6cc2gmmc2kAnUBKFbB8Iv0AAnJGzD++gkCgGrHECZJgEJbDoFUHYCBCVgeDLRDAFlDxAQw4gKirxBOLSBxAeXMIKCDACXFXhVQmADUP-7oiEhGTTYNMFlBPBEA5XdgOxDKG0QAAmjgxECkBKAwoy-sKU2DehVga5JAGAGFEoi3BEojwRiKDHADtqcI8AQiOgJJ1DRNDarrmxjL9EQcv+XYfxiYzmIFcrXamDrmPA0gpRCgfTIyXmqyj-Ek7WXPiA0D4wyYJlGauiBUKVsKRxoqsQ0MWGuwyQ4cMUSWNDGBjqWQpCMbmPzGFjphPTcsQeODFPC0Rx3HMT6D9TcAixKlKAhqQfjCFqgiZWHKBWgoQE6aQEdol0HUAJA0exIjcQ0WxCkZoR7Qp4oSI+GViK6jwEkU2AaKdB5exAkgHpiZhuIIup4AYFTCCaK4Wg2orXKoH0zYwqQP9FQJcOIB3jzqj4n-teNLFhir8xjQviGJeGVDLxWI2iQ+KfHoTiACojxOD1RAm1jaRlVEDoXTK31yWx4WXDfTYHQS1xoEvUTAFUBMZbwl-YxsWOeFCDyCPI2yLxN3qYTOgsoppoMIGAbhqgIOPcGmzqAakyajQGFgsJUkqBOYDY45quIUCv8shZ4hFsb17A8SKI9E7STeM4k08XkweVRp5jDxBYLspZOtB7wfyBVksufD6PFLAwYkKoryVnvBJmQy0qqEyF4LqJrFxgkelGdyUQFwwgQNSxwlHJFwZga9Bgxg6mBmThBBNz48BBXIRF8lECjRMIyRIABwCWUDa1dCHBAAuATVgv4LAZySVKbDktGMaEoMLhjaBqBLM6IFURCwnFKFgW4SSmKZVAq0klCLEbStRIABU50i6adIYnii9xnEy1LWxmHhjlEIcYrmMXOLRjyKIQE8pgGK78joAgo2vg30Ym3TPBQAsXvMOrFd8-EXMf5CpRJAuRaSNA1rnuAGCCBqgBdQicUEowYDck2uAgQ9PPF19Lx7E3SdyN7AhwtibSMsgmNmxfMdcKpFQmr0i5IgqYadKjMxn8Qo5dwLRVyQSBGAzSu+ZgCwFBOtIwSCZfkxsdUQ8SERoCwCXoCoSpiWZ6SUSNSRyRpgsQMcGpGpiBJfrgyqR64lSdqPHh8DiwJMssUTPgYZN-QsoeqmQnqo9SiRT03sNbNtn2yQpTE-cX60UkISHgSE6HKRhNlLTd4QENmHbkPjYIgmpHFmcMGAAQ9CMQCO8O4hVL5khxakG+MEhWkqjfEChOQnQGMr1E2gudeXNpDxiAyVueswSN5NiECymhFINQJpndkgyLxls5RC7LtkOyPhWItuW7LCmN0MsKjFNOoxil+84pmUiPuFLTBJTY+1UBIhXASzTzWoqU1LBlPD43JWeZs5iUQD9kqAPEi03WZSMEjUB8pFAYAEVLplk0uZhw8Hoxypg+JgmJ8fENJWJDMZtKLGLMVbKdGuyRRy4WzpPPiKJF55VgIEjUJtCGwa5foCkK0JXH9SVMx8wqRUP4lAQgmTMYibiB4EAi3uMZHGLATfF5tDcxpbVrzz2CIYVg78HsPkA0RyBtEiwRwPMmXBlMDRt4ECCiHIlBMPQDCxhcUB0wgReB89PWDIDkDq54giQdgDalyncsr8fIdgELNSAihiAj3GRYGGWmCAiMm4UCNzDVKkcFZ14ICdUGpB504yCIdorSBIUMpaA5ChQN23qowAJaiYhgJlRFA7QrFNiuxeETsARR5FbtaXLzFkmUxjhriBoBSC2HQNgWZINQATGNJ8woRdgZabGUIj3h9aSZAYPpRVGNdQImZSJEdNzJeJFgj9dyo7IeAABHTwOynGAKA1ycAUEKQBfCpIhwFwJIIosTAlooA2xBQPkMpSnyJ8+43+f3Kj66MgSKYSUH4H8A1LiSI+NZLDRNg7p8mCAURTujGBSLkgws+ZfYArlKSfI6QRwCADEL9D0lKQltqUwCCxt+J0Ml9AW3pL51-EZITjhgMPDXw3EPMDxHpkzKWCNlooBRSkGziPAdoQNX4DKm7YYx7WbyjkKQvMUWh8gvyv8P8owB-grgO6N5CtwxiAUH5-GCktLD0b-kKss9CWK5xny0ljSmmCgSBD8VYTqgMPaoEnV6A1NcQ806iZ8G+DzIZMXzFQsyQaK79kBVotXj4hVF5ktcDMX-OiBjk6ZhIMK7YjPjwElsKQp8HTPUFwFyZvuuitSR4kgYgwTwNAF8H+CQ4fwgQS4NZACVEUwBkUwIBgKmGIDsR0A00mAP8XOSeBMgKmc5Pmm0jxZeACgNkUoSCYbhNCKoJgOVxxXnIFA5FG1smj-BkVv4Ck0WesrpBIqAIKK+olbWLDGRyAsym-HpB3TyLYxqwDMpOCSAgQvlog3sDjQeBT8tuAa78OUKZVpri16AGfPCAxCMc9Mei2oIEvDlw59cKogCQjxKAcwjePkfuDsVIKKB0VBfWCQhlqVnB6l0iz5YmGKVCAXgZSkgBUqqUqhmlrSkgO0u+CdLqlQMQZfOsyYjL9x1dZUo4FACGhU4AAPgAAkoObQFeP9HJVM+XYHdD0mPUgBT1-PFYPwiWAvB7QRXekCevRRIBmAUAVOMxBu6IAWQw-NMDevtHOAM+O6OpOisT5vlMSGKzqqOrGVqwfQkyyKNMuEVzLgVZ0SFecQBWwrC1c8UFRYpIBEboVgKsjQ8FwynLXEmbS5YEpuX6U9wN9R5TOxeV0b0gHy5ZQRuIBbKdlwELNbwA+JHKgS0C72e-WBXShJ1FgeFXmt-jIr-hqK+6h+lZ5Yj6VPwCtWhmxWyBzkNajcHbjjK4xauLDF9CqNlx0BNaEgdTCW3B6yTd6ZQSkAgGgKVBXEdNZMi1I4YsQMyB4FiNrg0C4holJWUVRAHFWERJVedaHiUAaDjxdSoI6+Exjal5tIW6qqGJqstw6qRAPofVRwGtX8Q7VMyB1eICNVOhTV5qqAOGSBhOrQALq2TfvM8lNaHgPqugoZukClrnI6kIKKnGmDBAUG9EPNmrG4jRqsYamuNSqETXJr8lhM+sFiLo1VrTVXaZfgADU5Agouja+BWBbcZ8jQclsO10A7hgCuAzXPZs3ADE+hPMaHLoQ6J0a+1LwG0umDTUz1yuWa2yDmsHWab9G4s3qU7I+BfBdNZctngZtxXN5XwJmw+IRHM1XxagVmpXrwAGLBIHNquZzVDFSTjKsN1wLWDuhU0xqgIdGmZfhuBXiLeNTwRZY0sE3CapVomhrQcr3XHKg5w46AmcuY1yErl0OVMuxvuVbhGM3GjAeTs8XFMGlU6gjT8sZXEaYVRsPNSCrMWUbMmkumjaRvhVDqciiYHdcMu-lbAsOSfHDhVSBIrqjQxAfrt+u4CIACxoy5AnUvdAKbZFxYGdaUvKW7aulIicbYBCcmJgZtwvebavDe2ZqUwX2vNYtrzWPaB1ea5bYBSX4dgNtd67bctv22uIoCkSrXBiHoFDBlZPiYBChH1w0wOR92tXZiuk0RqfZRAdrX6q60kByKPWkAH1oG2hBcGw28ZSqHq37LS9qoCLTPl0Io0NASOjSnLO+Eqi4cYBc8C+x8TIJlxsEbLdqqRR6qYqBqorbat7AhEPQFWk1QoGq21aFe2hHEFgO+6E0BgtiMYpxy0oIDtCQCQERpr+0FLEVf4VTb5y0D+cE1H-WbbjreXprZ6H2n0EkFWGVqXd78CHQduT0EhU9p2jPfiCz1XbtRN2-PfmQj3-6o962zbVup3Rh7ntwe4UgzStQ5S1l7e8vZ1rt11a4g9O11SW3dVGZhCwhXeioDJA4gXIaSbGMbQox5zH4juUCjpjGIUh9KT+v7J3oh0SregsWvoPFrlVJbFVqWlVRlpB3yKj5OW2fflvn2Fac0S++1dIFX3ABjVVWi1fmixGndMuOXfLoVx+llcKuWK31Z1ppxU7YImOzDYyh90Sz6w+OibQ-p0BE68NN++sEJvmAiatAYmhnZJtl0U6pgSy+3aTtwOtafIpisheCr8BK66I0uoFcCol1-L4jtGwI8Lv1YhHAjDG1nUxouUc7WN3Ou5Zxv53PLBdqun7UXqDCa6qN+6zib-M959KHOQMR3XOud2VLXdq8MdecFt1ZGmlEWtpR0qTXVLC+3ulNb7qDBoHWQL29-Qnoh1qAu05mQ3LsPlV5l6ST4TRQeHpgtAOipIcQjukj1V6UAdGkPa9ozVf7QacYbQIXrQ2l13d2MFw57uL0Q0IjMAfA65wUAS1qAHUSwIGpB2t7GtxAN1QeAoMaAnjzOkgP4uPDCF6S6bAJr-l1Ly5NAdubAU+A6J8A4DCGaffEFy1z6JESQM0IvoljL6ythqjQ5Vo33aHkoiGPgy9QENSq4tsqxLQqroBKq0tqq3-CDqxH0QQQTEQZuomYRmGOt4Ol6iDFyRBMDBQwRyijVUL4hBAiuJQnnUa4Pgf2FQK3bjix12HxjDhu43foJ2PG3DCQEnW8uSNQrUjpG9I-MHl0xGqNcRkjTLpWU5H5ceRtEAUeuVFGONDy0o5O0F3pH+NoR9-TTrzp079lEmkZVJr6kyaJFby+TSEYqP1hUNo9DXZOCGW1HtdOoXXchqynWAgSrRiYO0aXUY7iSNu0XQJqDBG7BjG64Yx3PQ3IFNT2Gx9W8qcPY9H9WgLkRsuJ0eHnjXLcnbGasOBnvDtO3w-TrDNM6AQ-p7I7Q1yPnLXTQDd0yqJ50lGnlPp15cCqiNgqswEKu0wkfJ2mmpdtG+M8WETNEAW5vYPQyv3AA5DDDN3Yw-d3yRg6jNEOsU4eDtyVApTWICppTGvBjFXTSpu8GoFBz30ujxZ8db0f7NFKSlbRhdS7pQPFgKza6oY50eqMpnd1Ryz2SLJePRm2tD5yvSd2+OfxQ1tMr3S-vsP-aFtmB+Ax0dNWfFjjeanbVRZnwLGzMCIN86DnkxrHoKmxx+ExnaJUqKQD2soU9umN0X-dFxpIHCBuNJndTvYFs7j3bOSziAJ4E8FeA6LXxX2mgNBZUEiYFtcY58CoFSARBaV9j4W9AGKv4PRbBD0qkQ8yY4biHlV6WtVdIexPUBcTCh-EwarX1aGatHoIkyVqiAOrkoAJ3sJJTJo8DQcKNHeppNFHFgajjOos9btAulmAzEF2dfmegsdHYL5ZgYwharNIXCiOFyw2Lv8mfGMuF5i7tedoi3nTDwFus7YYbOpq3lnZj4avCDO7K-Do5iM-qEp1FWmz4RrC5EatPRHNzsRlI-acSMmmDI1G80w6cE0TnHTU550zOZY3znblnpvncuZ41Ka8d9x8o5UduNBggrpB0K2oEc36Zd6kSQQATHHj3KklxpHCCDjzkYCbwDMSJBcO4S0n8g9JoQzKoS3yrbLrJiQw5c5MarZDM+3VW5YWAL7lDxJ1Q+oc0OUnvLyUMY3Np1NBhDjXxMtcGpB1CN-9Nak7fWqowkh2LQSnlW2vlxk1O1YPHtfWCmOcIZjGy04+-tEuB6Xkklk8ywh2urnHg7x-1X0e7MBtC+4iqudNMTA83cLXxn44RZB3I239Gyj-e9pZsYpQ9gl8PX-oYvzGNwzF5Y-plWNeJ1jO+tENxZ2N8XjLGy9GzRZOOYG2bMAeSjtfkxZICYINrVTifkMwr3LHATywjctW+WSTah6k0bqi3Q5LLjJ362IYBv2WOTmWuGdDgsy3hKajQBMr4jxBqlJKKog3Ari7RBNyYXNh4IdZIAhWkeMk1oknXVOcZ6zOOqZU2btu4x8Y8l+sE1ZWV9merGysnekdashnxNSpAIysoyOFWyzbyp02zvyNzmudC54o16Y2u+me7g1jc5jCmtjXdzk17cwebeXHn5sWmhBmXh5M6JVWbETiAYgKtPmWiL5yU2qQ-OynvzCp7SrzBVOAXl12V4gOusKl5XRQ3RidXzaDB5nd1i6l+1uhQta7PZqc1SkiCMpDAwKEOESeeEph6kWZr7LxMBXlx8xTbc8D68OIssMnhDTJv68lrZOSHHLTtuQ+DbduQ2Pb5J9fSQE30+XobfllfYFeINt7Xj4ig4mQjy6eg3+nAKOviaBhi3Pj+F342Go5t6mJt9t2u9NpIvamyLfu844rf-gYGMmS2hA0cfj242NbixliysfYt63OLADI27xb2MCX+1z262ziTftgXm7RAL+wWd-uGqH7T9zdSqFitoW7p8VjU3VYrs4bGr7h5q7BHbvDnQzXd8M4Eabv92W7fVoXTPYV3z2dzgRvc8rpmtrm5rBGwey6eWuj3VrvOrjWUdJxbWq7QjwCCI4ZJYH176usmQoH6YMQ+TJkfe-efMMin8gz5iU2+bPsBIL78p38zfYAtqnC+edoEzR0Lt4x6OOsplqg5IBfWrLWDsOylojtSGCHYNvLcQ5YAeWyHXl729Q99v5plpQTAkCoGAQhKUZB4fYUiE0DMwRxHKmjmnTG15PsYBTx20DBluV25bzN7NQ4LkfKJlH6tl6kxaWOsXdbTMjYzo+2N6Px2CjqiwGtouoGVbRjva1JcmM4XeHktv4+GswtHyCpp8hBReMzPJ9UoOZ7df-bTNAx4Lj9xC5lYBCmOkrLRyC2leIA-2SX0CQ+6KePuNOqYzTmU1+baeKmOnqpoC8VZIRb2KnFrPe4KZquuOJl7jxsx2a8c93fHeyzu4LW7sEbgnyVnyK3ensUabTiu0a9E5WWxPpr41x514o-sD2FrQ92c5zrY3j31rAu7J6veU3V2HbhMRDbLeVd2uyShp5Nd44UsvW9MaJ3GHjCPBql5MPMHWtzHHgJBNeAPIXYk6SNL2UjlBVztwE0jhO1Xw1207G-jeJvAjKrhV91ZCeRHpX7VpUnlwOKmdV7xjwW31YACKIARUIcm4dwvDXDwe5x47NuKPxa7gF4FyjIQv73ne2+Y0ZjVL3xA3lmLEMyUo7ubr4COQd2pmRBI8DHQlum684LUiXpHzzolNcehfs23dVznGPa5FWmXIt5loOxg5+uiGWTUz9kzM6y2g2XbRDgrWaE9sUPtD4gH27DeSgEcagGoTcPUG-NvmSW+IGMu0HMwqFOgTyq6y3voeNbwL6QGw6K513LJ3Diip9Nu5udYogFx3cp7yYFdMJ5BaifKdKnOJqJOH4AB4UKYr192lXNj-d5Wefu0uLHlL7+zBdLvHASzhB5M7ZFTNQfgSjzQvjw4bflyD5ka2-TJeufmaOg-M4iw4Ff0POfIjNuW3Mc+ea3vnGj-Wn84NtbGeLux4F5RZLVKPlbhj4S2cc-2K2Cgxjq987Zcuu273ZJ+G4++8vPu1nr7k5DSf3eB2Yt4z0O2e9wdA2o7-EhHpmUECnx4CGlliHuHc2kYOgPiakCWzhyERJ9jwHpwXbCs0w06PB0lxhtg8SOuzDwBu9TsHPBm-Hsrz0kW7HMeSYFrxs6jm4o-FgdXcb85Am9dJy2In6rqa7V+kD1fyduGL10MFi23hckDQAN0MODdIhc2Jz5jCZz9MGuZFOTlu3a-aA7tTPpHiw83k49YiyEbEMIRaBgBpD77VHnKzR5VCWP0rhZ2s7jhY98euPIg2K8V8L4yHzPrlhZwSatUOfStah8rcs69v5pnPLSg93SfQffXrL2Duyxe-wdfNEQxwupnuCkDmJqYvQb7i0VoF004C2kGoP8Yg-BW+nSX2oDuwws9m63tT3m5x+bMieWYYnsR5J9ItZe+NK7z7X6Dovyf8gXz9Rzrc0eqeuLgLzT-xe0-oAwXc71WzGatsbubbBL2x8S6Y90AzvnHw79S8Y+4v2Pu64r+hZO9l23HFPj1-B6NOU+HgNX9Nw14GvJu57cR1r+18nMKAuvPr3r-65EpBu9wIbkb+G-G892o3A57ZUOZlcM7ivnV0r1Gd7OVfpvzr5D6J-m+C-TzPHdb5t+281PhTj5lglN5HXFhePhPsJ3c-Eco3JHU+6n9-tp87pZPkR+nwoEZ-a22LKn-W2z408m2QXOnr4uC7eW024wC36S6pvtuuvQfmtLtPrnUVqUQC8uLOSZXVzDskedTXJCXfesufD3bnkO6e-+vnu8HwNsz4Q-mdWeX3L3uGxSds9b7iwCXjH9hL6D-wxfHJFX5l7V85fo30UH0R0f9FJvrTKb4gGf79EBj5rpvtqQiFlxEhjhfi7QlpjzaWiamrQd9mphYiRuk3uY6eG+biObmMopJ778eLWv1YLavvja7bWAflaJoqwfpH5key3sAGreYflmBbeertB4gWPRuS7Tq9HlY60elHt97Ue9jor4xWeLuLSikhfFd7Fu3Smqh084UCY74B79pL7EBR3tY6EudjtWbHceXKYB-EEwNIKQAp8lwAvoB9vj7SA+2qZiRI8PsrygQpNMoS5swCPZoswL6O0Q0capi47K+GXqn6a+RPju4iE8ahK4a+avlAFleMARV7BGnHi1Z5ebVmAGC0HvhN4i6rHkk6SEBwt16ogFvv15W+JQDb7DeYbn4gRulpvr5bmabnV4Zu2rjG5mmRvom6HmQYGvZAkm-sdaa2XMMg70awcufBlSGAmiBhIcsoMCICebKiAkgT4EP6qBGIHu7fernsHaYOHnlP5eekdk5bXuFnre6KG97u95r+VDjaow2y-slBGB9tiYFk+Saqr7Z+FFhC76eC7mra9uL1AzBQELkJhDaEmigwL7C5LJTCCA1MI-Cx2RihX7c+JAAGjMQ56DKgW2y7kZ6ruJnigFtCJeq8a8exANvY2KfDlLY1mDfgTpDBx8CMFSezbjJ4TBNfpC4Gesxio4KeajoX6-OJfgC5l++jlz7UW1fo84Z+lxkSj1+kZjcEwBCfsAGpBhdoEhdopgSg6j+v3ke7-eEzp56A2zQbM43uC-h0HWeq-mapPuT3r0E0OAVicjLSr7LeAK4E4nOKkcJPuSwG4GZI1wciEOMXI4hV+MKSCBwgSULgAyKKsjnoUgVH4yB6Abm77A6XtjpjBnjuYGquV-pjC3+zAPf7i6BkFqEX+rgZkZ2BEJgJJP+yOGriUgHMGITng+IF-64wP-jLgF0GoPzBSuDgR3YM6KABAFBOlXpm5hOfvo4Z2uIhCMBIhjwLFaeh6ZpeK8BovhS6pWDHhlZ7+EvikA3eAdmP51BJ7jZY4OJIZe5YmrQfd6L+z3v5aveVIeQ40hiNkyG7wkTJZgo45mGiCASzLjQBDCfMEcI7y4xJrSYs4Hs6rZgnHmiEKhWbkGBNu4rpEZPONPq7DQhPPnT5AhDPop5M+Rfhxb-Ohtuz7l+envO7KQ4wfI4hhUagH4YEnPv0a7eRLrlakBUvjS4JhiVu4HNysvj6CpmkjL4B7+5diqEzeW4ViCZBxYMf7O+Phm74fEsoDeGQBqygJ7t6NgV2HAB1XgZCygGXIgAUA3Lu-pNe1-qBHQA4EUmodeu8DoC8wUgEWzJeDQOqLTiFIFdplAoONAxak2NlPpABimqW5XB+mtIHke6HmXj9cCAGxBSCsoMqCF8R4TL5K+zHqeHneUYQeERhmLvrpDoOLmx6Xhu6gAAaopLoHHAd4Yf64aaobqHRQaiCcRughEeRoahFCvJEbwJvlSRMwB4CeAjuKNA0AF0-iBjjHCVMDCZBaTMB4iQR+rm4Hne9gS775eH4UqQiRJXpYHe+3obYFJh8Abk7Ce2MEU7YErPICD0u9Toy6vmzLtKafmcpj+Ycuypp04WRh4sqx8umHrvZMIu9D4hrGVIFrjg+IBEmzMwj8IrIeIm4KBTDAptGSFtBFIe7adBNnqWGrO9Ies7+2IzsQBjOE-hmFA+M-j54HWaPkdb9OSdBoBDOZevW54WCLgI7XByLnApounwfeG9q-wdMGAhHzlOEghPziz7ghC4ZCFaeBxq26nBa4W85nBCtqu7-wG4UJ6N+LwTy4qI8UTvb8mdeE-yMAIMMMYcO87iR7kRsoTPgNOwUe+YtObLhFHX2UUVy63hB-gYFq+gwW67GmctqAH+OgtI5E-hirr6F-h5Xn9jhBCgHJFnAakTEGyRqkYpHvKxEVV4mhHmlpHw+pIEeD6Rj8kZGoC2kOZhmRsSIkHYGJTshZy+CgI5FUBn9lwHS+8YWJHi+7EZx6cR+3oI5eR4ns-rk+kkUzbwhSQLI4bRS7qtGguunjMHVqqjlrbzRxfto5LRxtlCGTBK4WmCOu0np4bDhmfkSjLhpBM2CixlfutHTRswbNHSxynnOFqeujhz7PhR0T5Ej0m7vzYQcKLifJnyotn1ES2BFoi5EGnYR1E8CQCNTBkwu9LLhp0p4EfCMYOUdiB1SbQDNS4w6du4g7gt4PrjVBZlviHj+9QZP6Zh0ziD45hd3pZ6UhD7pVE9BxWjVGJmOrApYG4ZQAbjKi7MNzCn06zCDjqYrptoK7yRstTYcgdUfpE-cNQFrixydAAwLAsmIDZJZ094GxZlAXMMVF5hlIUv6FhK-iWGUOdDl7G3BfUQ8Fux-DkRb2x+svnh22Y0XzFwh5wSOG6x-PvI5jh4sYbGSxwISbHM+ssfOHqeCsStF-BUwXX5kR1huwFmOCocxFMxu4eQEkA6rLBrR0oMkGKxWtMaxF0AEkb9FSR7rkjH+oKMZf5DWmMPDEKRiEab6aRP7DjGEQeMe+KGRgLCZEkx7mrEiGhlEa6G2RjgSDGekYMZDHQBPvm5EkRGygipIeXMTbHJBqAUt4vUxQNiDyBmgIoEbMKgeszqB-iloGSUMUfmoKAooaQAiB3xGIFShkgTd5i2+CaU4kAwiaIkSh4gdKHMx+-voFOu9dpK6zW6MepECSSCdpENAqCXpHoJNQJgl00pkTgkCJJWLDEHBUCTE4GQcCYjEEafYTGY+hBCe+EFuoMRAH+hrwcI70JWmlpICRNoMJGiRH8aur7hHMUQGxhJASeEEBZ4S-SbxyfrzGgJ-MbvGax+8Qza-BGyrX6TyEsQAZnxSnhfFmxpfjfE7hbyubbHGe0W8b1uiflDEwBGITwKBImKInE-en1n97ueacc1HeeLQdnHtBZUcWErOBcSob9B5YSQJBMKUf+z2h0BLfJk22MGeDPw6IMEjwyzGIdFpetVmolqxPifk4AxmvpspuhBXu77FuP4c4kM2riTJG9gLXjr7QJs9hEFxBlyTolm+PXn65+BgbgEEdAQQaN6hBjvujHkxxTlUb5WFEYAZyBptOwlk0SgXUxcJagepi8JIVjoF6SvYPInih4iRIEg67MZQFAJJwKzHABb8cd5-xl4gwHeqi8RU7LxzwTj7i8mydc5bxySTvHbRI4XvLkWh8UrF8+LbmLEikYAEcEvoJwbCG5+k4ab7Mw1JEsF4QWINfLq46wQMQUYWxjsHswfiZTHjmuYTnF9Jk8SvpveFUbPFOeKYcnFphAPpM5NB2YfxKtE-strwckTGCgKhu1QNDI8qhECeBKEB2lbH1JTFmnRX6c3I9K527Ua8YEcBdFA40CXMHqS7CHDGSqRMEBkiISmBEtzG8GeIfkDtxx9O0AngSOr3FK4fXsxjXg8IHJgjxQoXxpypvSSQ6EmBYUqn9JH3kjYp+6ibInWBbWpNGrhJ8XknGxBSbOFaOV8RbFLhesfsFV+22hrEIh6gJUn-RpKRDLx+hKbybEpHsYElXhgCbikNGU8mh6RJTutwGkBu0M-GEBcFg-bfxB7Fw5wpk2ElTkI+ytOnSJgKawnApAxKCmcJYTNwlQpmgTCkCJM6WsnKh28f750Jeaq+EnJlCRjHNaVgULrAxhXqUykJD-hpE6EyCTpFoJBkSYnGRZidgnmRgAVZHGh3yvYm2J6oTAkqRCMX8A-Jvkb9pOpExuekiul6VSnXpB0bemaJJ-r2CwRM9BBFXJCuvhnwRliSaHIR3XDsI3aVyphGVhR9BZjmYRmC5C3gqMXxraJbia74eJnpF+FORQRoBEKha8WLKC+iGX8nChfTCdGVOgrjKFoBDLuKbPRLLmFGX27Tp9F32MYZOmMxOKU-EJWsSRxEi+XEReFBJcoDeF0xT6S5Eux0gfC7uxg0eSneRiSaMFXp9KZtG5JjFtOGghC0XLHXxQLqUnMp+sVyk02ZaTkkpJNKWknruCZqzyxKeEAeB4SVmBpZ6YvRNAzyY-jBfTQMXBmpJjx8qVml5p3QfZ7VRjniP41BqYce5apxIRnGz+iYHakDO3USsmbhN6RJ72ZGGf5n3xgWXJ48pkJknpHaoBunrnakBjnowGd2piZlJrbqtox6yBpbYMpQWQHqruVQJUk9hFHsclEAdqcgobg4Jp67GcMpvSRI8mnDwJtAYTEnSpKd4DSIVAHRCtnvKGaaVGZZiqaSZ5xqqfllJxrSQSHtJTUeHbA+ZWfdEyZc6UdEIpogZKHIpMAKELvA2XKdGsQ30esnfBGidJFrm0EQb6auaRhAkjWZpgvZ4J8oY+lZBLOotbs6I9ua5rWmTiuYvpeyfZFyugTisrzZsAQ+neJG8ch7SpomQ8w-yzAXlSJ4AyjQFOODugzHHhKiYmEKhqKdWY88dsSTCGasQMIoEmIoMckQxcik76fCFYSorMYBdG8maKuINorJabJAYqWp7MCdnrmCus4q2KIgPYq0gTilADWKWuS5ZuKyGer7gJpmSiG0gouQCCvp-hkTkKWjGktZumaTouYT2VriYri5OrojlyKUOTcn7mKuibkbkA4DxxCA7nE6COAfLCzDGSXMMeDsKt4MeA8CnQIIbz0jjnUY05lhP5ZgEguWeE7QlCloiMRAMPgD4AqcKELTAJxH2rZgnwIrDymS4C3z2iWxLawHEiAHIB7ISHggD1UheVwBKgMAK4DOizwNACUQfIESLTAaiBNj+A+QmELYAMADkKzA74JeZ7AeXBNj5CvgNMCT5OQnlygWOQnsD5CE2J8RUUaiP4CUQE2NGI4AOQvkJ1QNEHBEIAm+XYC75+Qu8ATYq+Z8SugSSNflEAu+bKBH5nxJRAj5j+U5BjEisK-kFClEHvkP5J+fkLic1+VYALIheTcJhA1ed3kSAveY4CwAMEMTBwRrnNAVEAeUOgXnImBYaqkKG3qpA4F0gHgWxmPeQyjIFA+aEB4F76s5CucEAAAD87AK4Bc4H6q5w7ANBVZRMFCBXPyLgHBXYCCw6+T0aMFzBRIC8F38EIUXA-BUQDLAawCIUIF-XCsCrA0hTACB5YQNwXEFeBTMoaFNoBgV2AUgIZpoAf4DoXAguBXYBGARhdQAmFehWnk2OphdID8FUBTAWjc8BSwUyIdBechEiaBboVmFsUdmCaFdgKijIFohUgXQAKhUhJmArgGEB4Fb8OIUiA3ANwWWFpBU5jEFjhdAX4AsBXACuFYhVbheFxgAEV+F7AAUXwefeamDkFpRSoW-wiRRjCkFGavIVuFAvOwXbAyRf7ipFzRfgBOFHeZEU8FVuJIUMAVBbRAwQsoOxAHEvgGQir5LpIAX+A7wGEJ5c7EGoir5nPG5yAFaiCvkn5qxYAUHEi+bKAoAnxLKAH5q+Y3m16LkF9CAF+QsAU-5YBRAW2AnRZkXZFcRdQB9FeRUUU+FJBXYDeCxRUEXQAIRRQVhF7RQsrBGbhbZh9FAxTEUW4DxQkXsASRQIVcFohQ8UqF1+G0XQFnRZ3mMQrgIoVrAAxUSIf57wMflT5zokmqbFwxfMWUQhxZejJogBXsVhClELKA0lj+UBroAV+XsCfE4+Z8Qj5lEPkKP54AC+gMoIAFMXj5tJXiU5Ck9MrBMldgP4C+A7wN-mLFQgX+CAFZCDsUTY7wHlzDFq+RQjm67gEIDyl+xYEAn5FCDazyl5xWoiklepQewUlW+cPlH5q+fkIN6YpUQDb5tJdvnvAnJWAXgaKwN4BnFa2uyW+AE2AsVgFPOC0rxAgBVGITYlEFKVClA2hQUYAgBRW6+AH+d-kmlU+RW6eADKOaXil4+XlzRAnxEmKfE8xYsXgAMgcADjA3klMVJiZCNMBUU4+UmKLF4wP1Sug-ovyWzF+Zecill5ZZWVhC7wJ8TRA0wPkJqI4xSfn+AtZfYrjIwAG8YgA2iIAUAAWh-mTlvgGojsQQpZOVxAeyIbD-EKxZRC+AnoavkAAky0q8AwZb2X9lU+dMDjlzgPyW+AYQigCH5soJ8SUE+Zf2D+6wZYvn+AfpceVnAIgGcWWlxpXSVgFDesGp2lTopuXOl0QCgBH5CpRMWmADgPsTm6LgABUHEaiO8AS0MxZmX5ClpUKVHE5ANsiucIVLaXBlC5dWUn50wP8TjA8pQECdlqpXqXbEEyFqV7AMxZ8TvAnodSXDF0QGQhkVnxKvmfAp8rwD5algM4BUVwAGcUXFoBVPngFDCJAXpFGRS4VvSChUoXPFdhTYWCJ8lb4V8ZohRiWrAoJYEW-FZRYgVaVCJSkWvFaRYXmSVVedJWUQEsESLYFrxXgWNgkRVIBwAyJc4UmVL6E6LCKRIurk2mxBZECPcnlbsmEJ7odqBtF7xYso+Vv4S1ohVllfYUxK+Ra8UdFjlXAXSVXbpJ5Ei2SbZVhAXlQLGTqURXAB2AhxiFX0WRsS8WRVhRUpXSAsVcZXxVzlQ8VqIthkSKdmzogkB2A+Op5UF5dxdJV5MSAKEUoFkQD7mFVrnI1XhGIVd4VFVo0HlXeVMVQCX+FMVS1VSVlVcgCsFHhYVgwQERUqB2VdgDULqQa6cwDvFGxINXRVw1blIhV1+GZUOFgiNNVOVtEO1UPFTxTBBHVEsIQTxMFzL1XnI3VUTyWcgVc5E3BeVc8zOM9lHlXMsfCAFUGV90o-TahOwJEDs8NOLtVPVpVcVXNVcVVkVtVc1bkUwQedodVOYx1WDUfVLxiFW8eeVVuksEeVTB6XpVVbYaY1ilSFXwWo1Wdl4mWaSKBL+IVUQAPuIVRipfed2dDWRAZLuwDahdgDUYhVh3iFVNV41RLns1RAGMaJVSamDUF5XIB9BfFsAD8WlFk+d47EwFukYVt5uQNnB5QKtYyX1UPYNnCDWhBdmBa1GAKUw+5EZrGZP8RtW3nSgyyOijlFlBXyAZCAAD6O1CBZ1UDF9VOcUgF2cLQWC8-uJbX1UPtfQX1UoIAwBP8Fte4W+17AJdUR1rnJ-xwlMdZ4VYAZ6pr7xAgZV3AWBwtYdyNF5yPVTq1KyrLU66KypVLGA-tXnUEaWBa2D+1utUXX4FDKAbXsAltSbXhBP4RNXsAFtcgCq19VNbXfgrtVpUDFTtS7V21-eXyAe1QlYEa2F-tbJDB1wQGHUGglFBLAZCSdVx461FoJ-wrK46Qq4bEgdTnXnUgRkhLb10gPVSNCQTk5gH1XddUjT1odU-xz1WDAvXYAydQaAr1WYJ-x5qa9tgQv10sI-T+18SJfVh1gsFHVI1tmHHU9FtmPfUp1yaCcTp1n6YLC513AIEYF1GZjXXK1HddrVl15dZrUoNGAE-WbyGyvrWV1mDQgBN1ykS3UqV8gpPWD5PdUPVQA-dTADO1vdaUXu1ntSPnj1F3g3UEN59YaC-119RLDz10gIvUP1EsNg1r1BGhvVyaW9Vbhd1fqJm7RqWiEgAr8GMOHW2YjVBjAf1CrqfUSNU9SHWz1PDbfV8NYDY-U9gqjdQnoqRjZZTAloFkaCN1qGZxg26VwNsBcNghaBYANSAFdWgWwDUCWLgTxUvVq+qdZA07JmdY409GsDfA1aVhdeXUBN7DWg1nJbDagDa11deE14NqkFY2m1LDebXkNoQDbX0N9taEAD1WTcPWhAo9V7U111+JPUX1Wjdw2V6x1fw3L1hjevXgx4jeY1BNu9Y3aLKijZ42gWpTOzyAkmTR40SFoFjQ10NvTSIAglI9Uw0TYgRrEVW4xgLI3yNf4G019NPRqUzfVmPICSmNDNuo2NNFwBw0NU5TTfVVN+jYI2GNr9SY3ZwshesAxNndWc07NM9U-xnNXNf6LuNEgGpVgNPjRA1Kw-jcTBnNwTY3YbEXzc03ZuwRrc1KFXdRQ221TzUoUDNg9eC2YlozWPX51oTYg3hNyDbE1YNUTbhkXNcTRaAsNiTYbUENRDTBkkNaTew3d1YLW7UO1tDVC1ktBTWM2pNp9cS1lNNzXs131S9QY2r1dTSfX+4fzQy1X1TLXo0sthzavXHNH6Gs118pTZw27NOjfs38th9Uc0dF6RdLVrpR9a+pP8stZ-zpFQAA diff --git a/server/api/musicbrainz/index.ts b/server/api/musicbrainz/index.ts index 2e6ee8ef..2607e94f 100644 --- a/server/api/musicbrainz/index.ts +++ b/server/api/musicbrainz/index.ts @@ -1,28 +1,44 @@ -import { BaseNodeBrainz } from 'nodebrainz'; -import type { mbArtist, mbRecording, mbReleaseGroup, mbRelease, mbWork} from './interfaces'; -import {mbArtistType, mbReleaseGroupType, mbWorkType} from './interfaces'; - -interface SearchOptions { - query: string; - page?: number; - limit?: number; - keywords?: string; - artistname?: string; - albumname?: string; - recordingname?: string; - tag?: string; -} +import 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; - tag?: string; // (part of) a tag attached to the artist + tags?: string[]; // (part of) a tag attached to the artist limit?: number; offset?: number; } interface RecordingSearchOptions { query: string; - tag?: string; // (part of) a tag attached to the recording + 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; @@ -32,7 +48,7 @@ interface RecordingSearchOptions { interface ReleaseSearchOptions { query: string; artistname?: string; // (part of) the name of any of the release artists - tag?: string; // (part of) a tag attached to the release + tags?: string[]; // (part of) a tag attached to the release limit?: number; offset?: number; } @@ -40,7 +56,7 @@ interface ReleaseSearchOptions { interface ReleaseGroupSearchOptions { query: string; artistname?: string; // (part of) the name of any of the release group artists - tag?: string; // (part of) a tag attached to the release group + tags?: string[]; // (part of) a tag attached to the release group limit?: number; offset?: number; } @@ -48,240 +64,221 @@ interface ReleaseGroupSearchOptions { interface WorkSearchOptions { query: string; artist?: string; // (part of) the name of an artist related to the work (e.g. a composer or lyricist) - tag?: string; // (part of) a tag attached to the work + tags?: string[]; // (part of) a tag attached to the work limit?: number; offset?: number; } -interface Tag { - name: string; - count: number; -} - -interface Area { - "sort-name": string - "type-id": string - "iso-3166-1-codes": string[] - type: string - disambiguation: string - name: string - id: string -} - -interface Media { - position: number - "track-count": number - format: string - "format-id": string - title: string -} - -interface ReleaseEvent { - area: Area - date: string -} - -interface RawArtist { - "sort-name": string - disambiguation: string - id: string - name: string - "type-id": string - type: string -} - -interface RawRecording { - length: number - video: boolean - title: string - id: string - disambiguation: string - tags: Tag[] -} - -interface RawReleaseGroup { - tags: Tag[], - "primary-type": string - "secondary-types": string[] - disambiguation: string - "first-release-date": string - "secondary-type-ids": string[] - releases: any[] - "primary-type-id": string - id: string - title: string -} - -interface RawRelease { - barcode: string - tags: Tag[] - disambiguation: string - packaging: string - "packaging-id": string - "release-events": ReleaseEvent[] - title: string - status: string - "text-representation": { - language: string - script: string - } - "status-id": string - "release-group": any - country: string - quality: string - date: string - id: string - media: Media[] -} - -interface RawWork { - disambiguation: string - attributes: any[] - id: string - "type-id": string - languages: string[] - type: string - tags: Tag[] - iswcs: string[] - title: string - language: string -} - -function searchOptionstoArtistSearchOptions(options: SearchOptions): ArtistSearchOptions { - const data : ArtistSearchOptions = { - query: options.query - } - if (options.tag) { - data.tag = options.tag; +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 { + } else { data.limit = 25; } if (options.page) { - data.offset = (options.page-1)*data.limit; + data.offset = (options.page - 1) * data.limit; } return data; } -function searchOptionstoRecordingSearchOptions(options: SearchOptions): RecordingSearchOptions { - const data : RecordingSearchOptions = { - query: options.query - } - if (options.tag) { - data.tag = options.tag; - } +function searchOptionstoReleaseSearchOptions( + options: SearchOptions +): ReleaseSearchOptions { + const data: ReleaseSearchOptions = { + query: options.query, + }; if (options.artistname) { data.artistname = options.artistname; } - if (options.albumname) { - data.release = options.albumname; + if (options.tags) { + data.tags = options.tags; } if (options.limit) { data.limit = options.limit; - } - else { + } else { data.limit = 25; } if (options.page) { - data.offset = (options.page-1)*data.limit; + data.offset = (options.page - 1) * data.limit; } return data; } -function searchOptionstoReleaseSearchOptions(options: SearchOptions): ReleaseSearchOptions { - const data : ReleaseSearchOptions = { - query: options.query - } - if (options.artistname) { - data.artistname = options.artistname; - } - if (options.tag) { - data.tag = options.tag; - } - if (options.limit) { - data.limit = options.limit; - } - else { - data.limit = 25; - } - if (options.page) { - data.offset = (options.page-1)*data.limit; - } - return data; +function 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 searchOptionstoReleaseGroupSearchOptions(options: SearchOptions): ReleaseGroupSearchOptions { - const data : ReleaseGroupSearchOptions = { - query: options.query - } - if (options.artistname) { - data.artistname = options.artistname; - } - if (options.tag) { - data.tag = options.tag; - } - if (options.limit) { - data.limit = options.limit; - } - else { - data.limit = 25; - } - if (options.page) { - data.offset = (options.page-1)*data.limit; - } - return data; +function 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 searchOptionstoWorkSearchOptions(options: SearchOptions): WorkSearchOptions { - const data : WorkSearchOptions = { - query: options.query - } - if (options.artistname) { - data.artist = options.artistname; - } - if (options.tag) { - data.tag = options.tag; - } - if (options.limit) { - data.limit = options.limit; +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}"`; } - else { - data.limit = 25; + if (search.tags) { + processedSearchParams.query += ` AND tag:"${search.tags.join( + '" AND tag:"' + )}"`; } - if (options.page) { - data.offset = (options.page-1)*data.limit; + 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 data; + return processedSearchParams; } class MusicBrainz extends BaseNodeBrainz { constructor() { - super({userAgent:'Overseer-with-lidar-support/0.0.1 ( https://github.com/ano0002/overseerr )'}); + super({ + userAgent: + 'Overseer-with-lidar-support/0.1 ( https://github.com/ano0002/overseerr )', + retryOn: true, + retryDelay: 3000, + retryCount: 3, + }); } public searchMulti = async (search: SearchOptions) => { try { const artistSearch = searchOptionstoArtistSearchOptions(search); - const recordingSearch = searchOptionstoRecordingSearchOptions(search); - const releaseGroupSearch = searchOptionstoReleaseGroupSearchOptions(search); const releaseSearch = searchOptionstoReleaseSearchOptions(search); - const workSearch = searchOptionstoWorkSearchOptions(search); const artistResults = await this.searchArtists(artistSearch); - const recordingResults = await this.searchRecordings(recordingSearch); - const releaseGroupResults = await this.searchReleaseGroups(releaseGroupSearch); const releaseResults = await this.searchReleases(releaseSearch); - const workResults = await this.searchWorks(workSearch); const combinedResults = { status: 'ok', artistResults, - recordingResults, - releaseGroupResults, releaseResults, - workResults }; return combinedResults; @@ -289,281 +286,659 @@ class MusicBrainz extends BaseNodeBrainz { return { status: 'error', artistResults: [], - recordingResults: [], - releaseGroupResults: [], releaseResults: [], - workResults: [] }; } }; - public searchArtists = async (search: ArtistSearchOptions) => { + public searchArtists = async ( + search: ArtistSearchOptions + ): Promise => { try { - const results = await this.search('artist', search); - return results; + 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) { - return []; + logger.error('Failed to search for artists', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve([])); } }; - public searchRecordings = async (search: RecordingSearchOptions) => { + public searchRecordings = async ( + search: RecordingSearchOptions + ): Promise => { try { - const results = await this.search('recording', search); - return results; + 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) { - return []; + logger.error('Failed to search for recordings', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve([])); } }; - public searchReleaseGroups = async (search: ReleaseGroupSearchOptions) => { + public searchReleaseGroups = ( + search: ReleaseGroupSearchOptions + ): Promise => { try { - const results = await this.search('release-group', search); - return results; + 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) { - return []; + logger.error('Failed to search for release groups', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve([])); } }; - public searchReleases = async (search: ReleaseSearchOptions) => { + public searchReleases = ( + search: ReleaseSearchOptions + ): Promise => { try { - const results = await this.search('release', search); - return results; + 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) { - return []; + logger.error('Failed to search for releases', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve([])); } }; - public searchWorks = async (search: WorkSearchOptions) => { + public searchWorks = (search: WorkSearchOptions): Promise => { try { - const results = await this.search('work', search); - return results; + 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) { - return []; + logger.error('Failed to search for works', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve([])); } }; - public getArtist = async (artistId : string): Promise => { + public searchTags = (query: string): Promise => { try { - const rawData = this.artist(artistId, {inc: 'tags+recordings+releases+release-groups+works'}); - const artist : mbArtist = { - id: rawData.id, - name: rawData.name, - sortName: rawData["sort-name"], - type: (rawData.type as mbArtistType) || mbArtistType.OTHER, - recordings: rawData.recordings.map((recording: RawRecording): mbRecording => { - return { - id: recording.id, - artist: [{ - id: rawData.id, - name: rawData.name, - sortName: rawData["sort-name"], - type: (rawData.type as mbArtistType) || mbArtistType.OTHER, - tags: rawData.tags.map((tag: Tag) => tag.name) - }], - title: recording.title, - length: recording.length, - tags: recording.tags.map((tag: Tag) => tag.name), + 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); } - }), - releases: rawData.releases.map((release: RawRelease): mbRelease => { - return { - id: release.id, - artist: [{ - id: rawData.id, - name: rawData.name, - sortName: rawData["sort-name"], - type: (rawData.type as mbArtistType) || mbArtistType.OTHER, - tags: rawData.tags.map((tag: Tag) => tag.name) - }], - title: release.title, - date: new Date(release.date), - tags: release.tags.map((tag: Tag) => tag.name), - } - }), - releaseGroups: rawData["release-groups"].map((releaseGroup: RawReleaseGroup): mbReleaseGroup => { - return { - id: releaseGroup.id, - artist: [{ - id: rawData.id, - name: rawData.name, - sortName: rawData["sort-name"], - type: (rawData.type as mbArtistType) || mbArtistType.OTHER, - tags: rawData.tags.map((tag: Tag) => tag.name) - }], - title: releaseGroup.title, - type: (releaseGroup["primary-type"] as mbReleaseGroupType) || mbReleaseGroupType.OTHER, - firstReleased: new Date(releaseGroup["first-release-date"]), - tags: releaseGroup.tags.map((tag: Tag) => tag.name), + }); + }); + } 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); + } } - }), - works: rawData.works.map((work: RawWork): mbWork => { - return { - id: work.id, - title: work.title, - type: (work.type as mbWorkType) || mbWorkType.OTHER, - artist: [{ - id: rawData.id, - name: rawData.name, - sortName: rawData["sort-name"], - type: (rawData.type as mbArtistType) || mbArtistType.OTHER, - tags: rawData.tags.map((tag: Tag) => tag.name) - }], - tags: work.tags.map((tag: Tag) => tag.name), + ); + }); + } 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); + } } - }), - tags: rawData.tags.map((tag: Tag) => tag.name), - }; - return artist; + ); + }); } catch (e) { - throw new Error(`[MusicBrainz] Failed to fetch artist details: ${e.message}`); + logger.error('Failed to get full artist', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve({} as mbArtist)); } }; - public getRecording = async (recordingId : string): Promise => { + public getRecordings = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { try { - const rawData = this.recording(recordingId, {inc: 'tags+artists+releases'}); - const recording : mbRecording = { - id: rawData.id, - title: rawData.title, - artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => { - return { - id: artist.artist.id, - name: artist.artist.name, - sortName: artist.artist["sort-name"], - type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER + 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); + } } - }), - length: rawData.length, - firstReleased: new Date(rawData["first-release-date"]), - tags: rawData.tags.map((tag: Tag) => tag.name), - }; - return recording; + ); + }); + } 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) { - throw new Error(`[MusicBrainz] Failed to fetch recording details: ${e.message}`); + logger.error('Failed to get recording', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve({} as mbRecording)); } }; - public async getReleaseGroup(releaseGroupId : string): Promise { + public getReleaseGroups = ( + artistId: string, + maxElements = 50, + startOffset = 0 + ): Promise => { try { - const rawData = this.releaseGroup(releaseGroupId, {inc: 'tags+artists+releases'}); - const releaseGroup : mbReleaseGroup = { - id: rawData.id, - title: rawData.title, - artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => { - return { - id: artist.artist.id, - name: artist.artist.name, - sortName: artist.artist["sort-name"], - type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER + 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); + } } - }), - type: (rawData["primary-type"] as mbReleaseGroupType) || mbReleaseGroupType.OTHER, - firstReleased: new Date(rawData["first-release-date"]), - tags: rawData.tags.map((tag: Tag) => tag.name), - }; - return releaseGroup; + ); + }); } catch (e) { - throw new Error(`[MusicBrainz] Failed to fetch release group details: ${e.message}`); + logger.error('Failed to get release-groups by artist', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve([])); } }; - public async getRelease(releaseId : string): Promise { + public getReleaseGroup = ( + releaseGroupId: string + ): Promise => { try { - const rawData = this.release(releaseId, {inc: 'tags+artists+recordings'}); - const release : mbRelease = { - id: rawData.id, - title: rawData.title, - artist: rawData["artist-credit"].map((artist: {artist: RawArtist}) => { - return { - id: artist.artist.id, - name: artist.artist.name, - sortName: artist.artist["sort-name"], - type: (artist.artist.type as mbArtistType) || mbArtistType.OTHER + 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); + } } - }), - date: new Date(rawData["release-events"][0].date), - tracks: rawData.media.map((media: { - "track-count": number - title: string - format: string - position: number - "track-offset": number - tracks: { - title: string - position: number - id: string - length: number - recording: { - disambiguation: string - "first-release-date": string - title: string - id: string - length: number - tags: Tag[] - video: boolean - } - number: string - }[]; - "format-id": string - }) => { - return media.tracks.map((track: { - title: string - position: number - id: string - length: number - recording: { - disambiguation: string - "first-release-date": string - title: string - id: string - length: number - tags: Tag[] - video: boolean - } + ); + }); + } catch (e) { + logger.error('Failed to get release-group', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => + resolve({} as mbReleaseGroup) + ); + } + }; - number: string - }) => { - return { - id: track.id, - title: track.title, - length: track.recording.length, - tags: track.recording.tags.map((tag: Tag) => tag.name), + 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); + } + } + ); + }) + ); } - }) - }).flat(), - tags: rawData.tags.map((tag: Tag) => tag.name), - }; - return release; + 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) { - throw new Error(`[MusicBrainz] Failed to fetch release details: ${e.message}`); + logger.error('Failed to get releases by artist', { + label: 'MusicBrainz', + message: e.message, + }); + return new Promise((resolve) => resolve([])); } }; - public async getWork(workId : string): Promise { + public getRelease = (releaseId: string): Promise => { try { - const rawData = this.work(workId, {inc: 'tags+artist-rels'}); - const work : mbWork = { - id: rawData.id, - title: rawData.title, - type: (rawData.type as mbWorkType) || mbWorkType.OTHER, - artist: rawData.relations.map((relation: {artist: RawArtist}) => { - return { - id: relation.artist.id, - name: relation.artist.name, - sortName: relation.artist["sort-name"], - type: (relation.artist.type as mbArtistType) || mbArtistType.OTHER + 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); + } } - }), - tags: rawData.tags.map((tag: Tag) => tag.name), - }; - return work; + ); + }); } catch (e) { - throw new Error(`[MusicBrainz] Failed to fetch work details: ${e.message}`); + 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 index fcfc7c3f..78b005a8 100644 --- a/server/api/musicbrainz/interfaces.ts +++ b/server/api/musicbrainz/interfaces.ts @@ -1,5 +1,11 @@ // Purpose: Interfaces for MusicBrainz data. +export interface mbDefaultType { + media_type: string; + id: string; + tags: string[]; +} + export enum mbArtistType { PERSON = 'Person', GROUP = 'Group', @@ -7,10 +13,10 @@ export enum mbArtistType { CHOIR = 'Choir', CHARACTER = 'Character', OTHER = 'Other', -}; +} -export interface mbArtist { - id: string; +export interface mbArtist extends mbDefaultType { + media_type: 'artist'; name: string; sortName: string; type: mbArtistType; @@ -22,27 +28,24 @@ export interface mbArtist { area?: string; beginDate?: string; endDate?: string; - tags: string[]; -}; +} -export interface mbRecording { - id: string; +export interface mbRecording extends mbDefaultType { + media_type: 'recording'; title: string; artist: mbArtist[]; length: number; firstReleased?: Date; - tags: string[]; -}; +} -export interface mbRelease { - id: string; +export interface mbRelease extends mbDefaultType { + media_type: 'release'; title: string; artist: mbArtist[]; date?: Date; tracks?: mbRecording[]; - tags: string[]; -}; - + releaseGroup?: mbReleaseGroup; +} export enum mbReleaseGroupType { ALBUM = 'Album', @@ -50,17 +53,16 @@ export enum mbReleaseGroupType { EP = 'EP', BROADCAST = 'Broadcast', OTHER = 'Other', -}; +} -export interface mbReleaseGroup { - id: string; +export interface mbReleaseGroup extends mbDefaultType { + media_type: 'release-group'; title: string; artist: mbArtist[]; type: mbReleaseGroupType; firstReleased?: Date; releases?: mbRelease[]; - tags: string[]; -}; +} export enum mbWorkType { ARIA = 'Aria', @@ -93,13 +95,193 @@ export enum mbWorkType { MUSICAL = 'Musical', INCIDENTAL_MUSIC = 'Incidental music', OTHER = 'Other', -}; - +} -export interface mbWork { - id: string; +export interface mbWork extends mbDefaultType { + media_type: 'work'; title: string; type: mbWorkType; artist: mbArtist[]; - tags: string[]; -}; +} + +export interface Artist { + 'end-area': Area; + tags: Tag[]; + name: string; + country: string; + ipis: string[]; + gender: string; + area: Area; + begin_area: Area; + id: string; + releases: Release[]; + 'type-id': string; + 'begin-area': Area; + isnis: string[]; + recordings: Recording[]; + 'sort-name': string; + 'release-groups': Group[]; + works: Work[]; + type: string; + 'gender-id': string; + disambiguation: string; + end_area: Area; + 'life-span': LifeSpan; +} + +export interface Tag { + count: number; + name: string; +} + +export interface Area { + type: string; + disambiguation: string; + 'iso-3166-1-codes'?: string[]; + 'type-id': string; + id: string; + 'sort-name': string; + name: string; +} + +export interface Release { + 'packaging-id'?: string; + title: string; + 'release-events'?: Event[]; + tags: Tag[]; + country?: string; + status: string; + 'release-group': Group; + quality: string; + media: Medium[]; + date?: string; + packaging?: string; + disambiguation: string; + barcode?: string; + 'status-id': string; + 'text-representation': TextRepresentation; + id: string; + 'cover-art-archive': CoverArtArchive; + 'artist-credit': ArtistCredit[]; +} + +export interface CoverArtArchive { + artwork: boolean; + back: boolean; + count: number; + darkened: boolean; + front: boolean; +} + +export interface ArtistCredit { + name: string; + joinphrase: string; + artist: Artist; +} + +export interface Event { + area?: Area; + date: string; +} + +export interface Medium { + position: number; + 'format-id': string; + format: string; + title: string; + 'track-count': number; + 'track-offset'?: number; + tracks?: Track[]; +} + +export interface Track { + title: string; + position: number; + number: string; + recording: Recording; + length: number; + id: string; +} + +export interface TextRepresentation { + language: string; + script: string; +} + +export interface Recording { + title: string; + tags: Tag[]; + disambiguation: string; + id: string; + releases: Release[]; + 'first-release-date': string; + length: number; + 'artist-credit': ArtistCredit[]; + video: boolean; +} + +export interface Group { + id: string; + releases: Release[]; + 'first-release-date': string; + 'primary-type': string; + tags: Tag[]; + 'secondary-types': string[]; + disambiguation: string; + 'secondary-type-ids': string[]; + 'primary-type-id': string; + title: string; + 'artist-credit': ArtistCredit[]; +} + +export interface Work { + attributes: Attribute[]; + language: string; + type: string; + disambiguation: string; + id: string; + 'type-id': string; + iswcs: string[]; + title: string; + tags: Tag[]; + languages: string[]; + relations: Relation[]; +} + +export interface Relation { + type: string; + attributes: Attribute[]; + begin: string; + 'target-credit': string; + end: string; + 'type-id': string; + direction: string; + ended: boolean; + 'target-type': string; + 'source-credit': string; + artist: Artist; +} + +export interface Attribute { + 'type-id': string; + type: string; + value: string; +} + +export interface LifeSpan { + ended: boolean; + end: string; + begin: string; +} + +export interface SearchOptions { + query: string; + page?: number; + limit?: number; + keywords?: string; + artistname?: string; + albumname?: string; + recordingname?: string; + tags?: string[]; + tag?: string; +} 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/lidarr.ts b/server/api/servarr/lidarr.ts index 4920671f..3bfcb444 100644 --- a/server/api/servarr/lidarr.ts +++ b/server/api/servarr/lidarr.ts @@ -1,16 +1,28 @@ import logger from '@server/logger'; import ServarrBase from './base'; -export interface LidarrMusicOptions { - title: string; +export interface LidarrAlbumOptions { + profileId: number; qualityProfileId: number; - tags: number[]; + rootFolderPath: string; + title: string; + mbId: string; + monitored: boolean; + tags: string[]; + searchNow: boolean; +} + +export interface LidarrArtistOptions { profileId: number; - year: number; + qualityProfileId: number; rootFolderPath: string; - mbId: number; - monitored?: boolean; - searchNow?: boolean; + mbId: string; + monitored: boolean; + tags: string[]; + searchNow: boolean; + monitorNewItems: string; + monitor: string; + searchForMissingAlbums: boolean; } export interface LidarrMusic { @@ -19,7 +31,6 @@ export interface LidarrMusic { isAvailable: boolean; monitored: boolean; mbId: number; - imdbId: string; titleSlug: string; folderName: string; path: string; @@ -29,188 +40,351 @@ export interface LidarrMusic { hasFile: boolean; } +export interface LidarrAlbum { + title: string; + disambiguation: string; + overview: string; + artistId: number; + foreignAlbumId: string; + monitored: boolean; + anyReleaseOk: boolean; + profileId: number; + duration: number; + albumType: string; + secondaryTypes: string[]; + mediumCount: number; + ratings: Ratings; + releaseDate: string; + releases: LidarrRelease[]; + genres: string[]; + media: Medium[]; + artist: LidarrArtist; + images: Image[]; + links: Link[]; + statistics: Statistics; + grabbed: boolean; + id: number; +} + +export interface LidarrArtist { + addOptions?: { monitor: string; searchForMissingAlbums: boolean }; + artistMetadataId: number; + status: string; + ended: boolean; + artistName: string; + foreignArtistId: string; + tadbId: number; + discogsId: number; + overview: string; + artistType: string; + disambiguation: string; + links: Link[]; + images: Image[]; + path: string; + qualityProfileId: number; + metadataProfileId: number; + monitored: boolean; + monitorNewItems: string; + rootFolderPath?: string; + genres: string[]; + cleanName: string; + sortName: string; + tags: Tag[]; + added: string; + ratings: Ratings; + statistics: Statistics; + id: number; +} + +export interface LidarrRelease { + id: number; + albumId: number; + foreignReleaseId: string; + title: string; + status: string; + duration: number; + trackCount: number; + media: Medium[]; + mediumCount: number; + disambiguation: string; + country: string[]; + label: string[]; + format: string; + monitored: boolean; +} + +export interface Link { + url: string; + name: string; +} + +export interface Ratings { + votes: number; + value: number; +} + +export interface Statistics { + albumCount?: number; + trackFileCount: number; + trackCount: number; + totalTrackCount: number; + sizeOnDisk: number; + percentOfTracks: number; +} + +export interface Image { + url: string; + coverType: string; + extension: string; + remoteUrl: string; +} + +export interface Tag { + name: string; + count: number; +} + +export interface Medium { + mediumNumber: number; + mediumName: string; + mediumFormat: string; +} class LidarrAPI extends ServarrBase<{ musicId: number }> { + static lastArtistsUpdate = 0; + static artists: LidarrArtist[] = []; + static delay = 1000 * 60; constructor({ url, apiKey }: { url: string; apiKey: string }) { super({ url, apiKey, cacheName: 'lidarr', apiName: 'Lidarr' }); + if (LidarrAPI.lastArtistsUpdate < Date.now() - LidarrAPI.delay) { + this.getArtists(); + } } - public getMusics = async (): Promise => { + public getArtists = (): void => { try { - const response = await this.axios.get('/music'); + 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}`); + } + }; - return response.data; + 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] Failed to retrieve musics: ${e.message}`); + throw new Error(`[Lidarr] ${e.message}`); } }; - public getMusic = async ({ id }: { id: number }): Promise => { + public async getArtistByMusicBrainzId(mbId: string): Promise { try { - const response = await this.axios.get(`/music/${id}`); + 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 music: ${e.message}`); + throw new Error(`[Lidarr] Failed to retrieve albums: ${e.message}`); } }; - public async getMusicBymbId(id: number): Promise { + public async getAlbum({ + artistId, + albumId, + }: { + artistId?: number; + foreignAlbumId?: string; + albumId?: number; + }): Promise { try { - const response = await this.axios.get('/music/lookup', { + const response = await this.axios.get('/album', { params: { - term: `musicbrainz:${id}`, + artistId, + albumId, }, }); + return response.data; + } catch (e) { + throw new Error(`[Lidarr] Failed to retrieve album: ${e.message}`); + } + } + public async getAlbumByMusicBrainzId(mbId: string): Promise { + try { + const response = await this.axios.get('/album/lookup', { + params: { + term: `mbid:` + mbId, + }, + }); if (!response.data[0]) { - throw new Error('Music not found'); + throw new Error('Album not found'); } return response.data[0]; } catch (e) { - logger.error('Error retrieving music by MUSICBRAINZ ID', { - label: 'Lidarr API', + logger.error('Error retrieving album by MusicBrainz ID', { + label: 'Midarr API', errorMessage: e.message, - mbId: id, + mbId: mbId, }); - throw new Error('Music not found'); + throw new Error('Album not found'); } } - public addMusic = async ( - options: LidarrMusicOptions - ): Promise => { + public addAlbum = async ( + options: LidarrAlbumOptions + ): Promise => { try { - const music = await this.getMusicBymbId(options.mbId); + const album = await this.getAlbumByMusicBrainzId(options.mbId); - if (music.hasFile) { + if (album.id) { logger.info( - 'Title already exists and is available. Skipping add and returning success', - { - label: 'Lidarr', - music, - } + 'Album is already monitored in Lidarr. Starting search for download.', + { label: 'Lidarr' } ); - return music; - } - - // music exists in Lidarr but is neither downloaded nor monitored - if (music.id && !music.monitored) { - const response = await this.axios.put(`/music`, { - ...music, - title: options.title, - qualityProfileId: options.qualityProfileId, - profileId: options.profileId, - titleSlug: options.mbId.toString(), - mbId: options.mbId, - year: options.year, - tags: options.tags, - rootFolderPath: options.rootFolderPath, - monitored: options.monitored, - addOptions: { - searchForMusic: options.searchNow, - }, + this.axios.post(`/command`, { + name: 'AlbumSearch', + albumIds: [album.id], }); - - if (response.data.monitored) { - logger.info( - 'Found existing title in Lidarr and set it to monitored.', - { - label: 'Lidarr', - musicId: response.data.id, - musicTitle: response.data.title, - } - ); - logger.debug('Lidarr update details', { - label: 'Lidarr', - music: response.data, - }); - - if (options.searchNow) { - this.searchMusic(response.data.id); - } - - return response.data; - } else { - logger.error('Failed to update existing music in Lidarr.', { - label: 'Lidarr', - options, - }); - throw new Error('Failed to update existing music in Lidarr'); - } + return album; } - if (music.id) { - logger.info( - 'Music is already monitored in Lidarr. Skipping add and returning success', - { label: 'Lidarr' } - ); - return music; - } + const artist = album.artist; - const response = await this.axios.post(`/music`, { + artist.monitored = true; + artist.monitorNewItems = 'all'; + artist.qualityProfileId = options.qualityProfileId; + artist.rootFolderPath = options.rootFolderPath; + artist.addOptions = { + monitor: 'none', + searchForMissingAlbums: true, + }; + album.anyReleaseOk = true; + + const response = await this.axios.post(`/album/`, { + ...album, title: options.title, qualityProfileId: options.qualityProfileId, profileId: options.profileId, - titleSlug: options.mbId.toString(), - mbId: options.mbId, - year: options.year, - rootFolderPath: options.rootFolderPath, - monitored: options.monitored, + foreignAlbumId: options.mbId.toString(), tags: options.tags, + monitored: options.monitored, + artist: artist, + rootFolderPath: options.rootFolderPath, addOptions: { - searchForMusic: options.searchNow, + searchForNewAlbum: options.searchNow, }, }); if (response.data.id) { logger.info('Lidarr accepted request', { label: 'Lidarr' }); - logger.debug('Lidarr add details', { - label: 'Lidarr', - music: response.data, - }); } else { - logger.error('Failed to add music to Lidarr', { + logger.error('Failed to add album to Lidarr', { label: 'Lidarr', options, }); - throw new Error('Failed to add music to Lidarr'); + throw new Error('Failed to add album to Lidarr'); } return response.data; } catch (e) { - logger.error( - 'Failed to add music to Lidarr. This might happen if the music already exists, in which case you can safely ignore this error.', - { - label: 'Lidarr', - errorMessage: e.message, - options, - response: e?.response?.data, - } - ); - throw new Error('Failed to add music to Lidarr'); + logger.error('Error adding album by MUSICBRAINZ ID', { + label: 'Lidarr API', + errorMessage: e.message, + mbId: options.mbId, + }); + throw new Error(`[Lidarr] Failed to add album: ${options.mbId}`); } }; - public async searchMusic(musicId: number): Promise { - logger.info('Executing music search command', { - label: 'Lidarr API', - musicId, - }); - + public addArtist = async ( + options: LidarrArtistOptions + ): Promise => { try { - await this.runCommand('MusicsSearch', { musicIds: [musicId] }); + const artist = await this.getArtistByMusicBrainzId(options.mbId); + if (artist.id) { + logger.info('Artist is already monitored in Lidarr. Skipping add.', { + label: 'Lidarr', + artistId: artist.id, + artistName: artist.artistName, + }); + return artist; + } + + const response = await this.axios.post('/artist', { + ...artist, + qualityProfileId: options.qualityProfileId, + metadataProfileId: options.profileId, + monitored: true, + monitorNewItems: options.monitorNewItems, + rootFolderPath: options.rootFolderPath, + addOptions: { + monitor: options.monitor, + searchForMissingAlbums: options.searchForMissingAlbums, + }, + }); + + if (response.data.id) { + logger.info('Lidarr accepted request', { label: 'Lidarr' }); + } else { + logger.error('Failed to add artist to Lidarr', { + label: 'Lidarr', + mbId: options.mbId, + }); + throw new Error('Failed to add artist to Lidarr'); + } + return response.data; } catch (e) { - logger.error( - 'Something went wrong while executing Lidarr music search.', - { - label: 'Lidarr API', - errorMessage: e.message, - musicId, - } - ); + logger.error('Error adding artist by MUSICBRAINZ ID', { + label: 'Lidarr API', + errorMessage: e.message, + mbId: options.mbId, + }); + throw new Error(`[Lidarr] Failed to add artist: ${options.mbId}`); } - } + }; } export default LidarrAPI; 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 51b47116..6f56a314 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -11,6 +11,14 @@ export enum MediaType { MUSIC = 'music', } +export enum SecondaryType { + ARTIST = 'artist', + RELEASE_GROUP = 'release-group', + RELEASE = 'release', + RECORDING = 'recording', + WORK = 'work', +} + export enum MediaStatus { UNKNOWN = 1, PENDING, diff --git a/server/entity/Media.ts b/server/entity/Media.ts index cf9e2eb5..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'; @@ -20,25 +21,35 @@ import { import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; import Season from './Season'; -import LidarrAPI from '@server/api/servarr/lidarr'; @Entity() class Media { public static async getRelatedMedia( - tmdbIds: number | number[] + tmdbIds: number | number[] = [], + mbIds: string | string[] = [] ): Promise { const mediaRepository = getRepository(Media); try { - let finalIds: number[]; + let finalTmdbIds: number[] = []; if (!Array.isArray(tmdbIds)) { - finalIds = [tmdbIds]; + finalTmdbIds = [tmdbIds]; + } else { + finalTmdbIds = tmdbIds; + } + + let finalMusicBrainzIds: string[] = []; + if (!Array.isArray(mbIds)) { + finalMusicBrainzIds = [mbIds]; } else { - finalIds = tmdbIds; + finalMusicBrainzIds = mbIds; } const media = await mediaRepository.find({ - where: { tmdbId: In(finalIds) }, + where: [ + { tmdbId: In(finalTmdbIds) }, + { mbId: In(finalMusicBrainzIds) }, + ], }); return media; @@ -49,18 +60,43 @@ class Media { } public static async getMedia( - id: number, + id: number | string, mediaType: MediaType ): Promise { 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; @@ -73,13 +109,16 @@ class Media { @Column({ type: 'varchar' }) public mediaType: MediaType; + @Column({ type: 'varchar', nullable: true }) + public secondaryType?: string; + @Column({ nullable: true }) @Index() - public tmdbId: number; + public tmdbId?: number; @Column({ nullable: true }) @Index() - public mbId: number; + public mbId?: string; @Column({ nullable: true }) @Index() @@ -147,6 +186,12 @@ class Media { @Column({ nullable: true, type: 'varchar' }) public ratingKey4k?: string | null; + @Column({ nullable: true, type: 'varchar' }) + public title?: string; + + @Column({ nullable: true, type: 'varchar' }) + public parentRatingKey?: number; + public serviceUrl?: string; public serviceUrl4k?: string; public downloadStatus?: DownloadingItem[] = []; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 1bec10b2..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 { @@ -5,18 +12,21 @@ import type { SonarrSeries, } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import type { LidarrMusicOptions } from '@server/api/servarr/lidarr'; -import LidarrAPI from '@server/api/servarr/lidarr'; import TheMovieDb from '@server/api/themoviedb'; -import MusicBrainz from '@server/api/musicbrainz'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus, MediaType, + SecondaryType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; -import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; +import type { + MusicRequestBody, + TvRequestBody, + VideoRequestBody, +} from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; @@ -51,7 +61,7 @@ type MediaRequestOptions = { @Entity() export class MediaRequest { public static async request( - requestBody: MediaRequestBody, + requestBody: VideoRequestBody | TvRequestBody | MusicRequestBody, user: User, options: MediaRequestOptions = {} ): Promise { @@ -115,6 +125,18 @@ export class MediaRequest { requestBody.is4k ? '4K ' : '' }series requests.` ); + } else if ( + requestBody.mediaType === MediaType.MUSIC && + !requestUser.hasPermission( + [Permission.REQUEST, Permission.REQUEST_MUSIC], + { + type: 'or', + } + ) + ) { + throw new RequestPermissionError( + `You do not have permission to make music requests.` + ); } const quotas = await requestUser.getQuota(); @@ -123,49 +145,118 @@ export class MediaRequest { throw new QuotaRestrictedError('Movie Quota exceeded.'); } else if (requestBody.mediaType === MediaType.TV && quotas.tv.restricted) { throw new QuotaRestrictedError('Series Quota exceeded.'); + } else if ( + requestBody.mediaType === MediaType.MUSIC && + quotas.music.restricted + ) { + throw new QuotaRestrictedError('Music Quota exceeded.'); } - const tmdbMedia = + const metaMedia = requestBody.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: requestBody.mediaId }) + : requestBody.mediaType === MediaType.MUSIC + ? requestBody.secondaryType === SecondaryType.RELEASE + ? await musicbrainz.getRelease(requestBody.mediaId) + : await musicbrainz.getArtist(requestBody.mediaId) : await tmdb.getTvShow({ tvId: requestBody.mediaId }); - let media = await mediaRepository.findOne({ - where: { - tmdbId: requestBody.mediaId, - mediaType: requestBody.mediaType, - }, - relations: ['requests'], - }); + let media = + requestBody.mediaType === MediaType.MUSIC + ? await mediaRepository.findOne({ + where: { + mbId: requestBody.mediaId, + mediaType: MediaType.MUSIC, + secondaryType: requestBody.secondaryType, + }, + relations: ['requests'], + }) + : await mediaRepository.findOne({ + where: { + tmdbId: Number(metaMedia.id), // Convert tmdbId to number + mediaType: requestBody.mediaType, + }, + relations: ['requests'], + }); if (!media) { - media = new Media({ - tmdbId: tmdbMedia.id, - tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, - status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, - mediaType: requestBody.mediaType, - }); - } else { - if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { - media.status = MediaStatus.PENDING; + if (requestBody.mediaType === MediaType.MUSIC) { + media = new Media({ + mbId: requestBody.mediaId, + status: MediaStatus.PENDING, + mediaType: MediaType.MUSIC, + secondaryType: requestBody.secondaryType, + title: (metaMedia as mbRelease).title, + }); + } else if (requestBody.mediaType === MediaType.MOVIE) { + media = new Media({ + tmdbId: requestBody.mediaId, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k + ? MediaStatus.PENDING + : MediaStatus.UNKNOWN, + mediaType: requestBody.mediaType, + }); + } else { + let tvdbId: number | undefined; + if (requestBody.mediaType === MediaType.TV) { + const tvMedia = metaMedia as TmdbTvDetails; + tvdbId = tvMedia.external_ids?.tvdb_id; + } + + media = new Media({ + tmdbId: requestBody.mediaId, + tvdbId: (requestBody as TvRequestBody).tvdbId ?? tvdbId, + status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, + status4k: requestBody.is4k + ? MediaStatus.PENDING + : MediaStatus.UNKNOWN, + mediaType: requestBody.mediaType, + }); } + } else { + if (media.mediaType !== MediaType.MUSIC) { + if ( + media.status === MediaStatus.UNKNOWN && + !(requestBody as VideoRequestBody | TvRequestBody).is4k + ) { + media.status = MediaStatus.PENDING; + } - if (media.status4k === MediaStatus.UNKNOWN && requestBody.is4k) { - media.status4k = MediaStatus.PENDING; + if ( + media.status4k === MediaStatus.UNKNOWN && + (requestBody as VideoRequestBody | TvRequestBody).is4k + ) { + media.status4k = MediaStatus.PENDING; + } + } else { + if (media.status === MediaStatus.UNKNOWN) { + media.status = MediaStatus.PENDING; + } } } - const existing = await requestRepository - .createQueryBuilder('request') - .leftJoin('request.media', 'media') - .leftJoinAndSelect('request.requestedBy', 'user') - .where('request.is4k = :is4k', { is4k: requestBody.is4k }) - .andWhere('media.tmdbId = :tmdbId', { tmdbId: tmdbMedia.id }) - .andWhere('media.mediaType = :mediaType', { - mediaType: requestBody.mediaType, - }) - .getMany(); + const existing = + requestBody.mediaType !== MediaType.MUSIC + ? await requestRepository + .createQueryBuilder('request') + .leftJoin('request.media', 'media') + .leftJoinAndSelect('request.requestedBy', 'user') + .where('request.is4k = :is4k', { is4k: requestBody.is4k }) + .andWhere('media.tmdbId = :tmdbId', { tmdbId: metaMedia.id }) + .andWhere('media.mediaType = :mediaType', { + mediaType: requestBody.mediaType, + }) + .getMany() + : await requestRepository + .createQueryBuilder('request') + .leftJoin('request.media', 'media') + .leftJoinAndSelect('request.requestedBy', 'user') + .where('media.mbId = :mbId', { mbId: requestBody.mediaId }) + .andWhere('media.mediaType = :mediaType', { + mediaType: requestBody.mediaType, + }) + .getMany(); if (existing && existing.length > 0) { // If there is an existing movie request that isn't declined, don't allow a new one. @@ -174,7 +265,7 @@ export class MediaRequest { existing[0].status !== MediaRequestStatus.DECLINED ) { logger.warn('Duplicate request for media blocked', { - tmdbId: tmdbMedia.id, + tmdbId: metaMedia.id, mediaType: requestBody.mediaType, is4k: requestBody.is4k, label: 'Media Request', @@ -244,16 +335,17 @@ export class MediaRequest { await requestRepository.save(request); return request; - } else { - const tmdbMediaShow = tmdbMedia as Awaited< + } else if (requestBody.mediaType === MediaType.TV) { + const metaMediaShow = metaMedia as Awaited< ReturnType >; + const requestedSeasons = - requestBody.seasons === 'all' - ? tmdbMediaShow.seasons + (requestBody as TvRequestBody).seasons === 'all' + ? metaMediaShow.seasons .map((season) => season.season_number) .filter((sn) => sn > 0) - : (requestBody.seasons as number[]); + : ((requestBody as TvRequestBody).seasons as number[]); let existingSeasons: number[] = []; // We need to check existing requests on this title to make sure we don't double up on seasons that were @@ -366,6 +458,44 @@ export class MediaRequest { isAutoRequest: options.isAutoRequest ?? false, }); + await requestRepository.save(request); + return request; + } else { + await mediaRepository.save(media); + + const request = new MediaRequest({ + type: MediaType.MUSIC, + secondaryType: (requestBody as MusicRequestBody).secondaryType, + media, + requestedBy: requestUser, + // If the user is an admin or has the "auto approve" permission, automatically approve the request + status: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? MediaRequestStatus.APPROVED + : MediaRequestStatus.PENDING, + modifiedBy: user.hasPermission( + [ + Permission.AUTO_APPROVE, + Permission.AUTO_APPROVE_MUSIC, + Permission.MANAGE_REQUESTS, + ], + { type: 'or' } + ) + ? user + : undefined, + serverId: requestBody.serverId, + profileId: requestBody.profileId, + rootFolder: requestBody.rootFolder, + tags: requestBody.tags, + isAutoRequest: options.isAutoRequest ?? false, + }); + await requestRepository.save(request); return request; } @@ -406,6 +536,9 @@ export class MediaRequest { @Column({ type: 'varchar' }) public type: MediaType; + @Column({ type: 'varchar', nullable: true }) + public secondaryType?: SecondaryType; + @RelationCount((request: MediaRequest) => request.seasons) public seasonCount: number; @@ -471,7 +604,11 @@ export class MediaRequest { @AfterUpdate() @AfterInsert() public async sendMedia(): Promise { - await Promise.all([this.sendToRadarr(), this.sendToSonarr()]); + await Promise.all([ + this.sendToRadarr(), + this.sendToSonarr(), + this.sendToLidarr(), + ]); } @AfterInsert() @@ -753,7 +890,9 @@ export class MediaRequest { apiKey: radarrSettings.apiKey, url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'), }); - const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); + const movie = await tmdb.getMovie({ + movieId: Number(this.media.tmdbId), + }); const media = await mediaRepository.findOne({ where: { id: this.media.id }, @@ -970,7 +1109,7 @@ export class MediaRequest { apiKey: sonarrSettings.apiKey, url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'), }); - const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const series = await tmdb.getTvShow({ tvId: Number(media.tmdbId) }); const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; if (!tvdbId) { @@ -1261,10 +1400,8 @@ export class MediaRequest { const musicbrainz = new MusicBrainz(); const lidarr = new LidarrAPI({ apiKey: lidarrSettings.apiKey, - url: LidarrAPI.buildUrl(lidarrSettings, '/api/v3'), + url: LidarrAPI.buildUrl(lidarrSettings, '/api/v1'), }); - const music = await musicbrainz.getMusic({ mbId: this.media.mbId }); - const media = await mediaRepository.findOne({ where: { id: this.media.id }, }); @@ -1310,9 +1447,7 @@ export class MediaRequest { } } - if ( - media['status'] === MediaStatus.AVAILABLE - ) { + if (media['status'] === MediaStatus.AVAILABLE) { logger.warn('Media already exists, marking request as APPROVED', { label: 'Media Request', requestId: this.id, @@ -1325,56 +1460,110 @@ export class MediaRequest { return; } - const lidarrMusicOptions: LidarrMusicOptions = { - profileId: qualityProfile, - qualityProfileId: qualityProfile, - rootFolderPath: rootFolder, - title: music.title, - mbId: music.id, - year: Number(music.release_date.slice(0, 4)), - monitored: true, - tags, - searchNow: !lidarrSettings.preventSearch, - }; + if (this.media.secondaryType === SecondaryType.RELEASE) { + const release = await musicbrainz.getRelease(String(this.media.mbId)); + + const lidarrAlbumOptions: LidarrAlbumOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + title: release.title, + mbId: release.releaseGroup?.id ?? release.id, + monitored: true, + tags: tags.map((tag) => String(tag)), + searchNow: !lidarrSettings.preventSearch, + }; + + // Run this asynchronously so we don't wait for it on the UI side + lidarr + .addAlbum(lidarrAlbumOptions) + .then(async (lidarrAlbum) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); + } - // Run this asynchronously so we don't wait for it on the UI side - lidarr - .addMusic(lidarrMusicOptions) - .then(async (lidarrMusic) => { - // We grab media again here to make sure we have the latest version of it - const media = await mediaRepository.findOne({ - where: { id: this.media.id }, + media['externalServiceId'] = lidarrAlbum.id; + media['externalServiceSlug'] = lidarrAlbum.disambiguation; + media['serviceId'] = lidarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + + logger.warn( + 'Something went wrong sending music request to Lidarr, marking status as FAILED', + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + lidarrAlbumOptions, + } + ); + + this.sendNotification(media, Notification.MEDIA_FAILED); }); - - if (!media) { - throw new Error('Media data not found'); - } - - media['externalServiceId'] = - lidarrMusic.id; - media['externalServiceSlug'] = - lidarrMusic.titleSlug; - media['serviceId'] = lidarrSettings?.id; - await mediaRepository.save(media); - }) - .catch(async () => { - const requestRepository = getRepository(MediaRequest); - - this.status = MediaRequestStatus.FAILED; - requestRepository.save(this); - - logger.warn( - 'Something went wrong sending music request to Lidarr, marking status as FAILED', - { - label: 'Media Request', - requestId: this.id, - mediaId: this.media.id, - lidarrMusicOptions, + } else if (this.media.secondaryType === SecondaryType.ARTIST) { + const artist = await musicbrainz.getArtist(String(this.media.mbId)); + + const lidarrArtistOptions: LidarrArtistOptions = { + profileId: qualityProfile, + qualityProfileId: qualityProfile, + rootFolderPath: rootFolder, + mbId: artist.id, + monitored: true, + tags: tags.map((tag) => String(tag)), + searchNow: !lidarrSettings.preventSearch, + monitorNewItems: 'all', + monitor: 'all', + searchForMissingAlbums: true, + }; + + // Run this asynchronously so we don't wait for it on the UI side + lidarr + .addArtist(lidarrArtistOptions) + .then(async (lidarrArtist) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + }); + + if (!media) { + throw new Error('Media data not found'); } - ); - this.sendNotification(media, Notification.MEDIA_FAILED); - }); + media['externalServiceId'] = lidarrArtist.id; + media['externalServiceSlug'] = lidarrArtist.disambiguation; + media['serviceId'] = lidarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + const requestRepository = getRepository(MediaRequest); + + this.status = MediaRequestStatus.FAILED; + requestRepository.save(this); + + logger.warn( + 'Something went wrong sending music request to Lidarr, marking status as FAILED', + { + label: 'Media Request', + requestId: this.id, + mediaId: this.media.id, + lidarrArtistOptions, + } + ); + + this.sendNotification(media, Notification.MEDIA_FAILED); + }); + } + logger.info('Sent request to Lidarr', { label: 'Media Request', requestId: this.id, @@ -1394,7 +1583,7 @@ export class MediaRequest { private async sendNotification(media: Media, type: Notification) { const tmdb = new TheMovieDb(); - + const musicbrainz = new MusicBrainz(); try { const mediaType = this.type === MediaType.MOVIE ? 'Movie' : 'Series'; let event: string | undefined; @@ -1429,9 +1618,8 @@ export class MediaRequest { event = `${this.is4k ? '4K ' : ''}${mediaType} Request Failed`; break; } - if (this.type === MediaType.MOVIE) { - const movie = await tmdb.getMovie({ movieId: media.tmdbId }); + const movie = await tmdb.getMovie({ movieId: media.tmdbId as number }); notificationManager.sendNotification(type, { media, request: this, @@ -1450,7 +1638,7 @@ export class MediaRequest { image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, }); } else if (this.type === MediaType.TV) { - const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tv = await tmdb.getTvShow({ tvId: media.tmdbId as number }); notificationManager.sendNotification(type, { media, request: this, @@ -1477,24 +1665,34 @@ export class MediaRequest { ], }); } else if (this.type === MediaType.MUSIC) { - const music = await musicbrainz.getMusic({ mbId: media.tmdbId }); - notificationManager.sendNotification(type, { - media, - request: this, - notifyAdmin, - notifySystem, - notifyUser: notifyAdmin ? undefined : this.requestedBy, - event, - subject: `${music.name}${ - music.first_realease_date ? ` (${music.first_realease_date.slice(0, 4)})` : '' - }`, - message: truncate(music.overview, { - length: 500, - separator: /\s/, - omission: '…', - }), - image: `http://coverartarchive.org/${music.type}/${music.mbid}/front-250`, //TODO: Add coverartarchive - }); + if (this.media.secondaryType === SecondaryType.RELEASE) { + const music = await musicbrainz.getRelease(media.mbId as string); + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: `${music.title}${ + music.date ? ` (${music.date.toLocaleDateString()})` : '' + }`, + message: music.artist.map((artist) => artist.name).join(', '), + image: `http://coverartarchive.org/release/${music.id}/front-250`, + }); + } else if (this.media.secondaryType === SecondaryType.ARTIST) { + const artist = await musicbrainz.getArtist(media.mbId as string); + notificationManager.sendNotification(type, { + media, + request: this, + notifyAdmin, + notifySystem, + notifyUser: notifyAdmin ? undefined : this.requestedBy, + event, + subject: artist.name, + image: `http://coverartarchive.org/artist/${artist.id}/front-250`, + }); + } } } catch (e) { logger.error('Something went wrong sending media notification(s)', { 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/downloadtracker.ts b/server/lib/downloadtracker.ts index a45b37da..69a2ccc0 100644 --- a/server/lib/downloadtracker.ts +++ b/server/lib/downloadtracker.ts @@ -1,6 +1,6 @@ +import LidarrAPI from '@server/api/servarr/lidarr'; import RadarrAPI from '@server/api/servarr/radarr'; import SonarrAPI from '@server/api/servarr/sonarr'; -import LidarrAPI from '@server/api/servarr/lidarr'; import { MediaType } from '@server/constants/media'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -249,33 +249,32 @@ class DownloadTracker { if (server.syncEnabled) { const lidarr = new LidarrAPI({ apiKey: server.apiKey, - url: LidarrAPI.buildUrl(server, '/api/v3'), + url: LidarrAPI.buildUrl(server, '/api/v1'), }); try { const queueItems = await lidarr.getQueue(); this.lidarrServers[server.id] = queueItems.map((item) => ({ - externalId: item.seriesId, + externalId: item.musicId, estimatedCompletionTime: new Date(item.estimatedCompletionTime), - mediaType: MediaType.TV, + mediaType: MediaType.MUSIC, size: item.size, sizeLeft: item.sizeleft, status: item.status, timeLeft: item.timeleft, title: item.title, - episode: item.episode, })); if (queueItems.length > 0) { logger.debug( - `Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`, + `Found ${queueItems.length} item(s) in progress on Lidarr server: ${server.name}`, { label: 'Download Tracker' } ); } } catch { logger.error( - `Unable to get queue from Sonarr server: ${server.name}`, + `Unable to get queue from Lidarr server: ${server.name}`, { label: 'Download Tracker', } @@ -293,7 +292,7 @@ class DownloadTracker { if (matchingServers.length > 0) { logger.debug( - `Matching download data to ${matchingServers.length} other Sonarr server(s)`, + `Matching download data to ${matchingServers.length} other Lidarr server(s)`, { label: 'Download Tracker' } ); } 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 7b33d10a..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; } @@ -55,16 +55,17 @@ export interface ArrSettings { activeProfileId: number; activeProfileName: string; activeDirectory: string; - tags: number[]; isDefault: boolean; externalUrl?: string; syncEnabled: boolean; preventSearch: boolean; tagRequests: boolean; + tags: string[] | number[]; } export interface DVRSettings extends ArrSettings { is4k: boolean; + tags: number[]; } export interface RadarrSettings extends DVRSettings { @@ -82,7 +83,7 @@ export interface SonarrSettings extends DVRSettings { animeTags?: number[]; enableSeasonFolders: boolean; } - +export type LidarrSettings = ArrSettings; interface Quota { quotaLimit?: number; @@ -90,6 +91,7 @@ interface Quota { } export interface MainSettings { + fallbackImage: string; apiKey: string; applicationTitle: string; applicationUrl: string; @@ -127,6 +129,7 @@ interface FullPublicSettings extends PublicSettings { partialRequestsEnabled: boolean; cacheImages: boolean; vapidPublic: string; + fallbackImage: string; enablePushRegistration: boolean; locale: string; emailEnabled: boolean; @@ -308,6 +311,7 @@ class Settings { trustProxy: false, partialRequestsEnabled: true, locale: 'en', + fallbackImage: '/images/overseerr_poster_not_found_logo_top.png', }, plex: { name: '', @@ -528,6 +532,9 @@ class Settings { locale: this.data.main.locale, emailEnabled: this.data.notifications.agents.email.enabled, newPlexLogin: this.data.main.newPlexLogin, + fallbackImage: + this.data.main.fallbackImage ?? + '/images/overseerr_poster_not_found_logo_top.png', }; } 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 index e0593f9b..b2434aa3 100644 --- a/server/types/nodebrainz.d.ts +++ b/server/types/nodebrainz.d.ts @@ -1 +1,150 @@ -declare module 'nodebrainz'; +declare module 'nodebrainz' { + import type { + Artist, + Group, + Recording, + Release, + SearchOptions, + Work, + } from '@server/api/musicbrainz/interfaces'; + interface RawSearchResponse { + created: string; + count: number; + offset: number; + } + export interface ArtistSearchResponse extends RawSearchResponse { + artists: Artist[]; + } + export interface ReleaseSearchResponse extends RawSearchResponse { + releases: Release[]; + } + export interface RecordingSearchResponse extends RawSearchResponse { + recordings: Recording[]; + } + export interface ReleaseGroupSearchResponse extends RawSearchResponse { + 'release-groups': Group[]; + } + export interface WorkSearchResponse extends RawSearchResponse { + works: Work[]; + } + + export interface TagSearchResponse extends RawSearchResponse { + tags: { + score: number; + name: string; + }[]; + } + + export interface BrowseRequestParams { + limit?: number; + offset?: number; + artist?: string; + release?: string; + recording?: string; + 'release-group'?: string; + work?: string; + // or anything else + [key: string]: string | number | undefined; + } + + export interface luceneSearchOptions { + query: string; + limit?: number; + offset?: number; + } + + export default class BaseNodeBrainz { + constructor(options: { + userAgent: string; + retryOn: boolean; + retryDelay: number; + retryCount: number; + }); + artist( + artistId: string, + { inc }: { inc: string }, + callback: (err: Error, data: Artist) => void + ): Promise; + 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 = (